Database per service
Why microservices need isolated databases, the cross-service query and consistency challenges this creates, and the practical strategies (CQRS, API composition, sagas) for working around shared-data limitations.
TL;DR
- Each microservice owns its database exclusively. No other service reads from or writes to it directly.
- Shared databases create schema coupling (one team's migration breaks another service), deployment coupling (can't deploy independently), and scaling coupling (can't scale storage independently).
- Cross-service queries lose SQL JOINs. Replace them with API composition, CQRS read models, or bounded data duplication.
- Cross-service transactions require the saga pattern (local transactions with compensating actions). True distributed transactions (2PC) are avoided in practice.
- Polyglot persistence becomes possible: each service picks the optimal database for its workload (PostgreSQL, Redis, Elasticsearch, Cassandra).
The Problem
Your company runs an e-commerce monolith backed by a single PostgreSQL database. The Orders, Users, Products, and Inventory tables all live in one schema. Three teams (Order Team, User Team, Product Team) deploy from the same codebase.
The User Team needs to rename users.email to users.primary_email and add a users.secondary_email column. This is a routine migration. But the Order Service has a JOIN that references users.email. The Product Service has a reporting query that filters on users.email. Both break.
The User Team can't deploy without coordinating with two other teams. The Order Team can't scale their database independently because it shares storage with User and Product data. A runaway query from the Product Service's nightly report locks rows that the Order Service needs for checkout.
This is not a theoretical problem. I've seen shared databases turn three-week sprints into three-month coordination nightmares. Every schema change becomes a cross-team negotiation. Every deployment window requires all three teams to be available. The shared database is the hidden monolith that survives long after you've split the application code.
One-Line Definition
Database-per-service gives each microservice exclusive, private ownership of its persistence layer, eliminating schema, deployment, and scaling coupling between services.
Analogy
Think of apartments in a building vs a shared house. In a shared house, one roommate rearranging the kitchen affects everyone. In an apartment building, each tenant controls their own unit's layout, locks, and appliances independently. The building provides shared infrastructure (electricity, plumbing), but no tenant can modify another's space. Database-per-service is the apartment model: each service owns its data, and the "building" provides shared infrastructure (networking, observability).
Solution Walkthrough
Polyglot Persistence
Isolation enables each service to choose the optimal store for its workload:
| Service | Database | Reason |
|---|---|---|
| UserService | PostgreSQL | Relational, ACID, strong consistency |
| ProductService | Elasticsearch | Full-text search, faceted filters |
| SessionService | Redis | TTL-based, sub-millisecond reads |
| OrderService | PostgreSQL | Transactional, audit trail |
| ActivityFeedService | Cassandra | Append-only, high write throughput |
| RecommendationService | Neo4j | Graph traversal for relationships |
No more org-wide "we use MySQL for everything" decisions that don't fit every workload. Each team picks the right tool, and the technology choice stays local to the service.
Solving the Cross-Service Query Problem
The hard part of database-per-service is queries that span multiple services. In a shared database, this was trivial:
-- Trivial in a shared database:
SELECT o.id, o.total, u.name, u.email
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
With isolated databases, you can't do cross-service JOINs. Three alternatives exist:
Option 1: API Composition. The calling service (or an API gateway) fetches from each service separately and joins in application code:
async function getPendingOrdersWithUsers(): Promise<OrderWithUser[]> {
const orders = await orderService.getPendingOrders();
const userIds = [...new Set(orders.map(o => o.userId))];
const users = await userService.getUsersBulk(userIds);
const userMap = new Map(users.map(u => [u.id, u]));
return orders.map(order => ({
...order,
user: userMap.get(order.userId)!,
}));
}
This works for small result sets. At scale, two sequential network round trips and potential N+1 problems accumulate latency.
Option 2: CQRS with a read-optimized view. Build a separate read model that denormalizes data from multiple services:
The read model is eventually consistent (updated asynchronously from events). Queries are fast because data is pre-joined. The trade-off is operational complexity and propagation delay.
Option 3: Bounded data duplication. Services store minimal copies of data they need from other services:
OrderService stores in its own DB:
user_id (foreign reference to UserService)
user_name (denormalized copy, may be stale)
user_email (denormalized copy, may be stale)
When UserService updates a name, it emits a UserNameChanged event, and OrderService updates its local copy. This is eventual consistency applied to duplicated fields. Keep the duplicated fields minimal and non-critical.
Saga Pattern for Cross-Service Transactions
With isolated databases, you can't wrap a multi-service operation in a SQL transaction. The saga pattern replaces distributed transactions with a sequence of local transactions and compensating actions on failure:
Each service executes a local transaction. If a step fails, the orchestrator runs compensating transactions for all previously completed steps, in reverse order. This gives you eventual consistency across services without distributed locks.
Two-phase commit (2PC) is the alternative, but it's rarely used in practice. The coordinator becomes a single point of failure, and participants hold locks until the coordinator decides. In a microservices environment where services deploy independently and network partitions happen, 2PC creates more problems than it solves.
Migration: Shared DB to Isolated Databases
Splitting a monolith's shared database requires the Strangler Fig pattern applied to data:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.