- Published on
Shared Database Across Services — The Hidden Monolith
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Shared database is the most common way microservices fail to deliver on their promise. It looks like microservices — separate codebases, separate deployments, separate teams — but behaves like a monolith because every service reaches into the same schema. A migration by one team can break another team's queries. A slow report query by Service A saturates the connection pool and degrades Service B.
- Why It Happens
- The Problems with Shared Database
- Fix 1: Database Per Service
- Fix 2: Data Replication for Read-Heavy Cross-Service Queries
- Fix 3: API Composition for Reports Needing Multiple Services
- Fix 4: The Strangler Fig Migration
- Checklist
- Conclusion
Why It Happens
Day 1: We're splitting the monolith into microservices!
"We'll deal with the database later."
Day 30: order-service reads from users table (owned by user-service)
Day 60: billing-service writes to orders table (owned by order-service)
Day 90: All services read from a shared "reference_data" table
Day 120: 8 services, 1 database, nobody knows who owns what table
The database became the integration layer.
Any schema change requires cross-team coordination.
The Problems with Shared Database
-- user-service renames a column (their table, their decision)
ALTER TABLE users RENAME COLUMN user_name TO full_name;
-- order-service breaks immediately (reads user_name in a JOIN)
SELECT o.*, u.user_name FROM orders o JOIN users u ON o.user_id = u.id
-- ERROR: column "user_name" does not exist
-- billing-service breaks too (also reads user_name)
-- notification-service breaks too
-- 3 teams, broken in production, emergency coordination required
Fix 1: Database Per Service
Each service owns its own database — no other service can directly query it.
user-service → postgres database: user_db
order-service → postgres database: order_db
billing-service → postgres database: billing_db
Cross-service data access happens ONLY via:
1. Service API calls (synchronous queries)
2. Domain events (asynchronous data replication)
Fix 2: Data Replication for Read-Heavy Cross-Service Queries
// Pattern: each service maintains its own copy of data it needs from others
// Kept up to date via domain events
// user-service publishes when a user updates their name
eventBus.publish('user.updated', {
userId: 'u123',
fullName: 'Sanjeev Sharma',
email: 'sanjeev@example.com',
})
// order-service subscribes and maintains its own denormalized copy
eventBus.subscribe('user.updated', async (event) => {
await orderDb.query(`
INSERT INTO order_user_cache (user_id, full_name, email)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE
SET full_name = $2, email = $3
`, [event.userId, event.fullName, event.email])
})
// Now order-service can join against its own cache without calling user-service
const orders = await orderDb.query(`
SELECT o.*, uc.full_name, uc.email
FROM orders o
JOIN order_user_cache uc ON o.user_id = uc.user_id
WHERE uc.user_id = $1
`, [userId])
Fix 3: API Composition for Reports Needing Multiple Services
// For queries that genuinely need data from multiple services,
// compose at the API layer (or in a dedicated read model)
class OrderHistoryQuery {
async execute(userId: string) {
// Parallel calls to each service's API
const [orders, user, payments] = await Promise.all([
orderService.getOrdersByUser(userId),
userService.getUser(userId),
billingService.getPaymentsByUser(userId),
])
return orders.map(order => ({
...order,
customer: user,
payment: payments.find(p => p.orderId === order.id),
}))
}
}
Fix 4: The Strangler Fig Migration
Migrating from shared database to per-service databases:
Phase 1: Define ownership
- Map every table to a single "owning" service
- Document who else reads/writes each table
Phase 2: Add API layer over owned tables
- Build the API endpoints that let other services access the data
- Keep direct DB access working during transition
Phase 3: Migrate consumers one by one
- Update other services to use the API instead of direct DB access
- Verify with integration tests
Phase 4: Enforce boundary
- Remove other services' database credentials
- Separate the databases physically
Checklist
- ✅ Each service has its own database user with access ONLY to its own schema
- ✅ No service executes SQL against another service's tables
- ✅ Cross-service data needs are served via API or event-based replication
- ✅ Schema migrations require no cross-team coordination
- ✅ One service's connection pool saturation doesn't affect others
Conclusion
A shared database is a monolith with extra steps. The independence that microservices promise — teams deploying on their own schedule, services scaling independently — requires database independence. Start by mapping table ownership. Then build the API layer so other services can access data via API rather than SQL. Migrate consumers gradually. The goal isn't separate databases for their own sake — it's eliminating the invisible coupling that makes every schema change a multi-team incident.