One Writer, One Owner: What 'Private Schema' Actually Enforces
The diagram says billing and orders are separate services with separate databases. The reality: one Postgres instance, one credentials string pasted into six config files, and the billing developer who just added ALTER TABLE orders DROP COLUMN legacy_ref because it looked unused. At 02:14 the orders deploy failed on the missing column. Separate services, one schema, zero ownership — the deployment boundary was drawn; the data boundary was not.
Private schemas prevent services from coupling through each other's table structures, and the contract has four clauses:
| Clause | Mechanism | What it stops |
|---|---|---|
| One writer | Each table has exactly one service account with INSERT/UPDATE/DELETE | Foreign compensating writes and "quick fixes" at 2 a.m. |
| Private storage | Separate schema/database; other services get no credentials at all | Ad-hoc joins, SELECT fishing expeditions, schema peeking |
| Migrations with code | Flyway/Liquibase/knipper files live in the owning repo, reviewed by the owning team | Joint migration negotiation and deploy-order games |
| Schema is not an API | External consumers get versioned endpoints/events, never DDL knowledge | "We read your tables, so your migration must be additive" hostage situations |
The enforcement is a credential, not a convention. In practice:
# billing's deployment — note what is NOT here: no orders_dsn
DATABASE_URL: postgres://billing_svc@billing-db:5432/billing
# analytics gets read-only, on a replica, into reporting.* — never live schemas
On one shared instance, separate databases (or at minimum separate schemas with ALTER DEFAULT PRIVILEGES granting nothing to neighbor roles) plus per-service credentials deliver the guarantee. The test you can run today: \du and \dp in psql — if role billing has any privilege on orders.*, the boundary is a diagram.
Ownership extends to lifecycle. The owning team decides when a table is dropped, how it is backfilled, and what the retention policy is — because they own the SLO and the pager for the data. The consuming teams' legitimate needs are served by endpoints shaped for them (the consumer-owned interface idea applied to data), which brings the crucial design consequence: the owner's schema can change whenever the owner's use case changes, because nothing outside depends on its column layout. That is the entire prize of the pattern — the freedom to refactor storage without a cross-team meeting — and it is exactly what shared tables confiscate.
One writer also settles the transaction question before it is asked: transactions are local to one owner. There is no BEGIN spanning billing and orders, so any operation that appears to need both must be redesigned into a local transaction plus a message (the saga shape you will meet later), or accepted as two operations with a reconciliation path. Teams that resent this constraint are usually holding a boundary they drew too finely — worth knowing, but that is an argument for moving a seam, never for smuggling a distributed transaction through the database.
Interview framing: "how do services share data?" The clean answer: they don't — they share contracts. One writer per table, credentials as the enforcement, migrations co-located with the code, and reads through owned endpoints or copies. Then name what you give up (cross-service joins, one big transaction) and you have already answered the follow-up question they were going to ask next — which is what to do when you genuinely need another service's data, the subject of the next chapter.
Replacing the Join: APIs, Projections, and Which Facts Are Safe to Copy
The order-history page needs orders (owned by orders), payments (billing), and shipments (logistics). Before the pattern, the developer wrote one SQL join and went home. After the pattern, they have a choice to make — and the choice is a consistency decision, not a plumbing one.
Cross-service reads use APIs or replicated projections instead of shared joins. The three legal moves, with their honest costs:
| Move | How | Cost | Use when |
|---|---|---|---|
| Query the owner | GET /shipments?order=42 per hop, correlate in the caller |
Runtime dependency + latency; owner must stay available | The data must be current and the page tolerates fan-out |
| Local projection | Subscribe to OrderShipped, build shipment_summary table locally |
Eventual consistency; you own the rebuild story | Read-heavy views, decoupled availability required |
| Write-time copy | Duplicate a stable fact into your own record when you create it | Staleness only if the fact ever changes | Customer display name, product title — snapshot at use time |
The distinction that prevents production bugs: stable facts are safe to copy; live balances are not. Copying the customer's display name onto an order is correct even though it can go stale — nobody files a ticket because a two-year-old invoice shows a maiden name; the snapshot is supposed to reflect the order's time. Copying the customer's available credit is corruption: the copy is wrong the moment it is written, and two services will disagree about one number with no arbiter.
The projection option deserves its mechanics because done naively it is worse than the join it replaced. A materialized read model is only as good as its recovery path:
1. events are versioned and ordered per entity (order_id key from the comm topic)
2. consumer is idempotent — replays overwrite rather than duplicate rows
3. rebuild path exists: replay from t=0 or re-snapshot from the owner's API
4. staleness is bounded and monitored: projection_lag_seconds alert < agreed SLO
5. owner's events are a contract — additive-only, documented in the schema registry
Item 3 is the one teams skip. A projection with no rebuild procedure is a cache with extra steps, and the first bug (a handler deployed with an off-by-one in the transform) means either a careful replay or a hand-reconciled table. If you cannot rebuild it, you do not own it.
There is one more legitimate cross-service read pattern — reference data: currencies, countries, shipping-carrier codes. These are small, near-static, and universally needed. Fetch them from the owner's API on a schedule (or at startup) into a local read-only table with a last-refresh timestamp, and accept the hours of staleness because the data changes yearly. Treating reference data like a live join is latency theater; treating live data like reference data is a correctness incident.
The general principle ties the three moves together: every cross-service read converts a storage coupling (you knew my DDL) into a contract coupling (you consume my API or events). Contract coupling has an owner, a version, tests, and a deprecation path — the things a JOIN never had. That conversion is the whole point of the pattern, and it is what the next topic's anti-pattern forecloses: when services reach into each other's tables anyway, the contract evaporates and the deployment boundaries you paid for quietly reunite.
Quiz
1. Two services share one Postgres instance. What is the minimum enforcement that makes 'database per service' real?
2. Billing wants to show the customer's display name and their available credit on one screen, and it must be correct. How should each be obtained?
3. A dashboard materializes `shipment_summary` from `OrderShipped` events. Which requirement makes this design operable?
4. Why does a foreign key from orders.customer_id → customers.id undermine service autonomy?
Flashcards
Question
What is the four-part ownership contract of database-per-service?
Click to reveal answer
Answer
One writer per table (single service account with write privileges) · private storage (no credentials for neighbors — schema or database level) · migrations owned with the owning repo and team · schema is not an API (external consumers get versioned endpoints/events, never DDL knowledge). Enforcement = privileges, verified with \dp — not a wiki page.
Question
The three legal ways to read another service's data, and when each fits.
Click to reveal answer
Answer
Owner's API query + correlate: data must be current, page tolerates fan-out latency. Event-built local projection: read-heavy, availability decoupled — needs idempotent consumers, rebuild path, lag SLO. Write-time copy: only stable facts (display name, product title) snapshotted at creation. Live balances must never be copied.
Question
Why does a shared table recreate a distributed monolith even with twelve separately deployed services?
Click to reveal answer
Answer
Foreign reads, cross-owner FKs, and joint migrations reintroduce coordination: schema changes need multi-team negotiation and lockstep deploys, pools and maintenance windows are shared — so the release independence you bought evaporates while you keep paying for distribution. Symptom: a migration PR touching two services' tables.
Question
What are the four stages of escaping a shared database?
Click to reveal answer
Answer
1) Freeze — no new cross-owner reads today. 2) Replace each foreign read with the owner's API or an event-built projection (the list of reads is the hidden integration map). 3) Cut cross-owner FKs → application checks + reconciliation jobs. 4) Split storage with dual-write/backfill so physical ownership matches the deployment chart.
Question
What is the difference between a stable fact and a live value, with one example of each?
Click to reveal answer
Answer
A stable fact is effectively immutable once used — customer display name, product title — so copying it at write time is correct and even desirable as a historical snapshot. A live value (available credit, inventory count) changes constantly; a copy is wrong the moment it is written, so it must be fetched from the owner or rebuilt from its events.
Revision Notes
Key Takeaways
- 1. Database-per-service is enforced by credentials, not diagrams — if the neighbor role has any privilege, the data boundary does not exist
- 2. The prize is schema freedom: the owner refactors storage whenever its use case demands because no foreign code depends on its DDL
- 3. Cross-service reads become contract coupling (API, projection, stable-fact copy) with owners and versions — a JOIN has none of those
- 4. Projections are operable only with idempotent consumers, a rebuild path, and a monitored staleness SLO; a projection you cannot rebuild is a cache you do not control
- 5. Shared tables rebuild the monolith quietly — freeze, replace reads, cut foreign keys, then split storage — because the trap is the default result of not deciding
Interview Tips
- • 'How do services share data?' — they don't: one writer, private schemas, contracts for reads; then name the trade (no cross-service joins or big transactions) before they ask
- • 'What if a page needs three services' data?' — per-owner APIs correlated, or event-built projections with bounded staleness; distinguish stable facts (copyable) from live values (never)
- • 'Have you untangled a shared database?' — give the audit checklist and the four-stage plan: freeze, replace reads, cut FKs, split storage with dual-write
- • 'Why not just keep foreign keys across services?' — it is a cross-team DDL contract that puts one team's migrations on everyone's release train; move integrity to the owner's API and reconciliation
Cheat Sheet
Database per Service
Contract: one writer per table · private schema/db (no neighbor credentials) · migrations in the owner's repo · schema ≠ API (contracts are endpoints/events).
Verify in 30 seconds:
\du -- which roles exist
\dp -- any privilege on orders.* held by billing? → boundary is a diagram
DATABASE_URL: postgres://billing_svc@billing-db/billing # and no orders_dsn anywhere
Cross-service reads (only three):
| Move | Cost | Fits |
|---|---|---|
| Owner API + correlate | runtime dependency, fan-out latency | must-be-current reads |
| Event-built projection | eventual consistency, rebuild story needed | read-heavy, decoupled availability |
| Write-time copy | staleness only if fact changes | stable facts only (name, title) |
Never copy: balances, counts, anything mutable. Projection ops: versioned per-entity events · idempotent consumer · rebuild path · lag alert vs SLO · additive-only event schema.
Shared-table audit: foreign reads · cross-owner FKs · migration PR spanning owners · 12-service pool on one primary · multi-team maintenance windows.
Escape: freeze → replace reads (API/projection) → cut FKs (app checks + reconciliation) → split storage (dual-write/backfill).
Line to remember: services don't share data, they share contracts; a JOIN had no owner, no version, no deprecation path.