Domain-Driven Schema Boundaries
Bounded contexts mapped to PostgreSQL schemas give you namespace isolation without the operational cost of separate databases.
Search across all documentation pages
Bounded contexts mapped to PostgreSQL schemas give you namespace isolation without the operational cost of separate databases.
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:
public schema provides.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:
identity, catalog, fulfillment).GRANT limits which app can write which tables.search_path per role keeps unqualified names predictable.customer_id without a hard FK when contexts evolve independently.GRANT USAGE ON SCHEMA controls visibility; table privileges control read/write.ALTER ROLE ... SET search_path avoids catalog.products prefixes in app SQL when desired.| 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 |
-- 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;public - teams overwrite each other's naming conventions. Fix: create domain schemas on day one.GRANT - GRANT ALL ON SCHEMA public TO app exposes catalog tables to billing. Fix: least-privilege grants per role.search_path - unqualified SELECT * FROM products hits the wrong schema. Fix: set search_path per role or always qualify names.| 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 |
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.
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.
Revoke SELECT on foreign schemas from app roles. Give analysts a read-only role with broader access on a replica.
Yes - a platform team might own identity and access_control. Avoid one schema owned by many services; that recreates public chaos.
Denormalized tables, materialized views, and outbox consumers - written by batch jobs, read by BI. Do not let OLTP services write there directly.
Tools like Flyway can use schemas = billing per migration history table. Keep separate migration folders per bounded context in git.
Duplicated text + CHECK per context reduces coupling. Shared ENUM types tie migration schedules together.
Tenant isolation (RLS, tenant_id) is orthogonal. You can have billing schema with tenant_id on every table - see Multi-Tenant patterns.
CREATE SCHEMA legacy;
ALTER TABLE public.old_invoices SET SCHEMA legacy;Move tables in groups per team, then tighten grants.
Use COMMENT ON SCHEMA plus an ADR listing owners, allowed cross-context calls, and forbidden joins.
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+.
Reviewed by Chris St. John·Last updated Jul 18, 2026