What a distributed monolith is, and why it is defined by symptoms
A distributed monolith is a system that has been split into separately deployed services but still behaves like one deployable unit. Counting services won't tell you whether you have one. Forty services can be well decoupled, and four can be a monolith with network calls in the middle. Look at how the system behaves when someone tries to change it:
- Lockstep deploys. A change to the order service can't ship until billing and inventory ship compatible versions in the same release window.
- Shared domain libraries. A common
CustomerorOrdermodel lives in a library that every service imports. Changing one field forces every consumer to upgrade together. - Correlated outages. Search degrades, and checkout fails too, even though the two journeys have nothing to do with each other from the user's point of view.
Each symptom contradicts a property that Lewis and Fowler list as characteristic of microservices. Services should be independently deployable by automated machinery. Each service should manage its own data instead of sharing a central store.
Teams usually fall into this pattern because splitting deployables is the visible, easy part. You can create a new repository and pipeline in a day. Unwinding a shared schema or a synchronous call into another team's service takes much longer, so that work gets deferred.
That is the thesis of this guide. Most microservices anti-patterns come from splitting deployables while leaving runtime and data coupling in place. The test for any boundary is whether one team can change, deploy, and run its service without coordinating with another team.
A self-diagnosis checklist for coupling
Run these questions against a recent quarter of real work, not against the architecture diagram. Diagrams show the intended boundaries; delivery history shows the actual ones.
- How many services did a typical feature touch? Count the repositories and deployables changed per ticket or epic. If most features span several services, the boundaries follow technical layers rather than business capabilities.
- Can each service ship on its own schedule? If releases have to be ordered ("deploy billing before orders"), the services share a contract that cannot change independently.
- Does a schema change need a cross-team meeting? If so, more than one team reads or writes the same tables.
- Does integration testing need every service running? If no service can be tested against stubs or contracts, it depends at runtime on the whole graph.
- Do traces and correlation IDs exist to answer these questions? If they don't, you cannot measure the coupling. Fix that first.
These are heuristics, not thresholds. The trend matters more than any single answer. When the answers get worse over successive quarters, coupling is growing faster than the team is noticing it. Platform and tech leads should run the checklist before splitting another service, and again after any incident that crossed several service boundaries.
| Question | Points to | Fix covered in |
|---|---|---|
| Services touched per feature | Wrong boundaries, nano-services | Sections 5, 6 |
| Coordinated releases | Distributed monolith, shared libraries | Sections 5, 6 |
| Cross-team schema changes | Shared database | Section 3 |
| Full-stack integration environment | Chatty synchronous chains | Section 4 |
| Missing traces/correlation IDs | Unmeasurable coupling | Section 7 |
The shared database anti-pattern and how to migrate off it
When two services read and write the same tables, the schema becomes their contract, and nobody has agreed to it. If the billing team renames orders.status or narrows a column type, the fulfilment service breaks at runtime, often in production. Each table ends up needing every team's approval before it can change. Business rules such as "an order cannot ship before payment clears" spread across several codebases, so no one team owns the invariant and each one assumes another enforces it.
The alternative is what Lewis and Fowler call decentralized data management: each service owns its data, and other services reach that data only through the owner's API or the events it publishes.
Migrating incrementally
- One writer per table. Pick an owner for each table and route all writes through that service. You can enforce this at the database: give each service its own role, run
REVOKE INSERT, UPDATE, DELETEon the table for every role except the owner's, and leaveSELECTfor now. - Move reads. Switch readers to the owner's API, or to local read models kept up to date by the owner's events. When a reader is done, revoke its
SELECT. - Split the schema. Once no other role touches the tables, move them to the owner's own schema or database instance.
Trade-offs
You give up cross-entity joins and single ACID transactions that span what are now separate services. Data copied between services is eventually consistent, so you have to decide how stale each read model can be. Reporting gets harder and usually needs a separate analytics pipeline. A shared database is still a reasonable choice in two cases: as a temporary stage during this migration, or as a deliberate decision by a small team that can coordinate schema changes in one conversation.
-- One login role per service. Each service connects only as its own role.
CREATE ROLE order_service;
CREATE ROLE fulfilment_service;
CREATE ROLE billing_service;
-- Start from a clean slate: nobody gets access by default.
REVOKE ALL ON orders FROM PUBLIC;
-- Step 1: one writer per table.
-- order_service owns orders, so it is the only role that can change rows.
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO order_service;
-- The other services can still read for now, but their writes now fail
-- with a permission error. That error shows you which code path still needs to move.
GRANT SELECT ON orders TO fulfilment_service, billing_service;
REVOKE INSERT, UPDATE, DELETE ON orders FROM fulfilment_service, billing_service;
-- Step 2: move reads. Once fulfilment reads orders through the order
-- service's API, or through its own event-fed read model, take away direct access.
REVOKE SELECT ON orders FROM fulfilment_service;
-- Repeat for billing_service. When only order_service still has
-- privileges on orders, nothing else touches the table, and you can move it
-- into the owner's own schema or database (step 3).
These standard SQL GRANT and REVOKE statements give each service its own role, leave a single role able to write the orders table, and then remove the other services' read access one at a time as they move to the owner's API or to event-fed copies of the data.
Chatty services, synchronous call chains and cascading failure
Splitting a service doesn't remove a dependency. It turns an in-process function call into a network call that can be slow or fail. When one user request walks serially through five services, the latency and failure risk of every hop stack up:
client → gateway → orders → pricing → inventory → customers
Illustrative estimate. Assume each of the five calls is independently available 99.9% of the time. The request succeeds only if all five succeed: 0.999^5 ≈ 0.995, or about 99.5%. That turns roughly 1 failure in 1,000 requests into about 5 in 1,000. Real numbers will differ. Failures are often correlated, for example through a shared database or a single bad deploy. Retries can hide failures or make them worse, and caching takes some hops off the hot path. Latency adds up the same way, and the slowest call in the chain sets the tail latency. Fowler and Lewis warn about this multiplication in their sidebar on synchronous calls.
How the failure cascades
Say inventory slows down. Callers keep sending requests, so their threads and connection pools fill up waiting on responses. Once those are exhausted, the callers stop answering their own callers, and the slowdown travels back up the chain until the gateway fails.
These tools limit the damage:
- Timeouts free waiting resources after a set time.
- Bounded retries with exponential backoff and jitter absorb short failures without having every client retry at the same moment.
- Circuit breakers stop calling a dependency that is clearly failing, so requests fail fast.
- Bulkheads give each dependency its own connection or thread pool, so one slow dependency can't use up resources the others need.
Trade-offs
Badly configured retries cause retry storms that keep a struggling service down. Retries on several hops multiply load: three attempts at each of three layers can turn one request into 27 calls at the bottom service. All of these tools contain the damage, but none of them removes the coupling.
# Pseudocode: one bounded call to a downstream service.
# Tune values per dependency. These are placeholders.
MAX_ATTEMPTS = 3 # total tries, including the first
PER_TRY_TIMEOUT = 300ms # frees the waiting thread or connection
BASE_DELAY = 100ms
MAX_DELAY = 2s
OVERALL_DEADLINE = 1s # caller's budget for the whole operation
function call_with_retry(request):
deadline = now() + OVERALL_DEADLINE
for attempt in 1..MAX_ATTEMPTS:
remaining = deadline - now()
if remaining <= 0:
raise DeadlineExceeded
try:
# Never wait longer than the per-try timeout or the time left overall.
return send(request, timeout = min(PER_TRY_TIMEOUT, remaining))
catch error:
# Retry only transient failures on idempotent operations.
# Do not retry 4xx-style errors or non-idempotent writes.
if not is_transient(error) or not request.is_idempotent:
raise error
if attempt == MAX_ATTEMPTS:
raise error # bounded: give up and let the caller decide
# Exponential backoff capped at MAX_DELAY, with full jitter
# so many clients don't retry in lockstep.
ceiling = min(MAX_DELAY, BASE_DELAY * 2^(attempt - 1))
sleep(random_between(0, ceiling))
# Retry at one layer only (e.g. at the edge). If gateway, orders and
# pricing each retry 3 times, a failing inventory call can be attempted
# 3 * 3 * 3 = 27 times for a single user request.
Language-neutral sketch of a per-try timeout and overall deadline combined with bounded retries that use capped exponential backoff and full jitter, retrying only transient errors on idempotent requests.
Fixing the root cause: boundaries, data ownership and asynchronous messaging
…removes the reason the calls exist. That reason is usually a bad boundary. When you split a system by technical layer (an "API service" in front of a "DB service") or by entity CRUD (one service per table), every business operation has to touch several services. That produces the chatty calls, and because the rules are spread across those services, they have to be deployed together.
Finding better seams
Look at version-control history and incident tickets for data and rules that change together. If pricing rules and discount data always ship in the same pull request, they belong in one service. Draw boundaries around business capabilities that a single team can own from end to end, which is the organizing principle described in Fowler and Lewis's definition. Each service then owns its data, and other services reach it only through its API or the events it publishes.
Tactical fixes
| Fix | Coupling removed | New costs | Fits when |
|---|---|---|---|
| Coarser-grained API | Round trips per operation | Larger payloads, less reuse | Callers always need the data together |
| Edge aggregation | Client-to-service fan-out | Can grow into a "god" backend if business logic accumulates there | Several UIs compose the same views |
| Event-fed read model | Runtime dependency for reads | Stale data, storage, replay tooling | Reads dominate and can tolerate seconds of lag |
| Async messaging | Temporal coupling, cascading failure | Broker operations, ordering, idempotent consumers, harder tracing, user-visible delays | The process can complete later |
| Redrawn boundary | Lockstep deploys, chattiness at the source | Data migration, team reorganization | Two services always change together |
The source treats synchronous calls as harmful and argues for designing for failure. Use async messaging only where the business process tolerates delay. Keep aggregators limited to composition and keep business rules in the services that own them.
Other common anti-patterns: smart pipes, shared libraries, distributed transactions, nano-services
Smart pipes and central orchestration
When routing rules, transformations and business decisions live in a shared integration layer or message bus, that layer becomes the new monolith. Every team needs changes in it, and one team usually owns it. The microservices article by Lewis and Fowler calls the alternative smart endpoints and dumb pipes: the transport moves messages, and services own the logic.
Shared libraries
A shared domain library, such as a common Order model, ties every consumer to one release cadence. A field change then forces synchronized upgrades across services. Shared infrastructure libraries for logging, metrics or HTTP clients are usually fine because they rarely encode business meaning.
Distributed transactions
Two-phase commit across services pretends there is still one database. Sagas are the honest alternative: a sequence of local transactions, each with a compensating action. Compensation logic is complex and hard to test, and it cannot undo side effects such as a sent email or a settled external payment. Those steps need their own explicit follow-ups, such as a correction notice or a refund.
Nano-services
When a service is so small that its deployment pipeline, alerts and on-call burden cost more than the logic it holds, merge it back into a neighbour.
Missing observability
Without correlation IDs and distributed tracing, the other anti-patterns stay invisible. For example, the edge generates an ID and sends it in a header such as X-Correlation-ID: 7f3a9c. Every service forwards the header and writes the ID to each log line, such as level=error correlation_id=7f3a9c msg="payment timeout". One search then shows the whole call chain.
// Node.js 18+ using only built-ins: node:http, node:crypto and global fetch.
const http = require('node:http');
const { randomUUID } = require('node:crypto');
// Write one structured log line that always carries the correlation ID.
function log(level, correlationId, msg) {
console.log(`level=${level} correlation_id=${correlationId} msg=${JSON.stringify(msg)}`);
}
const server = http.createServer(async (req, res) => {
// Reuse the caller's ID. Generate a new one only at the first hop (the edge).
// Node lowercases incoming header names.
const correlationId = req.headers['x-correlation-id'] || randomUUID();
log('info', correlationId, 'order request received');
try {
// Forward the same ID on every downstream call so the chain stays linked.
const payment = await fetch('', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-correlation-id': correlationId },
body: JSON.stringify({ orderId: 'ord-1042', amountCents: 2599 }),
signal: AbortSignal.timeout(2000), // fail fast instead of hanging
});
log('info', correlationId, `payment responded ${payment.status}`);
// Echo the ID back so clients can quote it in bug reports.
res.writeHead(payment.ok ? 200 : 502, { 'x-correlation-id': correlationId });
res.end();
} catch (err) {
// A timeout surfaces as err.name === 'TimeoutError'.
log('error', correlationId, `payment call failed: ${err.name}`);
res.writeHead(504, { 'x-correlation-id': correlationId });
res.end();
}
});
server.listen(3000);
A minimal Node.js service that accepts or generates an X-Correlation-ID, forwards it to a downstream payment call, and writes it to every log line so one search can follow the whole request.
Remediation playbook, alternatives, and impact on cost and engineering time
That visibility makes the cheapest fixes obvious, so order the work by cost:
- Add timeouts, circuit breakers and tracing. This takes days rather than months, needs no redesign, and shows where coupling actually causes failures.
- Give every table a single owning service. Other services read through that owner's API or events, which ends hidden schema coupling.
- Merge services that always change together. If two services ship in the same release every time, the boundary between them is not doing anything.
- Redraw boundaries. This is the most expensive step, so do it last, guided by evidence from the first three.
A well-modularized monolith, or a consolidated module inside a larger service, is a legitimate end state. It is the main alternative to microservices and the better default for small teams, unclear domains, or organisations without deployment automation, monitoring and on-call capacity.
Merging has its own cost. It ends lockstep deploys, but teams can end up contending for the same code and waiting on one slower release pipeline. Draw boundaries along team ownership so each team can still ship on its own schedule.
| Modular monolith | Distributed monolith | Independently deployable services | |
|---|---|---|---|
| Deployment coordination | One release, no cross-service coordination | Lockstep releases across services | Each team releases on its own |
| Data consistency | Local transactions | Shared tables, fragile | Eventual, owned per service |
| Operational cost | Low | High | High, but justified |
| Team autonomy | Limited by the shared codebase | Low | High |
To decide whether microservices pay off for a team, compare engineering time saved with time added:
Net time saved = (coordination meetings + lockstep release work + cross-service debugging) − (pipelines + monitoring + on-call + infrastructure upkeep)
If the result is negative, consolidate.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to microservices anti-patterns, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.