Database-Per-Service and the Shared-DB Temptation
What This Concept Is
Database-per-service is the rule that each microservice owns its own database (or schema) and is the only piece of software that reads or writes its tables directly. All access by other services goes through the owning service's API or through events the service publishes.
The rule matters because a shared database silently re-introduces every coupling microservices were meant to break:
- schema changes require coordinated deploys (distributed monolith)
- one team's query load affects another team's latency (operational coupling)
- invariants become ambiguous: who enforces them?
- service boundaries become fiction, because the real coupling is at the row level
The alternative -- "shared-DB microservices" -- is the single most common cause of failed decompositions.
Why It Matters Here
Data ownership is where the decomposition becomes real. A team that shares tables with another team does not own its service, no matter what the org chart says. The whole value proposition of independent deployment depends on the database belonging to exactly one service.
Concrete Example
E-commerce retailer. The Orders service and the Fulfillment service.
Wrong (shared DB):
orders_db
├── orders <- Orders writes, Fulfillment reads
├── order_items <- Orders writes, Fulfillment reads
└── shipments <- Fulfillment writes, Orders reads
A change to orders.status column (say, adding partially_shipped) now breaks Fulfillment queries. The schema is a coupling point neither team owns.
Right (database-per-service):
orders_db (owned by Orders service)
├── orders
├── order_items
└── order_events (outbox)
fulfillment_db (owned by Fulfillment service)
├── shipments
├── shipment_items
└── fulfillment_events (outbox)
Fulfillment subscribes to OrderConfirmed events from Orders. It stores its own denormalized projection of whatever it needs (customer_id, items to ship). Orders subscribes to ShipmentDispatched events and updates its own order_status accordingly.
Each service is deployable alone. Each team migrates its schema without a meeting.
Cross-Service Data Without Shared DB
Three legitimate patterns, in increasing loosely-coupled order:
- API composition. Caller calls multiple services, joins in memory. Good for low-volume read paths, bad for high-fanout UIs.
- CQRS read models. Each service publishes events; consumers build their own read-optimized views. Good for complex UIs or reports.
- Event-carried state transfer. Publish events containing the state the downstream needs. Downstream stores and uses its own copy, treating it as a read-only cache.
Which you pick depends on freshness requirements, call volume, and how tolerant the consumer can be of eventual consistency.
Common Confusion / Misconception
"We just use the same database but different tables, so it is fine." If service A writes a table and service B reads the same table, the schema of that table is now a public contract, owned by neither team. Every schema change is a breaking change. You have a shared DB with extra steps.
Second confusion: "reports need to join across services, so we need a shared DB." Reports are a consumer, not a producer. Build a reporting data warehouse that subscribes to service events. Do not make every service's OLTP database a public read surface.
How To Use It
- Draw the service list from cluster 2.
- For each service, name its database (
orders_db,fulfillment_db, ...) and the tables or aggregates it owns. - Identify every cross-service data need. For each one, classify it:
- synchronous, small, fresh -> API composition
- async, large, tolerates eventual consistency -> events
- complex read, multiple sources -> CQRS projection
- Name the integration pattern next to each cross-service data flow.
- Reject any design where a service reads another service's tables directly.
Example Owned-Data Table
For the e-commerce decomposition (carrying forward from concept 05):
| Service | Database | Owned aggregates | Consumes (via events) |
|---|---|---|---|
| Catalog | catalog_db | Product, Category | -- |
| Cart | cart_db | Cart, CartItem | ProductUpdated |
| Orders | orders_db | Order, OrderItem, OrderEvent (outbox) | ProductUpdated, ShipmentDispatched |
| Payments | payments_db | PaymentAttempt, Refund | OrderConfirmed |
| Inventory | inventory_db | StockItem, Reservation | OrderConfirmed, ShipmentDispatched |
| Fulfillment | fulfillment_db | Shipment, PackagingRule | OrderConfirmed, ReservationConfirmed |
This table is the deliverable of a real decomposition memo.
Check Yourself
- Why is "different tables in the same database" still effectively a shared database?
- Name three legitimate ways to get cross-service data without a shared DB.
- Why does reporting not justify a shared OLTP database?
Mini Drill or Application
Take the decomposition from the concept-05 mini drill. For each service, list:
- the database name
- 3-5 owned aggregates
- the top 2 cross-service data flows and their pattern (sync/events/CQRS)
15 minutes.
How This Sits In The Module
This concept is where the "bounded context = service" claim from concept 04 becomes enforceable. Concept 08 defines the contract shapes that carry cross-service data; concept 09 keeps those contracts honest.
Migration Playbook: Breaking a Shared Database
The most common real situation is a live shared DB and two services that already read/write each other's tables. The migration sequence that actually works:
- Catalogue the coupling. List every cross-service table access (which service reads, which service writes, which columns). This is the shared surface.
- Draw ownership lines on paper first. For each table, decide the single owner. Any service currently reading the table must be retrained to go through the owner's API or events.
- Introduce the outbox pattern. The owner writes changes to an
outboxtable in the same transaction as the domain change; a relay publishes those as events to the bus. See Chris Richardson's Transactional Outbox. This preserves atomicity without 2PC. - Build consumer-owned projections. Each consumer subscribes to events and maintains its own read model in its own DB. Reads switch to the local projection.
- Sever the cross-service reads. Once projections are live and correct (shadow-compare for a deprecation window), revoke the consumer's direct DB access.
- Physically separate the databases. Move the owner's tables into a separate schema, then a separate instance. Blue/green with traffic cutover.
Each step is safe in isolation and reversible. Running the whole migration in one step is usually the cause of the first outage.
Reporting and Analytics Without a Shared DB
"But our BI team joins across 9 services." Reporting is a legitimate cross-service concern, and it does not justify a shared OLTP DB. Three patterns:
- Events -> Data Lake/Warehouse. Every service emits domain events to a topic (Kafka or equivalent). A single pipeline (Debezium CDC, Airbyte, Fivetran, or a custom consumer) loads them into a warehouse (Snowflake, BigQuery, Redshift). BI joins freely in the warehouse.
- Read-only replicas per service, unified at a BI layer. Each service has a read replica exposed to the BI team; the BI tool (Looker, dbt, Metabase) joins across them. Looser coupling than shared DB; slower than warehouse.
- Materialized views maintained by a reporting service. A dedicated reporting service subscribes to events and builds denormalized read models optimized for queries.
The warehouse pattern is standard; the key property is that the OLTP databases remain private to their owning services. BI is a consumer, not a co-owner.
Read This Only If Stuck
Local chunks
- Primer: Database Federation and Sharding -- especially the "Federation" section, which is the physical analogue of DB-per-service.
- Primer: RDBMS and Replication -- for the "reporting = read replicas of services" idea.
- Primer: SQL or NoSQL -- polyglot persistence is a direct consequence of DB-per-service.
- Primer: Consistency Patterns -- eventual consistency is the price of DB-per-service; know the vocabulary.
- Primer: CAP Theorem -- why cross-service transactional consistency is rarely available.
- FoSA: Database Partitioning -- the "database per service" choice in the partitioning framework.
- FoSA: Preventing Data Loss -- outbox + relay pattern in the reliability frame.
External canonical references
- Chris Richardson, Database per service -- the canonical pattern page.
- Chris Richardson, Shared database -- Richardson calls this an anti-pattern explicitly; read the "Problems" list.
- Chris Richardson, Transactional Outbox -- the key technique for atomic state + event publishing.
- Martin Fowler, PolyglotPersistence -- the principle that different services can and should use different data stores.
- Pat Helland, Life beyond Distributed Transactions: An Apostate's Opinion -- foundational paper on why cross-service transactions are usually wrong.
- Microsoft Learn, Database per service pattern -- enterprise framing with trade-offs.
- Confluent, Change data capture with Debezium -- practical CDC for the outbox pattern.
Depth Path
- Chris Richardson, Microservices Patterns, chapters 4-7 -- sagas, CQRS, and managing data across services. The pattern that comes next once your services own their data and you need cross-service consistency. Picked up properly in S8 M3.
- Martin Kleppmann, Designing Data-Intensive Applications, chapter 11 ("Stream Processing") -- the theoretical foundation for event-driven cross-service data.