Skip to content

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.

BenefitReal?Available another way?
Independent deployment per teamYes, and it is the pointNo — this genuinely requires separate deployables
Independent scalingYes, sometimesOften — most services scale together anyway
Technology heterogeneityYesRarely wanted once the second language needs a second on-call rota
Enforced module boundariesNo — a side effectYes. A linter does this for free.
The first is the real benefit and the only one that reliably justifies the cost. The other two are usually available without the network.

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

code
billing.invoices        ← only the billing module
catalogue.products      ← only the catalogue module
shared.audit_log        ← append-only, written by many, owned by platform

Cross-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.

ts
// 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

ts
// ✗ 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.

ts
// 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.