Domain-Driven Schema Boundaries
Bounded contexts mapped to PostgreSQL schemas give you namespace isolation without the operational cost of separate databases.
Recipe
Quick-reference recipe card - copy-paste ready.
CREATE SCHEMA identity;
CREATE SCHEMA billing;
CREATE SCHEMA catalog;
REVOKE ALL ON SCHEMA billing FROM PUBLIC;
GRANT USAGE ON SCHEMA billing TO billing_app_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing TO billing_app_role;
CREATE TABLE identity.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE
);
CREATE TABLE billing.subscriptions (
subscription_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id uuid NOT NULL, -- logical reference; FK optional across contexts
plan_code text NOT NULL
);When to reach for this:
- Multiple teams share one PostgreSQL cluster but own different domains.
- You need clearer ownership than a flat
publicschema provides. - Cross-context coupling should be explicit (FK, event, or API) rather than accidental joins.
Working Example
BEGIN;
CREATE SCHEMA IF NOT EXISTS identity;
CREATE SCHEMA IF NOT EXISTS catalog;
CREATE SCHEMA IF NOT EXISTS fulfillment;
CREATE ROLE storefront_app LOGIN PASSWORD 'replace-me';
CREATE ROLE fulfillment_app LOGIN PASSWORD 'replace-me';
CREATE TABLE identity.customers (
customer_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE catalog.products (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL,
price numeric(12, 2) NOT NULL CHECK (price >= 0)
);
CREATE TABLE fulfillment.orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id uuid NOT NULL,
product_id bigint NOT NULL REFERENCES catalog.products (product_id),
quantity integer NOT NULL CHECK (quantity > 0),
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
-- Cross-context FK only where coupling is intentional
-- identity.customers is referenced logically by customer_id (no FK to avoid tight coupling)
REVOKE ALL ON SCHEMA identity, catalog, fulfillment FROM PUBLIC;
GRANT USAGE ON SCHEMA identity, catalog TO storefront_app;
GRANT SELECT, INSERT, UPDATE ON identity.customers TO storefront_app;
GRANT SELECT ON catalog.products TO storefront_app;
GRANT INSERT ON fulfillment.orders TO storefront_app;
GRANT USAGE ON SCHEMA catalog, fulfillment TO fulfillment_app;
GRANT SELECT ON catalog.products TO fulfillment_app;
GRANT SELECT, UPDATE ON fulfillment.orders TO fulfillment_app;
ALTER ROLE storefront_app SET search_path = identity, catalog, fulfillment, public;
ALTER ROLE fulfillment_app SET search_path = fulfillment, catalog, public;
COMMIT;What this demonstrates:
- One schema per bounded context (
identity,catalog,fulfillment). - Role-based
GRANTlimits which app can write which tables. search_pathper role keeps unqualified names predictable.- Cross-context reference via
customer_idwithout a hard FK when contexts evolve independently.
Deep Dive
How It Works
- PostgreSQL schemas are namespaces inside one database - not separate servers.
GRANT USAGE ON SCHEMAcontrols visibility; table privileges control read/write.ALTER ROLE ... SET search_pathavoidscatalog.productsprefixes in app SQL when desired.- Cross-schema FKs are supported but create deployment coupling - migrations in one context can block another.
Context Boundary Patterns
| Pattern | Coupling | When |
|---|---|---|
| Schema per context | Low-Medium | Shared cluster, distinct teams |
| FK across schemas | Medium-High | Strong consistency required |
| ID reference only | Low | Contexts version independently |
| Outbox/event table | Low | Async integration preferred |
SQL Notes
-- List tables by schema
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2;
-- Move a misplaced table into the right context
ALTER TABLE public.invoices SET SCHEMA billing;Gotchas
- Everything in
public- teams overwrite each other's naming conventions. Fix: create domain schemas on day one. - Cross-schema FK spaghetti - one migration failure blocks all contexts. Fix: prefer logical IDs plus application validation or eventual consistency.
- Overly broad
GRANT-GRANT ALL ON SCHEMA public TO appexposes catalog tables to billing. Fix: least-privilege grants per role. - Implicit
search_path- unqualifiedSELECT * FROM productshits the wrong schema. Fix: setsearch_pathper role or always qualify names. - Shared sequences across contexts - accidental ID collisions in logs. Fix: context-prefixed table names and separate identity columns.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Database per service | Hard isolation, independent scale | Small team, high cross-join reporting needs |
| Single schema + naming prefix | Tiny apps, one team | Multiple teams with conflicting table names |
| Row-level multi-tenancy | SaaS tenant isolation | Distinct domain ownership (not tenant isolation) |
| Materialized integration schema | Read models crossing contexts | You skip defining write ownership rules |
FAQs
Is a PostgreSQL schema the same as a DDD bounded context?
It is a practical mapping, not a perfect one. A bounded context is a linguistic and ownership boundary; a schema is a namespace tool. Align them when one team owns one context.
Should I use foreign keys across schemas?
Use them when both contexts deploy together and strong consistency matters. Skip them when teams ship migrations on independent cadences - use UUID references and reconciliation jobs instead.
How do I prevent cross-context SELECT in ad-hoc SQL?
Revoke SELECT on foreign schemas from app roles. Give analysts a read-only role with broader access on a replica.
Can one service own multiple schemas?
Yes - a platform team might own identity and access_control. Avoid one schema owned by many services; that recreates public chaos.
What goes in the integration or reporting schema?
Denormalized tables, materialized views, and outbox consumers - written by batch jobs, read by BI. Do not let OLTP services write there directly.
How do migrations work per schema?
Tools like Flyway can use schemas = billing per migration history table. Keep separate migration folders per bounded context in git.
Should contexts share enum types?
Duplicated text + CHECK per context reduces coupling. Shared ENUM types tie migration schedules together.
How does this interact with multi-tenant SaaS?
Tenant isolation (RLS, tenant_id) is orthogonal. You can have billing schema with tenant_id on every table - see Multi-Tenant patterns.
What is a good first step from a flat public schema?
CREATE SCHEMA legacy;
ALTER TABLE public.old_invoices SET SCHEMA legacy;Move tables in groups per team, then tighten grants.
How do I document context boundaries?
Use COMMENT ON SCHEMA plus an ADR listing owners, allowed cross-context calls, and forbidden joins.
Related
- Data Modeling Basics - schema grouping example
- ADR: Single Database vs Per-Service DB - when schemas are not enough
- Schema Evolution Strategy - evolving contracts across contexts
- Shared Schema + tenant_id - tenant isolation within a context
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.