Writing

Why a small team should start with a modular monolith

Microservices solve a coordination problem most small teams do not have yet. A modular monolith gives you the same boundaries without the distributed system.

Microservices answer a question about people rather than about code. Once enough teams need to ship the same system without queueing behind each other, splitting the system along team lines starts to pay for itself. A company with one team of five engineers is a long way from that. Adopting the architecture anyway means paying the running costs of a distributed system to solve a coordination problem the company does not have yet.

The alternative is a single deployable application with real internal boundaries, which is a different thing from the tangled codebase people picture when they hear “monolith”.

What a modular monolith is

A modular monolith is one deployable application divided internally into modules that own their data and expose a narrow public interface. Modules call each other in process, through those interfaces, and never by reaching into each other’s tables. The boundaries are the same ones you would draw between services. What you leave out is the network between them.

The network is the expensive part

Once a call leaves the process it acquires failure modes that an in-process call does not have. It can be slow, it can be retried, it can succeed on the server and fail on the client, and it can arrive twice. Each of those needs handling code: timeouts, retries with backoff, idempotency keys, circuit breakers, and something that reconciles state when the retry lands anyway.

Data is the same story. A unit of work that used to be one database commit becomes a sequence of steps that can fail halfway through. You either accept that some states will be temporarily wrong and design around it, or you write a compensating action for every step. Both are real work, and both replace something a single transaction did for free.

Then there is everything around the code. Each service needs its own pipeline, its own configuration, its own alerting, its own place in whatever traces a request end to end, and its own answer to the question of who gets paged. Four engineers running six services own six of each. None of it is individually difficult. It is constant, and it comes out of the same budget as the product.

What one process buys you

Staying in one process costs a small team almost nothing, because nobody is waiting on somebody else’s release. In exchange:

  • A call between modules is a function call. It cannot time out, arrive twice, or half-succeed.
  • A unit of work is one database transaction. It commits or it does not.
  • A request produces one stack trace and one log stream, in order.
  • A release is one artifact through one pipeline.
  • A boundary you drew in the wrong place is a refactor, rather than a data migration plus a versioned contract plus a coordinated rollout.

That last point is the one people underestimate. Early on, the boundaries are guesses. Being able to move them cheaply is worth more than being able to deploy them separately.

Drawing the boundaries

The boundaries are the part worth the effort, and they are the same whether you deploy once or twenty times.

Cut along business capabilities, not technical layers. Ordering, inventory, billing, and notifications are modules. Controllers, services, and repositories are not. A layered cut produces a codebase where every feature touches every folder, which is the version of “monolith” everyone is afraid of.

Give each table exactly one owner. A module owns its data and no other module reads it directly. When ordering needs a stock level it asks the inventory module. This single rule does most of the work, because shared tables are the thing that makes a system impossible to separate later, and they are cheap to add and expensive to undo.

Keep the public surface small and explicit. A module exposes an interface and a set of types. Everything else is internal. Most languages can enforce that with their package or namespace system, and where the language cannot, a build check can.

Use in-process events for anything that is not the caller’s business. When one module needs to react to something another module did, publishing an event usually fits better than a direct call. Ordering does not need to know that notifications exist.

Keeping the boundaries honest

Boundaries decay under deadline pressure unless something enforces them. A build-time dependency rule that fails when a module imports another module’s internals is worth more than any diagram, because it fails on the branch instead of at the next architecture review.

Two failure patterns are worth watching for. The first is the shared common package, which starts life holding a date helper and ends up holding half the domain. If something is used by two modules, it probably belongs to one of them. The second is a module that everything depends on and that depends on nothing. That usually means the boundary was drawn around a data structure rather than a capability.

When to actually split something out

Extraction is a decision with a trigger. The triggers are specific:

  • A part of the system needs different hardware or a different runtime from the rest. Image processing, model inference, and long running jobs are the usual candidates.
  • A part needs to scale on its own axis, and scaling the whole application to serve it costs more than running it separately.
  • A second team takes ownership of something and the shared pipeline becomes the queue everyone waits in.
  • A compliance or data residency requirement forces isolation.

“We might need to scale one day” is not on that list. When one of the real triggers does fire, a module that already owns its data and exposes a narrow interface is most of the way to being a service: the interface becomes the API, the tables move with it, and the callers change one import.

That is the practical argument for building this way. The work that keeps a monolith modular is the same work that makes it splittable, so you get the option without paying for it up front.

This is the architecture I train teams on when they ask how to structure a system they will still be able to change in three years. The reason is unglamorous. For a team with more product to build than infrastructure to run, it keeps the most decisions open at the lowest running cost.

When one of those triggers does fire, the split has its own set of decisions — where the boundary falls, who owns which data across a network, what replaces the single transaction, and what has to exist operationally before anything ships. Those are the subject of what a team decides before its first microservice, which picks up where this post ends. Both are material I cover in team training.

← Back to the blog