Distributed monolith anti-pattern
Understand why microservices that share a database are worse than a monolith, how to detect a distributed monolith, and how to fix service boundaries without a rewrite.
TL;DR
- A distributed monolith has the operational complexity of microservices (network calls, partial failures, versioning) but the tight coupling of a monolith (shared database, shared schema, lock-step deployments).
- It is the worst of both worlds: you cannot deploy services independently because a schema change in one breaks another, yet you still pay the network overhead of inter-service communication.
- The signature: services that point at the same database tables, or services that must be deployed in a specific order.
- The fix requires splitting ownership of data first. Each service owns its tables. This is harder and more disruptive than any code change.
The Problem
Your team decomposed the monolith into eight services over six months. Deployments are still coordinated events requiring a 4-hour Saturday night window. A schema change in the Users table requires touching the User, Auth, Notification, and Analytics services. Service B can't start until Service A has finished its migration because they both write to the same orders table.
You split the code into services but never split ownership of data. Every service still reads and writes the same shared Postgres database. The database is your secret monolith, the part you didn't decompose.
I've seen this happen on three different teams. Each time the conversation was the same: "We have microservices." No, you have a monolith with extra network hops.
This is common because database decomposition is the hard part. Code decomposition is refactoring. Data decomposition requires migrating tables, duplicating data, accepting eventual consistency, and changing all the places that relied on ACID transactions spanning service boundaries.
Every service reaches into every table. A column rename on users breaks four services simultaneously. That is the distributed monolith in one picture.
What deployment coupling looks like
Here's the deployment ceremony for a simple feature: adding a preferred_name column to the users table.
Adding a single column requires four coordinated deployments. In a properly decomposed system, only the User Service would need to change.
Why It Happens
Nobody sets out to build a distributed monolith. It emerges from a series of individually reasonable decisions.
"We'll share the database for now." The team plans to split data later. Later never comes because the codebase grows around the shared access pattern.
"It's faster to query directly." Building an API for internal data feels like overhead when the table is right there. Every shortcut compounds into coupling.
"We followed the microservices playbook." Most tutorials focus on code decomposition (separate repos, separate containers) and barely mention data ownership. Teams split code because that's what the guides show.
"The domain boundaries aren't clear yet." When you don't know which service owns which data, sharing a database feels like the safe default. It isn't. It's the default that locks you in.
I've been guilty of the "just query the table directly" shortcut myself. It saves a day in week one and costs a quarter in year two.
The coupling progression
Distributed monoliths don't appear overnight. They grow through a predictable progression:
- Week 1: Service B needs user data. "Let's just query the users table directly for now."
- Month 2: Service C also needs user data. "Service B already does it this way, so we'll do the same."
- Month 6: Five services query the users table. Nobody remembers which columns each service actually needs.
- Year 1: Someone renames a column. Three services break in production. The post-mortem reveals nobody knew about the cross-service dependencies.
- Year 2: The team wants to shard the users table for performance. They can't, because five services have hardcoded queries against the table schema.
Each step was reasonable in isolation. The compound effect is a system that's harder to change than the monolith it replaced.
How to Detect It
Ask these questions about your "microservices" architecture:
| Symptom | What It Means | How to Check |
|---|---|---|
| Services share database tables | Tight data coupling | Run SELECT * FROM pg_stat_activity and check which connection pools touch which tables |
| Deployments must happen in a specific order | Deployment coupling | Try deploying Service B before Service A. If it fails, you're coupled |
| A schema migration requires coordinating multiple teams | Schema coupling | Check how many services have migration scripts that reference the same table |
| Cross-service JOIN queries at the database layer | Query coupling | Search codebase for queries joining tables owned by different services |
| One service's tests require another service's seed data | Test coupling | Run each service's test suite in isolation. If tests fail, coupling exists |
| You can't deploy Service A without redeploying Service B | Interface coupling | Check your CI/CD pipeline for multi-service deployment steps |
If three or more of these apply, you have a distributed monolith. I once ran this checklist on a "microservices" system and scored 5 out of 6. The team was surprised. The database wasn't.
Code smells to watch for
These patterns in your codebase are early warnings:
// š© Red flag: Service A importing Service B's database models
import { UserModel } from "../user-service/models/user";
// š© Red flag: Direct SQL against another service's tables
const orders = await db.query(`
SELECT o.*, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending'
`);
// This JOIN crosses service boundaries at the data layer
// š© Red flag: Shared database connection string in multiple services
// config/database.ts (same file in Order Service AND User Service)
export const dbConfig = {
host: "shared-postgres.internal",
database: "main_db",
// Every service connects to the same database
};
Quick diagnostic: the deploy test
The fastest way to detect a distributed monolith is the independent deploy test. Pick any service. Try to deploy it without changing or redeploying anything else. If the system stays healthy, that service is truly independent. If anything breaks, you have coupling.
Run this test for every service. The results will map your actual dependency graph, which is often different from the one in your architecture diagram.
The Fix
You cannot fix a distributed monolith overnight. The strangler fig pattern applies at the data layer too. Here's the migration path, step by step.
Fix 1: Establish data ownership
For each table, assign a single service as the authoritative owner. Other services become consumers, not co-writers. This is a design decision, not a code change.
ā Distributed monolith:
Order Service ā SELECT * FROM users WHERE id = ? (shared DB)
Profile Service ā SELECT * FROM users WHERE id = ? (same table!)
Auth Service ā UPDATE users SET last_login = ? WHERE id = ? (shared writes!)
ā
Proper service isolation:
Order Service ā GET /api/users/{id} (via User Service API)
Profile Service ā owns `users` table, handles all reads/writes
Auth Service ā owns `auth_sessions` table, calls User Service for profile data
Trade-off: This is a design exercise, not a code change. The hard part is getting teams to agree on ownership boundaries. Expect 2-3 weeks of discussion for a system with 10+ tables.
Fix 2: Dual-read migration (API alongside direct DB)
The owning service exposes an API for its data. Other services begin calling the API while still having DB access. This is the safe transition phase.
// BEFORE: Direct DB query from Order Service
async function getUserForOrder(userId: string) {
return await db.query("SELECT name, email FROM users WHERE id = $1", [userId]);
}
// DURING: Dual-read with feature flag
async function getUserForOrder(userId: string) {
if (featureFlags.useUserServiceAPI) {
return await userServiceClient.getUser(userId);
}
return await db.query("SELECT name, email FROM users WHERE id = $1", [userId]);
}
// AFTER: API-only access
async function getUserForOrder(userId: string) {
return await userServiceClient.getUser(userId);
}
Trade-off: You run two access paths simultaneously. This adds complexity but makes rollback trivial. Keep the dual-read phase short (2-4 weeks per service).
Fix 3: Replace cross-service transactions with sagas
Splitting data into separate databases means you lose ACID transactions across service boundaries. A checkout that updates Inventory (one DB) and creates an Order (another DB) can partially fail.
You replace ACID with eventual consistency, using the Saga pattern or the Outbox pattern to coordinate multi-service data changes.
// Saga: Checkout across Order + Inventory + Payment
// Each step is a local transaction with a compensating action
const checkoutSaga = [
{ action: "inventory.reserve(items)", compensate: "inventory.release(items)" },
{ action: "payment.charge(amount)", compensate: "payment.refund(amount)" },
{ action: "order.create(orderId, items)", compensate: "order.cancel(orderId)" },
];
// If payment.charge fails, inventory.release runs automatically
Trade-off: Sagas add significant complexity. You need idempotency, compensation logic, and monitoring for stuck sagas. This is the real cost of true service independence.
Fix 4: Physical database separation
Once no service except the owner accesses a table, move that table to the owner's dedicated database. This is the final goal: physical separation.
Trade-off: You now manage multiple database instances, connection pools, and backup strategies. But each team controls their own schema evolution without coordinating deployments.
What the migration timeline looks like
Data decomposition is not a sprint. Here's a realistic timeline for a mid-size system (8 services, 30 tables):
| Phase | Duration | What happens |
|---|---|---|
| Ownership mapping | 2-3 weeks | Assign every table to one owning service. Resolve disputes. Document in an ADR |
| API creation | 4-6 weeks | Owning services expose APIs for their data. No consumer changes yet |
| Dual-read migration | 6-12 weeks | Migrate consumers one at a time. Feature flags for rollback |
| Direct DB access removal | 2-4 weeks | Remove all cross-service DB credentials. Enforce at the network layer |
| Physical DB separation | 4-8 weeks | Move tables to dedicated databases. Update connection strings |
| Saga introduction | 4-8 weeks (parallel) | Replace cross-service transactions with sagas where needed |
Total: 5-9 months for a team of 10-15 engineers. This is not a side project. It requires dedicated investment and executive buy-in.
The fixed architecture
Each service owns its data. Cross-service communication happens through APIs and events, never through shared tables.
Which fix to apply when
Severity and Blast Radius
A distributed monolith is a high-severity anti-pattern because it compounds over time. The longer services share a database, the more cross-service queries accumulate, and the harder extraction becomes.
What breaks: Every schema migration becomes a multi-team coordination event. Deployment velocity drops to the pace of the slowest team. Database connection pool exhaustion affects all services simultaneously because they share the same pool.
Cascade risk: High. A single database outage takes down every service. A slow query from one service degrades connection availability for all others.
Recovery difficulty: Weeks to months. Data decomposition requires careful migration, dual-read phases, and saga introduction. You cannot fix this in a weekend.
When It's Actually OK
- Prototype or MVP phase. If you're validating a product idea with 2-3 services and plan to split data within 6 months, a shared database is pragmatic. Set a deadline and enforce it.
- Read-only shared access. If Service B only reads from a table that Service A owns and writes to, the coupling is lower. This is acceptable short-term if you add a read API later.
- Reporting and analytics. A read replica shared across services for analytics queries is fine. The key is that no service writes to the reporting database.
- Very small teams (2-3 engineers). If the entire system is owned by one team, the coordination cost of a shared database is low. The anti-pattern bites when teams need to move independently.
How This Shows Up in Interviews
When designing a microservices system, describe data ownership explicitly: "The Order service owns the orders and order_items tables. The Inventory service owns the products and stock tables. They communicate only via API, no cross-service DB queries." This signals you understand that code decomposition without data decomposition is just a distributed monolith.
'We have microservices' doesn't mean independent deployability
The only meaningful test of a microservice is: can you deploy it without coordinating with any other team? If the answer is no, you have a distributed monolith regardless of how many separate containers you're running.
Quick Recap
- A distributed monolith has service-shaped code but monolith-shaped data: services share a database.
- You get all the complexity of distributed systems (network, partial failure, versioning) with none of the independence benefits.
- It happens because teams split code first and plan to split data "later," which rarely arrives.
- Deployment coupling, schema coupling, and shared table writes are the key diagnostic signals.
- The fix is data decomposition: establish ownership, add APIs, migrate consumers, then physically separate databases.
- Cross-service transactions must be replaced with sagas, which is the real complexity cost of proper decomposition.
- At small scale with one team, a shared database is acceptable if you document ownership and set a migration deadline.