Few microservices decisions split teams the way the database question does. One camp says database-per-service is the only honest way to run independent services. The other camp watches teams hit eventual consistency, saga compensation logic, and the operational drag of running four databases instead of one, and asks if the original problem was really that bad.
Both camps have a point. Teams running database-per-service do report faster deploy cycles and better team autonomy. They also report the predictable tax: harder cross-service queries, more failure modes to handle, more infrastructure to babysit. Shared databases keep ACID and simple joins. They also create the coordination problems that pushed people toward microservices in the first place.
The 2025 picture is less dogmatic than the 2018 one. Saga orchestrators are mature, Debezium 3.0 made change-data-capture boring in a good way, and managed services like Aurora DSQL and CockroachDB Serverless blur the line between "one database" and "many." The right answer depends on team size, deployment frequency, transaction shape, and how much pain you can absorb. That is what this guide is built around.
If you are also questioning whether microservices were the right call to begin with, my migration guide from microservices to modular monolith is the companion piece.
The two patterns at a glance
Shared database pattern
All services access a single database:
// Services access same database directly
// Order Service
class OrderService {
async createOrder(data: CreateOrderDTO): Promise<Order> {
return this.db.query(
`INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING *`,
[data.customerId, data.total]
);
}
}
// Customer Service
class CustomerService {
async getCustomer(id: string): Promise<Customer> {
return this.db.query(
`SELECT * FROM customers WHERE id = $1`,
[id]
);
}
// Can directly query orders table
async getCustomerWithOrders(id: string): Promise<CustomerWithOrders> {
return this.db.query(
`SELECT c.*,
json_agg(o.*) as orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.id = $1
GROUP BY c.id`,
[id]
);
}
}
// Payment Service
class PaymentService {
async processPayment(orderId: string, amount: number): Promise<void> {
await this.db.transaction(async (tx) => {
// Direct transaction across entities
await tx.query(
`UPDATE orders SET status = 'paid' WHERE id = $1`,
[orderId]
);
await tx.query(
`INSERT INTO payments (order_id, amount) VALUES ($1, $2)`,
[orderId, amount]
);
});
// ACID guarantees maintained
}
}
Benefits:
- ACID transactions across entities, no compensation logic needed
- Joins and analytical queries are trivial
- One source of truth, no duplicated data to keep in sync
- Operations stay simple: one cluster to patch, back up, monitor
Drawbacks:
- Services end up coupled at the schema layer
- Schema changes require coordination across teams
- Deployments often have to happen together
- Scaling is bounded by what the single database can do
- The database team becomes everyone's bottleneck
Database per service pattern
Each service owns its database:
// Each service has its own database
// Order Service (orders_db)
class OrderService {
async createOrder(data: CreateOrderDTO): Promise<Order> {
// Only access orders database
return this.ordersDb.query(
`INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING *`,
[data.customerId, data.total]
);
}
}
// Customer Service (customers_db)
class CustomerService {
async getCustomer(id: string): Promise<Customer> {
// Only access customers database
return this.customersDb.query(
`SELECT * FROM customers WHERE id = $1`,
[id]
);
}
// Cannot directly join with orders - must call Order Service API
async getCustomerWithOrders(id: string): Promise<CustomerWithOrders> {
const customer = await this.getCustomer(id);
// API call to Order Service
const orders = await this.orderServiceClient.getOrdersByCustomer(id);
return {
...customer,
orders
};
}
}
// Payment Service (payments_db)
class PaymentService {
async processPayment(orderId: string, amount: number): Promise<void> {
// No direct transaction - use Saga pattern
const saga = new PaymentSaga();
try {
// Step 1: Create payment in local db
const payment = await this.paymentsDb.query(
`INSERT INTO payments (order_id, amount, status)
VALUES ($1, $2, 'pending') RETURNING *`,
[orderId, amount]
);
saga.addCompensation(() => this.cancelPayment(payment.id));
// Step 2: Call Order Service to update order
await this.orderServiceClient.markOrderAsPaid(orderId);
saga.addCompensation(() => this.orderServiceClient.markOrderAsPending(orderId));
// Step 3: Complete payment
await this.paymentsDb.query(
`UPDATE payments SET status = 'completed' WHERE id = $1`,
[payment.id]
);
await saga.commit();
} catch (error) {
// Compensate all completed steps
await saga.rollback();
throw error;
}
}
}
Benefits:
- Each service owns its data and can evolve it independently
- Hot services scale without dragging cold ones along
- Polyglot persistence is on the table (Postgres for one service, DynamoDB for another)
- Deploys happen per service, no coordination tax
- Domain boundaries are enforced by the database, not by convention
Drawbacks:
- Cross-service transactions require sagas, with compensation logic to write and test
- Some data has to live in more than one place
- Queries that used to be a join now need API composition or a materialized view
- More databases means more ops surface
- Eventual consistency is the default, and you have to design around it
The 2025 decision matrix
| Factor | Shared Database | Database Per Service | Winning Pattern |
|---|---|---|---|
| Team Size | < 5 developers | > 10 developers | DB per service at scale |
| Service Count | < 5 services | > 10 services | DB per service at scale |
| Transaction Complexity | High (multi-entity ACID required) | Low (mostly single-entity) | Shared for complex transactions |
| Query Complexity | High (complex joins, analytics) | Low (simple lookups) | Shared for complex queries |
| Deployment Frequency | < 1/week | > 1/day | DB per service for fast deploys |
| Team Autonomy Need | Low (coordinated releases OK) | High (independent teams) | DB per service for autonomy |
| Scalability Requirements | Uniform (all entities scale same) | Varied (some hot, some cold) | DB per service for varied scale |
| Consistency Requirements | Strong (ACID mandatory) | Eventual OK | Shared for strong consistency |
| Technology Diversity | Single stack preferred | Polyglot needed | DB per service for polyglot |
| Operational Maturity | Low (small ops team) | High (mature DevOps) | Shared for simpler ops |
| Data Duplication Tolerance | Low (single source of truth) | High (eventual consistency OK) | Depends on domain |
| Compliance/Audit | Complex (cross-entity audits) | Simple (entity-level audits) | Depends on requirements |
How to read the matrix
Pick shared database if most of the following are true:
- You have fewer than ten developers
- You genuinely need ACID across multiple entities
- You run complex analytical queries that would be painful to compose across services
- Your team has not done distributed systems before
- Services scale roughly together
- You are still finding product-market fit
Pick database per service if most of the following are true:
- You have more than ten developers across multiple teams
- Services have meaningfully different scaling needs
- You deploy services independently multiple times a day
- You want technology diversity (Postgres plus DynamoDB plus a graph store)
- You can tolerate eventual consistency, often via CQRS and event sourcing for audit-heavy domains
- DevOps is a strong muscle on your team
A hybrid usually beats both extremes. Start with a shared database. Pull out services that have high change rates or high scale into their own databases. Keep tightly coupled entities together. Use DDD bounded contexts to find the seams. Most successful migrations end up here.
Implementing database per service
Saga pattern for distributed transactions
// Saga Orchestrator Pattern
export class PaymentSaga {
private steps: SagaStep[] = [];
private completedSteps: SagaStep[] = [];
async execute(): Promise<void> {
for (const step of this.steps) {
try {
await step.execute();
this.completedSteps.push(step);
} catch (error) {
// Compensate in reverse order
await this.compensate();
throw error;
}
}
}
private async compensate(): Promise<void> {
// Reverse order compensation
for (const step of this.completedSteps.reverse()) {
try {
await step.compensate();
} catch (error) {
console.error('Compensation failed:', error);
// Log and alert - manual intervention may be needed
}
}
}
addStep(step: SagaStep): void {
this.steps.push(step);
}
}
interface SagaStep {
execute(): Promise<void>;
compensate(): Promise<void>;
}
// Order creation saga
export class CreateOrderSaga {
constructor(
private orderService: OrderService,
private inventoryService: InventoryService,
private paymentService: PaymentService
) {}
async execute(data: CreateOrderDTO): Promise<string> {
const saga = new PaymentSaga();
let orderId: string;
let reservationId: string;
// Step 1: Create order
saga.addStep({
execute: async () => {
orderId = await this.orderService.createOrder(data);
},
compensate: async () => {
await this.orderService.cancelOrder(orderId);
}
});
// Step 2: Reserve inventory
saga.addStep({
execute: async () => {
reservationId = await this.inventoryService.reserveItems(
data.items,
orderId
);
},
compensate: async () => {
await this.inventoryService.releaseReservation(reservationId);
}
});
// Step 3: Process payment
saga.addStep({
execute: async () => {
await this.paymentService.authorizePayment(
orderId,
data.paymentDetails
);
},
compensate: async () => {
await this.paymentService.refundPayment(orderId);
}
});
// Execute all steps
await saga.execute();
return orderId;
}
}
// Usage
const saga = new CreateOrderSaga(orderService, inventoryService, paymentService);
try {
const orderId = await saga.execute(orderData);
console.log('Order created:', orderId);
} catch (error) {
// All steps compensated automatically
console.error('Order creation failed:', error);
}
Event-driven data synchronization
// Maintain local copies of data via events
// Customer Service publishes events
export class CustomerService {
async updateCustomer(id: string, data: UpdateCustomerDTO): Promise<void> {
await this.customersDb.query(
`UPDATE customers SET name = $1, email = $2 WHERE id = $3`,
[data.name, data.email, id]
);
// Publish event
await this.eventBus.publish(new CustomerUpdatedEvent({
customerId: id,
name: data.name,
email: data.email,
timestamp: new Date()
}));
}
}
// Order Service subscribes and maintains local copy
export class OrderService {
async onCustomerUpdated(event: CustomerUpdatedEvent): Promise<void> {
// Update local denormalized customer data
await this.ordersDb.query(
`UPDATE order_customer_cache
SET name = $1, email = $2, updated_at = $3
WHERE customer_id = $4`,
[event.name, event.email, event.timestamp, event.customerId]
);
// Also update existing orders for reporting
await this.ordersDb.query(
`UPDATE orders
SET customer_name = $1, customer_email = $2
WHERE customer_id = $3 AND created_at > NOW() - INTERVAL '30 days'`,
[event.name, event.email, event.customerId]
);
}
}
// Order Service schema includes denormalized customer data
/*
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
-- Denormalized customer data (from events)
customer_name VARCHAR(255),
customer_email VARCHAR(255),
total_amount DECIMAL(10, 2),
status VARCHAR(50),
created_at TIMESTAMP
);
CREATE TABLE order_customer_cache (
customer_id UUID PRIMARY KEY,
name VARCHAR(255),
email VARCHAR(255),
updated_at TIMESTAMP
);
*/
Change data capture for sync
// Use Debezium to capture changes and sync data
// Docker Compose configuration
/*
version: '3.8'
services:
postgres-customers:
image: postgres:16
environment:
POSTGRES_DB: customers
# Enable logical replication
POSTGRES_INITDB_ARGS: "-c wal_level=logical"
debezium:
image: debezium/connect:3.0
environment:
BOOTSTRAP_SERVERS: kafka:9092
CONFIG_STORAGE_TOPIC: debezium_configs
OFFSET_STORAGE_TOPIC: debezium_offsets
kafka:
image: confluentinc/cp-kafka:7.6
*/
// Debezium connector configuration
const debeziumConfig = {
name: 'customers-connector',
config: {
'connector.class': 'io.debezium.connector.postgresql.PostgresConnector',
'database.hostname': 'postgres-customers',
'database.port': '5432',
'database.user': 'debezium',
'database.password': 'secret',
'database.dbname': 'customers',
'table.include.list': 'public.customers',
'topic.prefix': 'customers',
'plugin.name': 'pgoutput'
}
};
// Order Service consumes CDC events
export class OrderServiceCDCConsumer {
async consumeCustomerChanges(): Promise<void> {
const consumer = kafka.consumer({ groupId: 'order-service-cdc' });
await consumer.subscribe({ topic: 'customers.public.customers' });
await consumer.run({
eachMessage: async ({ message }) => {
const change = JSON.parse(message.value.toString());
if (change.op === 'u') {
// Update operation
await this.updateCustomerCache({
customerId: change.after.id,
name: change.after.name,
email: change.after.email
});
} else if (change.op === 'd') {
// Delete operation
await this.deleteCustomerCache(change.before.id);
}
}
});
}
private async updateCustomerCache(data: any): Promise<void> {
await this.ordersDb.query(
`INSERT INTO order_customer_cache (customer_id, name, email, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (customer_id) DO UPDATE SET
name = $2, email = $3, updated_at = NOW()`,
[data.customerId, data.name, data.email]
);
}
}
API composition for queries
// BFF (Backend for Frontend) composes data from multiple services
export class OrderDetailsComposer {
constructor(
private orderService: OrderServiceClient,
private customerService: CustomerServiceClient,
private inventoryService: InventoryServiceClient,
private paymentService: PaymentServiceClient
) {}
async getOrderDetails(orderId: string): Promise<OrderDetailsDTO> {
// Parallel fetch from multiple services
const [order, customer, items, payment] = await Promise.all([
this.orderService.getOrder(orderId),
this.customerService.getCustomer(order.customerId),
this.inventoryService.getOrderItems(orderId),
this.paymentService.getPaymentStatus(orderId)
]);
// Compose into single response
return {
order: {
id: order.id,
status: order.status,
createdAt: order.createdAt
},
customer: {
name: customer.name,
email: customer.email,
phone: customer.phone
},
items: items.map(item => ({
productId: item.productId,
name: item.name,
quantity: item.quantity,
price: item.price
})),
payment: {
status: payment.status,
method: payment.method,
amount: payment.amount
}
};
}
}
// GraphQL alternative - dataloader for N+1 prevention
export class OrderGraphQLResolver {
private customerLoader = new DataLoader(async (customerIds: string[]) => {
// Batch fetch customers
return this.customerService.getCustomersBatch(customerIds);
});
@Query(() => Order)
async order(@Arg('id') id: string): Promise<Order> {
return this.orderService.getOrder(id);
}
@ResolveField(() => Customer)
async customer(@Root() order: Order): Promise<Customer> {
// Uses dataloader to batch requests
return this.customerLoader.load(order.customerId);
}
}
Migration strategies
Strategy 1: extract one service at a time
// Phase 1: Identify bounded context
// Analyze dependencies and coupling
// Before: All in one database
/*
┌─────────────────────────────┐
│ Monolith Database │
├─────────────────────────────┤
│ customers │
│ orders │
│ order_items │
│ products │
│ inventory │
│ payments │
└─────────────────────────────┘
*/
// Phase 2: Create read-only replica for new service
// New service reads from replica, writes to old database
export class InventoryService {
async getStock(productId: string): Promise<number> {
// Read from replica
return this.replicaDb.query(
`SELECT quantity FROM inventory WHERE product_id = $1`,
[productId]
);
}
async reserveStock(productId: string, quantity: number): Promise<void> {
// Still write to primary database
await this.primaryDb.query(
`UPDATE inventory
SET quantity = quantity - $1
WHERE product_id = $2`,
[quantity, productId]
);
}
}
// Phase 3: Implement CDC to sync data
// Set up Debezium to sync inventory table to new database
// Phase 4: Switch writes to new database
export class InventoryService {
async reserveStock(productId: string, quantity: number): Promise<void> {
// Now write to new database
await this.inventoryDb.query(
`UPDATE inventory
SET quantity = quantity - $1
WHERE product_id = $2`,
[quantity, productId]
);
// Publish event for other services
await this.eventBus.publish(new StockReservedEvent({
productId,
quantity,
timestamp: new Date()
}));
}
}
// Phase 5: Update dependent services to use events
export class OrderService {
async onStockReserved(event: StockReservedEvent): Promise<void> {
// Update local cache
await this.updateProductStockCache(
event.productId,
event.quantity
);
}
}
// Phase 6: Decommission old tables
// Once all services migrated, drop inventory tables from monolith
Strategy 2: strangler fig
// Gradually route traffic to new service
export class DatabaseRouter {
constructor(
private oldDb: Database,
private newDb: Database,
private migrationPercentage: number = 0
) {}
async query(sql: string, params: any[]): Promise<any> {
// Route based on feature flag
if (this.shouldUseNewDatabase()) {
try {
// Try new database
const result = await this.newDb.query(sql, params);
// Double-write for consistency verification
if (this.isWriteOperation(sql)) {
await this.oldDb.query(sql, params);
}
return result;
} catch (error) {
// Fallback to old database
console.error('New database failed, falling back:', error);
return this.oldDb.query(sql, params);
}
}
// Use old database
return this.oldDb.query(sql, params);
}
private shouldUseNewDatabase(): boolean {
// Gradual rollout: 0% -> 10% -> 50% -> 100%
return Math.random() * 100 < this.migrationPercentage;
}
private isWriteOperation(sql: string): boolean {
return /^(INSERT|UPDATE|DELETE)/i.test(sql.trim());
}
}
// Increase percentage gradually
// Day 1: 0% (new database deployed, not used)
// Day 2: 10% (monitor errors)
// Day 3: 50% (if no errors)
// Day 4: 100% (full migration)
Handling cross-service queries
Pattern 1: CQRS with materialized views
// Maintain optimized read models
// Order Service publishes events
export class OrderService {
async createOrder(data: CreateOrderDTO): Promise<void> {
const order = await this.ordersDb.query(
`INSERT INTO orders (...) VALUES (...) RETURNING *`,
[...]
);
await this.eventBus.publish(new OrderCreatedEvent(order));
}
}
// Analytics Service subscribes and builds materialized view
export class AnalyticsService {
async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
// Fetch related data
const customer = await this.customerCache.get(event.customerId);
const items = await this.productCache.getMany(event.productIds);
// Insert into denormalized view optimized for analytics
await this.analyticsDb.query(
`INSERT INTO order_analytics_view (
order_id, customer_id, customer_name, customer_segment,
total_amount, product_categories, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
event.orderId,
customer.id,
customer.name,
customer.segment,
event.totalAmount,
items.map(i => i.category),
event.createdAt
]
);
}
// Complex analytical query now simple
async getRevenueBySegment(startDate: Date, endDate: Date): Promise<any> {
return this.analyticsDb.query(
`SELECT
customer_segment,
SUM(total_amount) as revenue,
COUNT(*) as orders
FROM order_analytics_view
WHERE created_at BETWEEN $1 AND $2
GROUP BY customer_segment`,
[startDate, endDate]
);
}
}
Pattern 2: distributed query engine
// Use Presto/Trino to query multiple databases
// Presto catalog configuration
/*
# customer-db.properties
connector.name=postgresql
connection-url=jdbc:postgresql://customer-db:5432/customers
connection-user=presto
connection-password=secret
# order-db.properties
connector.name=postgresql
connection-url=jdbc:postgresql://order-db:5432/orders
connection-user=presto
connection-password=secret
*/
// Query across databases with Presto SQL
const query = `
SELECT
c.name as customer_name,
COUNT(o.id) as order_count,
SUM(o.total_amount) as total_revenue
FROM "customer-db".public.customers c
LEFT JOIN "order-db".public.orders o ON o.customer_id = c.id
WHERE o.created_at >= DATE '2025-01-01'
GROUP BY c.name
ORDER BY total_revenue DESC
LIMIT 100
`;
// Execute via Presto client
export class DistributedQueryService {
async executeQuery(sql: string): Promise<any[]> {
const result = await this.prestoClient.execute(sql);
return result.rows;
}
}
// Note: Use for analytics/reporting, not real-time queries
Real-world cost analysis
| Aspect | Shared Database | Database Per Service | Winner |
|---|---|---|---|
| Infrastructure | $500/month (1 RDS instance) | $2000/month (4 RDS instances) | Shared (4x cheaper) |
| Development Speed | Initial: Fast, Later: Slow | Initial: Slow, Later: Fast | Depends on phase |
| Deployment Time | 45 min (coordinated) | 10 min (independent) | DB per service |
| Incident Resolution | Easy (single database) | Complex (distributed trace) | Shared |
| Team Velocity | Slows at 10+ devs | Scales linearly | DB per service |
| Data Inconsistency Risk | Low (ACID) | Medium (eventual) | Shared |
| Scaling Cost | High (vertical only) | Low (horizontal) | DB per service |
| Operational Overhead | 1 DBA | 1 DBA + DevOps | Shared |
Three-year cost picture for a team of 20 developers:
- Shared database: $250K infrastructure + $600K in slower velocity = $850K
- Database per service: $350K infrastructure + $200K in initial migration = $550K
Database per service wins at scale because team velocity dwarfs infrastructure cost, but only once you are actually at that scale.
Practical advice
For shared database setups:
- Assign clear ownership for each table, even if the database is shared
- Use views to hide complexity and constrain what each service sees
- Use row-level security where you need isolation between tenants or services
- Plan schema migrations together, with backward-compatible deploys
- Watch cross-service query performance, one service's bad join can starve everyone
For database per service:
- Start with the minimum data duplication you can get away with
- Make event handlers idempotent, retries will happen
- Propagate correlation IDs through every async hop so you can trace a request end-to-end
- Monitor projection lag and saga failures as first-class signals
- Version events from day one, schema evolution is harder when it is already in production
- Write tests for compensation paths, not just happy paths
For the hybrid approach:
- Keep tightly coupled entities in the same database
- Extract the services with the highest change rate or highest scale first
- Let DDD bounded contexts guide where the seams go
- Start shared, extract gradually, and stop extracting when the pain stops
Conclusion
The right answer here is rarely doctrinal. Small teams with complex transactions are usually better served by a shared database, both for ACID guarantees and operational simplicity. Large teams deploying multiple times a day benefit from database-per-service, despite the eventual-consistency tax.
The mistake worth avoiding in both directions: extracting databases too early because the architecture diagram says so, or refusing to extract until tight coupling has already throttled velocity. Use the matrix as a forcing function for an honest conversation, start with a hybrid, and let the pain points tell you which services need their own database next.
Next Steps
- Assess current state: Team size, deployment frequency, transaction patterns
- Identify pain points: Deployment bottlenecks, scaling issues, team conflicts
- Start small: Extract one service to its own database
- Implement patterns: Saga for transactions, events for sync, API composition for queries
- Measure impact: Deployment time, team velocity, incident resolution time
- Iterate: Extract more services if benefits outweigh costs