Why a Modular Monolith Beats Microservices (for Most Teams)
Photo: Unsplash
Somewhere along the way "monolith" became an insult. Teams started splitting their first product into a dozen services before they had a dozen users, convinced that microservices were what serious engineering looked like. Then they spent the next year wiring up service meshes and debugging distributed traces instead of shipping features. There's a better default for most teams, and it isn't a big ball of mud — it's a modular monolith.
A modular monolith is a single deployable unit with strict internal boundaries. Modules talk to each other through well-defined interfaces, own their data, and can't reach into each other's internals. You get most of the design discipline microservices force on you, without paying the network and operational tax for it. The distinction matters because the value microservices advocates actually want — clear boundaries — comes from modularity, not from distribution.
The Thing You Actually Want Is Modularity
When people praise microservices, listen closely and they're usually praising the consequences of having to define boundaries: separate codebases, explicit contracts, independent reasoning. None of those require a network call. As Simon Brown put it, "if you can't build a well-structured monolith, what makes you think microservices is the answer?" Distribution doesn't grant you good boundaries; it just makes bad ones much more expensive to fix.
A modular monolith enforces boundaries in the code:
// modules/billing/index.ts — the ONLY public surface of billing
export interface BillingModule {
charge(customerId: string, cents: number): Promise<ChargeResult>;
refund(chargeId: string): Promise<void>;
}
// Internal repositories, entities, and tables stay unexported.
// Other modules depend on this interface, never on billing's internals.// modules/orders/place-order.ts
import type { BillingModule } from "../billing";
// Orders knows the billing CONTRACT, not its database or implementation.
export async function placeOrder(billing: BillingModule, order: Order) {
const result = await billing.charge(order.customerId, order.totalCents);
if (!result.ok) throw new PaymentFailedError();
// ...
}The boundary is just as real as a network boundary — the compiler enforces it — but a call across it is a fast, reliable, in-process function call instead of a fallible HTTP request.
The key discipline is that modules share no database tables. Billing owns its tables; orders owns its tables. They communicate only through public interfaces. Get this right and extracting a module into a service later becomes mechanical instead of archaeological.
What You Keep By Not Distributing
Staying in one process hands you a long list of capabilities for free — the exact ones microservices make you rebuild:
- ACID transactions. A single database means you can wrap a multi-module operation in one transaction. No sagas, no compensations, no eventual consistency, no two-phase commit. Correctness is the database's job, not yours.
- One deployment. One CI pipeline, one artifact, one rollback. No version-compatibility matrix between services, no orchestrating deploys across teams.
- Trivial debugging. A bug is one stack trace in one process. You can set a breakpoint and step through the entire flow.
- Fast local development.
git clone, run one process, and the whole system is up. New hires are productive in an afternoon, not a week of spinning up dependencies.
-- In a modular monolith this is one transaction and it just works:
BEGIN;
INSERT INTO orders (id, customer_id, total) VALUES (...);
UPDATE inventory SET available = available - 1 WHERE sku = 'ABC';
INSERT INTO ledger (order_id, amount) VALUES (...);
COMMIT;
-- Across three microservices, this same guarantee costs you a saga.That single transaction is not a small convenience. It's the difference between a 20-line function and a stateful, retryable, idempotent distributed workflow with its own failure modes.
Performance and Scale, Honestly
The most common objection is "but it won't scale." For the overwhelming majority of systems, this is folklore. A modular monolith scales horizontally just fine: run multiple identical instances behind a load balancer. What you can't do is scale one module independently of the others — and most teams never actually need to. Shopify famously runs one of the largest Rails modular monoliths in the world, serving enormous traffic, precisely because they invested in modularity instead of premature distribution.
Microservices let you scale the inventory service to 50 replicas while billing runs on two. That's a genuine capability — but you only need it when scaling profiles genuinely diverge, which is a specific, measurable condition, not a default assumption. Until you can point at the bottleneck, you're buying a solution to a problem you don't have.
Build the modular monolith first. When a specific module proves it needs independent scaling, deployment cadence, or fault isolation, extract that one module into a service. Because its boundary was already clean, extraction is a refactor, not a rewrite.
The Migration Path Is the Real Win
The strongest argument for starting modular is that it keeps every option open. Microservices are a one-way door that's expensive to walk back through. A modular monolith is a hallway: you can stay, or you can open the door to a specific service when the evidence demands it.
This is the MonolithFirst strategy, and it works because boundaries are cheap to move inside one codebase and expensive to move across a network. Drawing your service lines on day one, before the domain has taught you where the real seams are, almost guarantees you'll draw them wrong. Inside a monolith, redrawing a boundary is a refactor your IDE can mostly do for you. Across services, it's a coordinated migration with data movement and downtime.
Start as a modular monolith. Extract services reluctantly, one at a time, each justified by a named problem. For most teams, that day never comes — and that's not a failure, it's the system doing its job.
Takeaways
- The value people attribute to microservices comes from modularity, which you can have in a single process.
- A modular monolith enforces boundaries in code — public interfaces, no shared tables — without network calls.
- Staying in one process preserves ACID transactions, single-step deploys, easy debugging, and fast onboarding.
- A modular monolith scales horizontally; you only lose independent per-module scaling, which most teams never need.
- Clean module boundaries make later extraction into a service a refactor rather than a rewrite.
- Default to modular monolith, extract services one at a time, and only when a specific problem justifies it.

