Writing
Why we build modular monoliths first, and when we actually split
6 min read
The microservices debate is usually conducted as if it were about technology. It is not. It is about how many teams need to deploy independently, and almost every argument that ignores that ends up somewhere unhelpful.
Our default is a modular monolith, and we mean something specific by it — not "one big codebase", which is what people picture, but one deployable made of modules with enforced boundaries. The distinction is the whole point.
What microservices actually buy
Three things, and it is worth being precise because only one of them is usually the reason a team wants them.
| Benefit | Real? | Available another way? |
|---|---|---|
| Independent deployment per team | Yes, and it is the point | No — this genuinely requires separate deployables |
| Independent scaling | Yes, sometimes | Often — most services scale together anyway |
| Technology heterogeneity | Yes | Rarely wanted once the second language needs a second on-call rota |
| Enforced module boundaries | No — a side effect | Yes. A linter does this for free. |
That last row is where most teams actually are. They want boundaries that cannot be violated, and they have been told the way to get them is a network call.
What the bill looks like
Every one of these is work you do not do in a monolith:
Every call can fail halfway
A function call either returns or throws. A network call can also time out after the work completed — so every mutating call needs an idempotency key and a retry policy, or you get duplicates.
Transactions become sagas
Two writes across two services cannot be one transaction. You now write compensating actions and reason about the states between them. This is genuinely hard, and it is the cost people most consistently underestimate.
Debugging needs infrastructure
A stack trace stops at the boundary. You need distributed tracing, correlated logs and propagated context before you can answer "why was this request slow", which was previously a profiler.
Local development gets worse for everyone
Running the system means running many things. The usual answers — docker-compose, a shared dev cluster, mocks — all trade fidelity for convenience, and the gap between local and production is where bugs live.
Deployment becomes a version-compatibility problem
Two services deploy at different times, so every change to a shared contract has to be backwards compatible for at least one release. Every change.
None of that is an argument against microservices. It is the price, and it is worth paying when you are buying the first row of that table. It is a bad trade when you are buying the last one.
What "modular" has to mean to be worth anything
A monolith without enforced boundaries becomes the thing everyone is afraid of. Four rules do most of the work.
One: modules own their data
The rule that matters more than all the others. No module reads another module's tables. Not "should not" — cannot, enforced by schema-level permissions if your database supports it.
billing.invoices ← only the billing module
catalogue.products ← only the catalogue module
shared.audit_log ← append-only, written by many, owned by platformCross-module reads go through the owning module's public interface. This is the discipline that makes a later split possible, because a module that reads five other modules' tables cannot be extracted at any price.
Two: an explicit public interface per module
Each module exposes a small, deliberate surface and everything else is internal.
// modules/billing/index.ts — the entire public surface
export { createInvoice, voidInvoice, getInvoice } from './service';
export type { Invoice, InvoiceStatus } from './types';
// Everything else in ./internal is unreachable from outside.Enforce it mechanically — a lint rule that forbids deep imports across module folders. A convention nobody can violate is worth ten that everyone agrees with.
Three: modules talk through interfaces, not each other's internals
// ✗ billing now knows how the catalogue stores prices
import { priceTable } from '../catalogue/internal/db';
// ✓ billing knows only what it needs
import { getPrice } from '../catalogue';Four: asynchronous where it is genuinely asynchronous
When a module needs to notify rather than ask, publish an event — even in-process. It decouples the modules, and it is the seam a message broker slots into later without touching either side.
// In-process today. The same call site works with a broker behind it tomorrow.
events.publish('invoice.paid', { invoiceId, amount, paidAt });The four signals that mean it is time
Not "we have grown". Specific, observable conditions:
Teams are blocking each other on deploys
Two teams, one pipeline, and a revert for one is a revert for both. This is the real signal and it is organisational, not technical.
One module's resource profile is genuinely different
Video transcoding beside a CRUD API. One needs machines the other does not, and scaling together wastes most of them.
One module has a different availability requirement
Payment capture must stay up while the admin console may be down for an hour. Coupling them in one deployable means the strictest requirement applies to everything.
A compliance boundary requires isolation
A regulator or a customer requires that a subsystem run separately, with its own audit and access path. This is a requirement rather than an engineering choice.
If none of those is true, splitting buys the last row of the first table at the price of the whole second list.
Splitting a module that was built this way
When a signal does fire, extraction is a known procedure rather than a rewrite — because the data boundary was there from the start.
Confirm the boundary is real
Does anything outside the module touch its tables? If yes, that is the work, and it is the whole work.
Put the interface behind a transport
Same functions, now over HTTP or a queue, still in the same deployable. Nothing has moved yet and you can measure the added latency honestly.
Deal with the calls that were transactional
Every place that wrote across the boundary in one transaction now needs an idempotency key and a compensating action. This is the real cost, and doing it while both halves still ship together is much safer than doing it during a move.
Then move it
Separate deployable, separate pipeline, separate database credentials. By this point it is a deployment change rather than an architecture change.
The honest summary
Start with a modular monolith because it gives you the boundaries — which is what you actually wanted — while keeping function calls, real transactions, one deployment and a stack trace that goes all the way down.
Split when an organisational or operational fact forces it, not when the architecture diagram would look more modern. And when you do split, split the module whose boundary you have been maintaining all along, which is the only kind that comes out cleanly.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.