Skip to content
intermediate Phase 5 · Microservices Criteria and Service Boundaries

Database per Service

Give each service exclusive data ownership and define how others read it.

55m
0 problems
Topic Progress 0%

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.

The Shared Tables Trap: How Separate Processes Become One Unit Again

A company runs twelve services, Kubernetes, a service mesh — and one shared Postgres with 340 tables. New engineers are told which tables their service owns, in a wiki page last edited eighteen months ago. The lead engineer can name three incidents this quarter caused by "who writes customers.status?" The processes are distributed; the data is not. Shared database tables recreate a distributed monolith despite separate deployments, and the symptoms are consistent enough to audit for.

The diagnostic checklist:

[ ] a service reads another's tables 'just for a report'
[ ] foreign keys cross ownership lines (orders.customer_id → customers.id)
[ ] one migration PR touches tables of two services → two teams review, two deploys
[ ] connection pool exhaustion: 12 services × 20 conns against one primary
[ ] schema change needs a maintenance window agreed by three teams
[ ] 'temporary' stored procedures join five owners' tables for the dashboard

Each item is a joint-deploy requirement wearing SQL. The foreign-key line is the subtlest: it looks like good data modeling and is actually a cross-team DDL contract — you cannot drop or retype customers.id without coordinating every referencing service, which means customers' schema changes ship on everyone's release train. Referential integrity across an ownership line buys a constraint at the price of the autonomy you split for.

Why the trap forms is worth understanding, because nobody chooses it. The first shared table arrives during the first migration out of the monolith: "we need the customer's email in billing for now, just read it from customers — we'll fix it later." Then three more. Each read is locally reasonable; collectively they rebuild the monolith's coupling at 2 a.m., when the fix never comes and the wiki page rots. The trap is not a design decision, it is the default result of not deciding, which is why the enforcement from the first chapter (no credentials, no FKs across lines, CI schema-ownership checks) matters more than any diagram.

Exit strategies, because many readers inherited this:

Stage Move Reversibility gained
1. Freeze No new cross-owner reads; the rule applies from today Stops the bleeding immediately
2. Replace reads Each foreign read becomes owner API or event-built projection, prioritized by change frequency Cross-owner runtime coupling becomes contract coupling with owners
3. Cut FKs Replace FK with application-level checks + reconciliation jobs Schema changes stop needing joint negotiation
4. Split storage Move tables to per-service databases; dual-write/backfill during transition Physical ownership matches the deployment chart

Stage 2 is where the real migration effort goes, and it doubles as documentation: the list of foreign reads is the list of integration points the shared DB was hiding. Interviewers who have lived through this light up at the checklist — recite two or three lines, then give the stage plan and the enforcement (credentials as the rule), and the answer lands as experience rather than theory. It also sets up the failure mode the next topic dissects: when the coordination you removed with boundaries comes back through shared libraries, synchronous chains, and release lockstep.

Quiz

1. Two services share one Postgres instance. What is the minimum enforcement that makes 'database per service' real?

Question 1 options

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?

Question 2 options

3. A dashboard materializes `shipment_summary` from `OrderShipped` events. Which requirement makes this design operable?

Question 3 options

4. Why does a foreign key from orders.customer_id → customers.id undermine service autonomy?

Question 4 options

Flashcards

Question

What is the four-part ownership contract of database-per-service?

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.

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?

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?

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?

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.

Continue Learning