Database per Service Pattern
IntermediateEach microservice owns its data store (SQL, NoSQL, graph) chosen for its workload; inter-service data access happens only through APIs, never direct DB queries.
Overview
Database per Service is a fundamental microservices data pattern: each service has its own private data store that no other service can access directly. This enforces loose coupling — services can evolve their schemas, migrate to different database technologies, and scale independently without coordinating with other teams. The pattern eliminates the "shared database" anti-pattern (the most common cause of microservices coupling) but introduces new challenges: **joins must become API calls**, **referential integrity must be enforced at the application layer**, and **distributed data consistency requires eventual consistency patterns** like the Saga and Outbox patterns.
Enforcing Isolation — One Schema, One Service
At minimum, each service should own its own schema (even within a shared DBMS) so that no other service can issue SQL across schema boundaries. Ideally each service has its own DBMS instance. Kubernetes helps: each service's database is a separate StatefulSet with its own PVC, and network policies block cross-service DB port access.
# docker-compose.yml (dev) — each service has its own DB instance
services:
order-service:
image: order-service:latest
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://order-db:5432/orders
order-db:
image: postgres:16
volumes: [order-data:/var/lib/postgresql/data]
inventory-service:
image: inventory-service:latest
environment:
SPRING_DATASOURCE_URL: jdbc:mongodb://inventory-mongo:27017/inventory
inventory-mongo:
image: mongo:7
# Kubernetes NetworkPolicy — deny direct DB access from other services
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: order-db-policy }
spec:
podSelector: { matchLabels: { app: order-db } }
ingress:
- from: [{ podSelector: { matchLabels: { app: order-service } } }]
ports: [{ port: 5432 }]Cross-Service Data Access — API calls and Materialised Views
When Service B needs data owned by Service A, it calls Service A's API (synchronous) or subscribes to Service A's events and maintains a local read-replica (asynchronous). The local copy is a **materialised view** — denormalised, kept eventually consistent by event consumption. This is faster than API calls on hot read paths and survives Service A being down.
// Option 1: Synchronous API call (simple, coupled)
@Service
class OrderService {
private final InventoryClient inventoryClient; // Feign / WebClient
public Order createOrder(CreateOrderRequest req) {
// Check stock — direct API call to inventory service
StockResponse stock = inventoryClient.checkStock(req.getSku());
if (stock.available() < req.getQuantity())
throw new InsufficientStockException();
// ...
}
}
// Option 2: Local materialised view via event consumption (better for reads)
@Entity
class ProductSnapshot { // owned by order-service DB
String sku;
String name;
BigDecimal price; // kept in sync by consuming ProductPriceUpdatedEvent
}
@KafkaListener(topics = "product-events")
void onProductEvent(ProductPriceUpdatedEvent event) {
productSnapshotRepo.findBySku(event.sku())
.ifPresent(p -> { p.setPrice(event.newPrice()); productSnapshotRepo.save(p); });
}Challenges: Distributed Joins and Referential Integrity
Relational concepts that are trivial in a shared DB become distributed systems problems. Cross-service JOINs must be done at the application layer (N+1 risk) or with materialised views. Referential integrity (FK constraints across services) is replaced by application-level validation + compensating transactions. The Outbox pattern ensures events are published atomically with DB writes.
// Cross-service "join" — application layer fan-out
public OrderDetailsDTO getOrderDetails(Long orderId) {
Order order = orderRepo.findById(orderId).orElseThrow();
// Parallel calls to avoid sequential N+1
CompletableFuture<CustomerDTO> customerFuture =
CompletableFuture.supplyAsync(() -> customerClient.get(order.getCustomerId()));
CompletableFuture<List<ProductDTO>> productsFuture =
CompletableFuture.supplyAsync(() -> productClient.getBatch(order.getSkus()));
CompletableFuture.allOf(customerFuture, productsFuture).join();
return new OrderDetailsDTO(order, customerFuture.join(), productsFuture.join());
}
// Outbox pattern — atomic event publish with DB write
// (solve dual-write problem between DB commit and message publish)
@Transactional
public Order createOrder(CreateOrderRequest req) {
Order order = orderRepo.save(new Order(req));
outboxRepo.save(new OutboxEvent("order.created", toJson(order)));
// Outbox poller (separate thread) reads + publishes to Kafka
return order;
}Key Points to Remember
- 1Each microservice owns its private data store — no other service can query it directly
- 2Different services can use different DB technologies chosen for their workload (polyglot persistence)
- 3Cross-service reads: call the API (synchronous) or maintain a local materialised view (async)
- 4Referential integrity becomes application-level validation + compensating transactions
- 5The Outbox pattern solves dual-write: persist event to DB in the same transaction as the state change
- 6Shared database is the most common microservices anti-pattern — it tightly couples schemas and teams
Interview Questions
Sign in to ask AriaWhy does the database-per-service pattern eliminate the need for cross-service JOINs?
How would you implement a cross-service query that previously was a SQL JOIN?
What is the dual-write problem and how does the Outbox pattern solve it?
What is polyglot persistence and when would you use it?
How do you enforce referential integrity between entities owned by different services?
Ask Aria about Database per Service Pattern
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.