Free lesson · Microservices Interview
Database-Per-Service - Why and How
Database-Per-Service - Why and How
If there is one sentence that captures the hardest part of microservices, it is this: the data layer is where microservices become difficult.
Everything in the previous modules - decomposition, migration, communication - is either a structural decision (where to draw boundaries) or a transport decision (how to move data between services). This module addresses the problem that makes both of those decisions consequential: how do you manage data when each service owns its own database, ACID transactions cannot span services, and the system must still behave correctly?
Database-per-service is the foundational principle. Every other pattern in this module - sagas, event sourcing, CQRS, the outbox pattern, eventual consistency - exists because database-per-service creates problems that need solving. If you understand why database-per-service is non-negotiable and what pain it creates, you understand why the rest of this module exists.
The Principle
Each microservice owns a private database that no other service can access directly. No shared tables. No cross-service joins. No foreign keys spanning service boundaries. If Service B needs data that Service A owns, it requests it through Service A's API or subscribes to Service A's events.
This is not a suggestion. It is the defining data architecture property of microservices. Without it, the other properties - independent deployability, fault isolation, technology heterogeneity - are impossible to achieve. Services that share a database are coupled at the data layer regardless of how independent their codebases and deployment pipelines appear.
Important: Database-per-service is the most controversial microservices principle because it is the one that creates the most pain. Engineers who are comfortable with SQL joins, foreign key constraints, and ACID transactions across tables - which is most engineers - experience database-per-service as a loss. Things that were easy in a monolith (join orders with customers, transactionally update inventory and orders) become genuinely difficult. This pain is real, and the rest of this module teaches the patterns for managing it.
Why Shared Databases Destroy Microservices
To understand why database-per-service is non-negotiable, consider what happens when two services share a database.
Coupling Through Schema
If Service A and Service B both read from the customers table, a schema change to that table must be coordinated across both services. Adding a column, changing a column type, renaming a column, adding a NOT NULL constraint - any of these changes can break the other service. The services are coupled through the schema even though their codebases are separate.
This coupling defeats independent deployability. Service A cannot deploy a schema migration without verifying that Service B's code handles the new schema. If Service B has not been updated, Service A must wait. The deployment becomes a coordination exercise across teams - exactly the coordination bottleneck that microservices are supposed to eliminate.
Coupling Through Queries
If Service B writes a complex SQL query that joins across tables owned by different domains - SELECT * FROM orders JOIN customers ON orders.customer_id = customers.id JOIN inventory ON ... - it has created an implicit dependency on the structure of every table in that join. Any table restructuring, even within its "owning" domain, can break Service B's query.
Worse, these cross-domain queries are invisible. There is no API boundary, no contract, no versioning. The dependency exists only in SQL strings embedded in Service B's code. When it breaks, the failure appears at runtime - often in production - rather than at compile time or deployment time.
Coupling Through Resources
Two services sharing a database share its resources: connection pool, memory, CPU, disk I/O. A slow query from Service A's reporting module can degrade Service B's transactional operations. A lock held by Service A blocks Service B's writes. A spike in Service A's traffic exhausts the connection pool, causing Service B's requests to queue.
This resource coupling eliminates fault isolation. One of the primary benefits of microservices - a failure in Service A should not affect Service B - is destroyed when both services depend on the same database. The database becomes a shared single point of failure that contradicts the independence the microservices architecture was supposed to provide.
graph TD
subgraph Shared["Shared Database - All Coupling"]
SA["Service A"]
SB["Service B"]
SC["Service C"]
SA --> DB["Shared Database"]
SB --> DB
SC --> DB
DB --> Schema["Schema coupling:\nA's migration breaks B"]
DB --> Query["Query coupling:\nB's joins span A's tables"]
DB --> Resource["Resource coupling:\nA's slow query blocks B"]
end
This diagram shows three types of coupling caused by a shared database. Schema coupling means Service A's migration can break Service B. Query coupling means Service B's joins span Service A's tables, creating invisible dependencies. Resource coupling means Service A's slow query blocks Service B by exhausting shared database resources.
The Amazon Lesson
At Amazon, the shared database was the defining problem of the pre-microservices era. The retail platform's database was a massive Oracle instance that every part of the business accessed directly. A schema change to support a new product category could break the checkout flow. A reporting query from the finance team could slow down the product catalog. The database was the integration point that coupled every team to every other team.
The Bezos API mandate - "all data must be exposed through service interfaces" - was specifically aimed at breaking this coupling. The mandate was not about code architecture. It was about data ownership. Each team would own their data, expose it through APIs, and no other team would touch their database. This is database-per-service stated as a management directive.
What Database-Per-Service Means in Practice
Each Service Has Its Own Database Instance
The strongest form of database-per-service: each service runs its own database server (or database cluster). Service A uses PostgreSQL. Service B uses MongoDB. Service C uses DynamoDB. Complete resource isolation - no possibility of cross-service resource contention.
This is the recommended approach when the services have genuinely different data requirements (relational vs document vs key-value) or when resource isolation is critical (payment processing that cannot tolerate latency spikes from other services' workloads).
Each Service Has Its Own Schema on a Shared Instance
The pragmatic alternative: multiple services share a database instance but each service has its own schema (or database within the instance). Service A uses the orders schema. Service B uses the customers schema. They share the database engine's resources but cannot access each other's tables.
This approach provides schema isolation (Service A's migrations do not affect Service B's tables) and query isolation (no cross-schema joins without explicit configuration) but does not provide resource isolation (a slow query in the orders schema affects the shared instance). It is a reasonable starting point for organisations transitioning from a shared database, with the plan to move to separate instances as services mature and resource contention becomes measurable.
In PostgreSQL, this is implemented by creating separate databases or schemas within one instance, with separate database users for each service. The orders_svc user has access to the orders schema only. The customers_svc user has access to the customers schema only. Network policies in Kubernetes ensure that only the Order Service's pods can connect with the orders_svc credentials. This provides defense-in-depth: even if a developer tries to write a cross-schema query, the database credentials prevent it.
In AWS, the equivalent is using separate DynamoDB tables (inherently isolated) or separate RDS instances (full resource isolation). RDS instances are more expensive than separate schemas on a shared instance, but they provide the resource isolation that matters under load. Many teams start with shared-instance schemas and graduate to separate instances when they observe resource contention in production metrics.
Warning: The shared-instance approach is a stepping stone, not a destination. It provides schema isolation but not resource isolation. Under load, the services will contend for the shared instance's resources, reintroducing the resource coupling that database-per-service is supposed to eliminate. Plan to migrate to separate instances when resource contention becomes a problem - and instrument the database to detect contention before it becomes a crisis.
Access Only Through APIs
The ownership rule is enforced through access control and team discipline: no service accesses another service's database, period. If the Order Service needs customer data, it calls the Customer Service's API. It does not query the customers schema directly, even if it is on the same database instance and technically has network access.
This enforcement is partly technical (database credentials are scoped to one schema, network policies restrict database access to the owning service's pods) and partly cultural (code reviews flag direct database access to other services' data, architectural decisions document the ownership model). Technical enforcement prevents accidents. Cultural enforcement prevents deliberate shortcuts.
The Pain Database-Per-Service Creates
Being honest about the pain is important because the rest of this module teaches the patterns for managing it. If you do not understand the pain, the patterns seem like unnecessary complexity.
No More Joins
In a monolith with a shared database, displaying an order with the customer's name and the product details is a three-table join - fast, simple, and guaranteed consistent. With database-per-service, that same operation requires three separate calls: the Order Service for the order data, the Customer Service for the customer name, the Product Service for the product details. Three network round trips instead of one SQL join. Each call adds latency. Each can fail independently.
The latency difference is significant. A three-table SQL join on indexed columns typically completes in 1-5ms. Three network calls to separate services, even with gRPC, take 30-100ms. For a product listing page serving millions of requests per day, this difference is material - it affects the user experience and the infrastructure cost.
The mitigation patterns: local projections (the Order Service maintains a local copy of customer names updated through events - zero network calls on the read path), API composition (a BFF or API gateway assembles the data from multiple services - parallel calls reduce total latency), or denormalisation (the Order Service stores the customer name directly in the order record at creation time - simple but the data can become stale).
Each mitigation has its own tradeoff. Local projections add eventual consistency and the operational complexity of maintaining the projection. API composition adds latency and a failure dependency on multiple services. Denormalisation trades data freshness for query simplicity. There is no free solution - database-per-service moves the cost from one place (coupling) to another (complexity). The skill is choosing which cost is most acceptable for each specific use case.
No More Cross-Service Transactions
In a monolith, creating an order and decrementing inventory happens in one database transaction. Either both succeed or both roll back. ACID guarantees consistency. With database-per-service, the order lives in one database and the inventory lives in another. No ACID transaction can span both. If the order is created but the inventory decrement fails, the system is inconsistent - an order exists for stock that has not been reserved.
This is not a theoretical concern. It is the scenario that every production microservices system must handle. The Order Service writes to its PostgreSQL database. The Inventory Service writes to its DynamoDB table. There is no transaction coordinator that spans both. The databases do not know about each other.
The mitigation pattern: sagas (covered in Topic 4) - a sequence of local transactions coordinated by an orchestrator, with compensation logic for failures. Sagas provide eventual consistency rather than ACID consistency. The tradeoff is real: eventual consistency is harder to reason about, harder to debug, and requires explicit compensation logic for every failure scenario.
Tip: A useful interview framing for the join/transaction loss: "Database-per-service trades data-level simplicity for service-level independence. We lose joins, cross-service transactions, and referential integrity - but we gain independent deployment, fault isolation, and the ability for each team to evolve their data model without coordinating with other teams. The patterns in this module - sagas, CQRS, event sourcing, the outbox pattern - exist to manage the data complexity that this tradeoff creates."
No More Referential Integrity
In a monolith, foreign keys enforce referential integrity at the database level. An order cannot reference a customer that does not exist. With database-per-service, there are no foreign keys across service boundaries. The Order Service stores a customer_id as a plain value. If the customer is deleted from the Customer Service, the Order Service has orphaned references.
The mitigation patterns: soft deletes (never hard-delete entities that other services reference), event-driven cleanup (publish deletion events and let dependent services handle orphaned references), or validation at write time (the Order Service calls the Customer Service to verify the customer exists before creating the order).
Reporting Is Harder
In a monolith, reporting queries can join any tables in the database. A financial report that correlates orders, payments, customers, and products is a single SQL query. With database-per-service, this data lives in four different databases owned by four different services. The report cannot be a single query.
The mitigation pattern: a dedicated reporting database fed by CDC or events from all services. The reporting database is a read-only projection that combines data from multiple services in a schema optimised for analytical queries. This is the pattern we discussed in Module 03 (reporting data separation) and it applies here as well.
Interview: Interviewers expect you to acknowledge the pain, not just the principle. "I would use database-per-service because independent data ownership is what enables independent deployment and fault isolation. The tradeoff is that I lose joins, cross-service transactions, and referential integrity. For joins, I would use local projections via events. For transactions that span services, I would use the saga pattern. For referential integrity, I would use soft deletes and event-driven cleanup." This answer takes 20 seconds and shows you understand both the benefit and the cost.
When to Relax the Rule
Database-per-service is the target state, but there are practical situations where relaxing the rule is a reasonable trade - as long as the relaxation is deliberate and time-bounded.
During migration. While transitioning from a monolith to microservices (Module 03), services may temporarily share a database with separate schemas. This is acceptable as a transitional state with a plan to separate.
For tightly coupled bounded contexts. If two bounded contexts share a database and always deploy together, they may be part of the same service (an under-decomposition rather than a sharing violation). Revisit the decomposition - perhaps these should be one service.
For very small teams. A 5-person startup with two services that share a PostgreSQL instance is making a pragmatic trade: lower operational overhead now in exchange for coupling that will need to be addressed if the team grows.
In all three cases, the relaxation should be documented, the plan to resolve it should be time-bounded, and the coupling should be monitored so it does not deepen.
What Comes Next
Database-per-service means each service can choose its own database technology - a capability called polyglot persistence. The next lesson covers the decision framework for choosing the right database for each service: when PostgreSQL, when MongoDB, when Redis, when DynamoDB, when a time-series database. Emeka contributes his Netflix perspective on operating multiple database technologies across a microservices architecture.