Microservices vs Monolith (and the Modular Monolith)
The architecture decision, honestly: why a monolith is the right default, what microservices actually cost you, and how the modular monolith gives you most of the upside without the distributed-systems tax.
Somewhere between the conference talks and the FAANG engineering blogs, an entire generation of teams absorbed a quiet assumption: that microservices are the grown-up choice and a monolith is what you build before you know better. So they split a five-person codebase into fifteen services, and spend the next two years debugging timeouts instead of shipping features.
This article is the honest version of the conversation. The monolith is the right *default*. Microservices solve real problems, but they are organizational problems far more often than technical ones, and they charge a steep, permanent tax to do it. In between sits the option most teams should actually reach for: the modular monolith.
Who this is for
Engineers and tech leads staring at a 'should we go microservices?' decision, whether you are starting fresh, feeling pain in a big monolith, or sceptical that the rewrite your team is lobbying for will actually help. If you have ever been told 'we need to split this up to scale,' read on before you agree.
You must be this tall to ride
You must be this tall to use microservices. If you cannot deploy, monitor, and roll back a single service automatically, you are not ready to operate twenty of them.
Fowler's point is brutal and correct: microservices are a capability multiplier, not a capability. They multiply whatever your team already has. If your CI is flaky, your observability is a Slack channel, and a deploy is a manual SSH session, then twenty services will multiply that chaos by twenty. The prerequisites, automated deploys, per-service monitoring, distributed tracing, on-call rotation, are themselves a year of work. If you have not paid for them, you are not buying microservices; you are buying a distributed mess.
One big house: every room shares walls, plumbing, and a front door. You walk between rooms instantly.A monolith: modules share a process and a database. A function call to another module is free and instant.
A neighbourhood of houses with roads between them. Each family owns its house, but visiting a neighbour means crossing a street that can be blocked, slow, or closed.Microservices: each service owns its data and deploys alone, but every interaction is a network call that can time out, fail, or arrive twice.
One large house with clearly separated, self-contained apartments and locked internal doors.A modular monolith: one deployable, but strict module boundaries, you keep instant in-house travel and gain the option to move an apartment into its own house later.
A codebase is a place people live and move around in.
The two topologies, side by side
Here is the same e-commerce system drawn two ways. On the left, a modular monolith: one process, internal modules, one database with separate schemas. On the right, the microservices version: a gateway fanning out to independent services, each with its own database, talking over a message bus for anything async.
Modular monolith (single deployable, internal modules) vs microservices (gateway, N services, per-service DBs, message bus).
1
A request arrives
In the monolith, the client hits the process directly. In microservices, it hits the gateway first, your new single front door that handles auth, routing, and rate limiting. See [API gateways and the edge](/blog/api-gateways-and-the-edge).
2
Work gets dispatched
Monolith: the Orders module calls the Payments module as a normal function, nanoseconds, same memory. Microservices: Orders makes an HTTPS call to Payments across the network, milliseconds, and it can fail.
3
Data gets read and written
Monolith: one transaction across module schemas, ACID guarantees for free. Microservices: each service writes its own DB; there is no cross-service transaction, so consistency becomes your job.
4
Async things happen
Both can publish events to a bus for things like 'send a receipt email.' In the monolith this is optional; in microservices it is how services stay decoupled. See [event-driven architecture on the cloud](/blog/event-driven-architecture-on-the-cloud).
Three options, what they actually cost
The honest comparison is not 'monolith vs microservices.' It is three points on a spectrum, and the middle one is criminally underused. The column that matters most is the last one, ops cost, because it is the bill you pay every single day forever, not just at build time.
Axis
Monolith
Modular Monolith
Microservices
Deploy
One artifact, all-or-nothing
One artifact, all-or-nothing
Per-service, independent
Data
Shared DB, ACID free
Shared DB, schema per module
DB per service, eventual consistency
Debugging
One stack trace, one log
One stack trace, module-scoped logs
Distributed tracing required
Team scale
Strong up to ~1 team
Good up to a few teams
Scales to many autonomous teams
Ops cost
Low
Low
High and permanent
The same axes, three architectures. The modular monolith keeps the cheap columns cheap.
The middle path is the surprise winner
A modular monolith gives you the deploy, data, and debugging simplicity of a monolith with the internal boundaries of microservices. You get clean seams now, and the option to extract a module into a service later, when, and only when, the data tells you to.
A boundary in code: in-process vs over the wire
The single most important habit is to give each bounded context a narrow, explicit interface, even inside a monolith. Modules talk through that interface, never by reaching into each other's tables. Here is a checkout flow calling the payments context as an in-process module:
checkout.ts (modular monolith)
typescript
// The payments module exposes ONE interface. No one else// touches its tables, that is the whole discipline.import { paymentsModule } from'../payments';
import { ordersRepo } from'./orders.repo';
exportasyncfunctionplaceOrder(cart: Cart): Promise<Order> {
// One database, one transaction. ACID is free here.return ordersRepo.transaction(async (tx) => {
const order = await ordersRepo.create(tx, cart);
// In-process call: nanoseconds, cannot 'time out',// cannot half-succeed. It either returns or throws.const receipt = await paymentsModule.charge(tx, {
orderId: order.id,
amount: cart.total,
});
await ordersRepo.markPaid(tx, order.id, receipt.id);
return order;
});
}
Now extract Payments into its own service. The *interface* barely changes, but the failure modes explode. The transaction is gone, the call can hang, the network can lie, and a success can be reported as a failure (and vice versa):
checkout.ts (microservices)
typescript
import { paymentsClient } from'../clients/payments';
import { ordersRepo } from'./orders.repo';
exportasyncfunctionplaceOrder(cart: Cart): Promise<Order> {
// Orders DB only. There is NO cross-service transaction.const order = await ordersRepo.create(cart);
let receipt;
try {
// Network call. Now everything can go wrong.
receipt = awaitwithRetry(
() =>
paymentsClient.charge(
{ orderId: order.id, amount: cart.total },
{ timeoutMs: 2000 }, // a hang is now YOUR problem
),
{ attempts: 3, backoffMs: 200 },
);
} catch (err) {
// Did the charge fail, or did it succeed and the// RESPONSE time out? You cannot tell. You must make// charge() idempotent and reconcile out-of-band.await ordersRepo.markPaymentPending(order.id);
thrownewPaymentUncertainError(order.id, err);
}
await ordersRepo.markPaid(order.id, receipt.id);
return order;
}
The timeout is not the bug, it is the architecture
Retries, timeouts, idempotency keys, and 'I genuinely do not know if that worked' are not edge cases you bolt on. They are the permanent, baseline reality of every single cross-service call. Multiply that by every interaction in your system.
Distributed transactions: the guarantee you lose
In the monolith, 'create the order AND charge the card, or do neither' was one BEGIN...COMMIT. Split the two across services and that guarantee is gone. There is no distributed transaction you can reasonably run in production, two-phase commit is a famous availability and latency trap that locks resources across the network and falls over when any participant is slow.
You cannot have it back
Once data lives in separate services with separate databases, atomic 'all-or-nothing' across them is off the table. Your only real option is to embrace eventual consistency and design for partial failure explicitly, which is what the saga pattern is for.
The saga pattern: consistency without a transaction
A saga replaces one big transaction with a sequence of local transactions, each in its own service, stitched together by events. If a step fails, you run compensating actions to undo the earlier steps, there is no rollback, only forward-correction. There are two flavours.
Orchestration, one coordinator in charge
A central orchestrator tells each service what to do and reacts to the result. The logic lives in one place, which makes the flow easy to read, test, and visualise, at the cost of one component knowing about everyone.
order-saga.orchestrator.ts
typescript
// One place owns the flow. Each step is a local tx in// its own service; on failure we COMPENSATE, not roll back.asyncfunctionrunOrderSaga(cart: Cart) {
const order = await orders.create(cart); // step 1try {
await payments.charge(order.id, cart.total); // step 2await inventory.reserve(order.id, cart.items); // step 3await orders.confirm(order.id);
} catch (err) {
// Walk it back with compensating actions.await payments.refund(order.id).catch(noop);
await inventory.release(order.id).catch(noop);
await orders.cancel(order.id, 'saga_failed');
throw err;
}
}
Choreography, services react to events
No coordinator. Each service listens for events and emits its own. It is maximally decoupled, but the flow is implicit, it exists only as the emergent sum of every subscriber, which is wonderful until you need to understand why an order is stuck.
Aspect
Orchestration
Choreography
Flow logic
Centralised, explicit
Distributed, emergent
Coupling
Coordinator knows all
Services know only events
Debugging
Read one orchestrator
Trace events across services
Best for
Complex, ordered workflows
Simple, loosely-coupled reactions
Pick orchestration when the flow is complex; choreography when decoupling matters most.
Either way, you are now writing and testing compensation logic for every failure path, code that simply did not exist in the monolith. That is the price of consistency once the database boundary is crossed.
Conway's Law: the real reason to split
Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure.
This is the part the hype skips. Microservices are, at their core, an answer to an organizational scaling problem, not a performance one. When you have many teams all committing to one monolith, they collide: merge conflicts, release trains, 'we cannot deploy because team B's feature is half-done.' Splitting the system along team boundaries lets each team own, deploy, and operate its piece on its own cadence. That autonomy is the prize.
The corollary is the warning: if you draw service boundaries that do not match how your teams actually communicate, you get the worst of both worlds. So the right signals to split are organizational, not technical.
Many teams, one codebase, constant contention, release coordination is eating real engineering weeks.
Genuinely different scaling profiles, one part needs 50 instances, another needs 2, and you are over-provisioning the whole monolith to feed the hungry part.
Independent deploy cadence is a hard requirement, a regulated component must ship on its own schedule and audit trail.
Different reliability or compliance domains, payments must not share a blast radius with the marketing widget.
Notice what is not on the list
'We want to use a different language,' 'microservices are best practice,' and 'we read it scales better' are not reasons. A monolith scales horizontally just fine behind a load balancer. See [scalability principles](/blog/scalability-principles) for what actually moves the needle.
Finding boundaries: contexts, not layers
The single most expensive mistake in this whole space is splitting by technical layer instead of business capability. A 'controllers service,' a 'services service,' and a 'repositories service' is an architecture that requires three deploys to ship one feature, because every feature cuts across all three. That is a distributed monolith, all the cost, none of the autonomy.
Instead, find your bounded contexts, the term comes from Domain-Driven Design. A bounded context is a slice of the business where a word means one specific thing and the data and rules hang together: Ordering, Catalog, Payments, Shipping, Identity. Each owns its data and exposes a narrow contract. The test is simple: a typical change should land inside *one* context. If your features keep cutting across many, your boundaries are wrong.
Slicing a cake into thin horizontal layers, sponge, cream, sponge. Every serving needs all layers, so you can never hand someone just one.Splitting by layer (UI / logic / data): every feature touches every service. Three deploys per change.
Cutting the cake into vertical wedges, each wedge is a complete, self-contained serving.Splitting by bounded context: each service is a whole capability. One change, one deploy.
Layers run horizontally through everything; contexts are vertical slices that stand alone.
Strangler-fig: how to migrate without a rewrite
If you do decide to extract a service, do not do a big-bang rewrite, they are where projects go to die. Use the strangler-fig pattern (named after the vine that grows around a tree and gradually replaces it): you wrap the monolith, route one capability at a time to a new service, and shrink the old system slice by slice until it is gone or small enough to keep.
1
Put a seam in front
Route all traffic through a facade or gateway. Today it forwards everything straight to the monolith, but now you have a place to intercept.
2
Pick one bounded context
Choose a context with the clearest boundary and the strongest case to move, usually the one with a distinct scaling or compliance need. Resist starting with the hardest, most entangled one.
3
Build it alongside, dual-write if needed
Stand up the new service. Migrate its data, and during the transition write to both old and new stores so you can compare and fall back safely.
4
Flip a slice of traffic
Route that one capability's calls to the new service behind a flag. Start with a small percentage, watch your metrics and traces, then ramp.
5
Delete the old code
Once the new service owns it fully, rip the capability out of the monolith. The monolith genuinely shrinks, this is the step teams skip, and skipping it gives you two systems instead of one.
6
Repeat, or stop
Extract the next context only if it has earned it. It is completely valid to extract two services and leave the rest a happy modular monolith forever.
A modular monolith makes this almost easy
If your modules already talk through narrow interfaces and own their schemas, extraction is mostly swapping an in-process call for a network client. The hard architectural work was done up front, which is the whole argument for starting modular.
Common mistakes that cost teams years
Splitting by layer, not capability. A UI service, a logic service, and a data service mean every feature spans all three. You shipped a distributed monolith, maximum coordination, zero autonomy.
Sharing one database across services. The moment two services read and write the same tables, they are coupled at the deepest possible level. One schema change breaks both, and you can no longer deploy them independently, which was the entire point.
The distributed monolith. Services that must be deployed together, in lock-step, because they are chatty and tightly coupled. You pay every microservices cost, network, tracing, ops, and get none of the independence. The worst of all worlds.
Going micro on day one. Splitting before you understand your own domain. Boundaries drawn in month one are almost always wrong, and moving a boundary across services is brutally expensive compared to moving it inside a monolith.
Skipping the prerequisites. No automated deploys, no distributed tracing, no per-service alerting, then operating fifteen services by hand. You are not too tall for the ride; you climbed on without the safety bar.
Takeaways
The whole article in nine lines
The monolith is the right **default**, start there, almost always.
Microservices solve an **organizational** scaling problem, not usually a technical one.
Their cost, network failure, lost transactions, distributed debugging, ops, is **permanent and daily**.
The **modular monolith** is the underused middle: monolith simplicity, microservice seams.
Find boundaries by **bounded context** (business capability), never by technical layer.
Crossing a service boundary means **losing ACID**, you trade it for sagas and compensation.
Sagas come in two shapes: **orchestration** (central, explicit) and **choreography** (decentralised, emergent).
Migrate with the **strangler-fig**, one slice at a time, never a big-bang rewrite.
Split only on a real **org or scaling signal**, and be happy stopping after one or two services.
Where to go next
Architecture is a chain of trade-offs, and this decision touches the rest of your system. If you are weighing a split, get clear first on what actually scales, how services should communicate, and how requests reach them.
Scalability principles, what really lets a system grow, and why a monolith scales further than you think.
Practice the operational side in the networking lab and the kubectl lab, the muscles you need before you run many services.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.