Skip to main content

Database per Service

The Database per Service pattern requires that each microservice owns its private database. Other services cannot access this database directly. Instead, they must query or update data strictly via the owner service's public APIs (HTTP, gRPC, or events).

This is a fundamental pattern for achieving loose coupling in a microservices architecture.


Why Shared Databases (Integration DB) Fail

In a monolithic application, different modules join tables freely. Exposing this database to multiple microservices creates a severe architectural bottleneck:

Integration Database Anti-Pattern vs. Polyglot Database per Service
Order Service
Payment Service
User Service
↓↓↓ Direct Table Access & Joins ↓↓↓
SINGLE SHARED DATABASE INSTANCE
Schema Locking Β· Thread Pool Exhaustion Β· Forced Monolithic Releases
Why Shared Databases Fail: Direct joins across service tables create hidden runtime dependencies, force coordinated deployments across teams for simple column renames, and lead to DB CPU starvation.
  • Schema Locking: A simple table change (e.g., renaming a column in users) requires coordinated deployments across all services.
  • Resource Starvation: A slow query written by the Order Service can consume all CPU threads on the database host, slowing down the completely unrelated User Service.
  • Technology Locking: All services are forced to use the same database technology (e.g., Oracle SQL), even if one service would benefit from a Document DB (MongoDB) or Search Index (Elasticsearch).

Cross-Service Data Retrieval Options

Since direct SQL joins (JOIN orders ON users.id = orders.user_id) are impossible, systems implement three options:

1. API Composition (Query-Time Aggregation)

The client or API Gateway requests data from each service in parallel and merges the results in code.

API Composition (Parallel Query Aggregation) Visualizer
Client / BFF
GET /dashboard
GET /dashboard
User Service (45ms)
Order Service (85ms)
Loyalty Service (30ms)
API Composition: Queries are executed concurrently using non-blocking thread pools or CompletableFuture.allOf(). Total latency equals the slowest downstream service response (85ms) plus minimal JSON merging overhead.
  • Pros: Easy to implement. Good for simple joins.
  • Cons: Adds latency (multiple network calls) and memory overhead.

2. CQRS (Command Query Responsibility Segregation)

Builds a read-only database optimized for complex queries. The read-only service consumes events (e.g., OrderCreated, CustomerUpdated) and maintains a projection index (e.g., in Elasticsearch).

CQRS (Command Query Responsibility Segregation) Architecture
Command Client
CreateOrderCommand
Write Model (Postgres)
ACID Transactions
Kafka Broker
OrderCreatedEvent
Projection Builder
Async Worker
Command Side: Optimized strictly for write operations, enforcing domain invariants and transaction integrity. State changes emit domain events to Kafka.
  • Pros: Fast, highly scalable queries. Avoids network join overhead.
  • Cons: High complexity; read-model suffers from eventual consistency lag.

3. Shared Read Replicas (Read Only DB access)

Shared Read Replicas Architecture & Schema Coupling Hazards
Order Service (Owner)
↓ Writes
Primary DB (Master)
WAL / Binlog Replication
Reporting Service (Caller)
↓ Read-Only Queries
Read Replica DB
Tradeoff Analysis: Shared Read Replicas simplify reporting queries without overloading the primary database master, but they recreate database schema coupling β€” any DDL change by the owner service will still break read queries in caller services.

The owner service writes to its primary database and replays transactions to a replica database. Selected caller services are permitted to read (but never write) from this replica.

  • Pros: Simplifies complex reporting queries.
  • Cons: Couples services to the database schema of the replica.

Code Example: API Composition Aggregator

Below is a Spring Boot service implementing parallel queries using Java CompletableFuture to aggregate details across three remote APIs:

@Service
@RequiredArgsConstructor
public class CustomerDashboardService {

private final UserClient userClient;
private final OrderClient orderClient;
private final LoyaltyClient loyaltyClient;

public DashboardData getDashboard(String customerId) throws Exception {
// Trigger parallel requests
CompletableFuture<UserDto> userFuture =
CompletableFuture.supplyAsync(() -> userClient.getUser(customerId));

CompletableFuture<List<OrderDto>> ordersFuture =
CompletableFuture.supplyAsync(() -> orderClient.getOrders(customerId));

CompletableFuture<Integer> pointsFuture =
CompletableFuture.supplyAsync(() -> loyaltyClient.getPoints(customerId));

// Wait for all to complete with a strict global timeout
CompletableFuture.allOf(userFuture, ordersFuture, pointsFuture)
.get(2, TimeUnit.SECONDS);

return DashboardData.builder()
.profile(userFuture.join())
.orders(ordersFuture.join())
.loyaltyPoints(pointsFuture.join())
.build();
}
}

Pros vs. Cons

ProsCons
Independent Deployability: Services change their internal schemas freely without coordinating releases.Query Complexity: Merging data across services requires custom API composition or complex CQRS setups.
Polyglot Persistence: Each service optimizes its storage technology (SQL, NoSQL, Elasticsearch, Redis).Transactional Complexity: Distributed updates require Saga coordination patterns instead of simple ACID transactions.
Resource Isolation: Slow database operations are sandboxed to a single service domain.Operational Overhead: Platform engineers must maintain, back up, and monitor N distinct databases.

Common Gotchas & Anti-Patterns

  1. Logical Shared Databases: Running separate schemas inside the same physical database instance. While this solves schema coupling, it fails resource isolation; a database deadlock or CPU lockup in one service will still crash all other services.
  2. Sync Loop Joins: Querying a list of 100 orders, and then making 100 synchronous HTTP requests to User Service to fetch customer profiles one by one (the N+1 query problem).
    • Solution: Provide batch APIs (GET /users?ids=1,2,3...).
  3. Data Drift: If Event Sourcing or CQRS data-sync jobs fail silently, read models will display stale data indefinitely. Set up reconciler jobs to periodically compare record states.
πŸ“–
Track Page Progress0 / 635 Read
Knowledge Base Completion0%