Skip to content
parvezive
Subscribe

Practical notes on leadership and architecture, every other week.

Subscribe to the newsletter
All writing
Architecture

Start with a modular monolith

Most teams reach for microservices to fix a problem they don't have yet. Draw the boundaries inside one deployable first, and let the system tell you when a module has earned its own service.

Start with a modular monolith: title card in the parvezive style

Parvez · · 5 min read

Share

Every few years I sit in a kickoff meeting for a new system where someone draws twelve boxes on a whiteboard, labels each one a service, and the room nods. The product doesn't exist yet. There are five engineers. Nobody knows which parts of the domain will change every week and which will never be touched again. But the boxes are drawn, and in six months the team is spending more time on deployment pipelines, contract tests and distributed tracing than on the product.

I've made this mistake myself, and I've been brought in to untangle it more than once. My advice now is boring and consistent: start with a modular monolith. One deployable, one database, and strong boundaries inside it. Then let the system tell you when a module has earned the right to become a service.

What a modular monolith actually is

A modular monolith is not "a monolith, but we promise to be careful". It's a single application split into modules that each own a piece of the domain, with rules about how they talk to each other that are enforced, not just hoped for.

In practice, a good one has four properties:

  • Each module owns its data. Orders owns the orders tables. Billing doesn't read them directly, even though it technically could.
  • Modules talk through a published interface. A small set of public types and methods per module; everything else is internal.
  • Dependencies point one way. If Billing depends on Orders, Orders never depends on Billing. Cycles are the first sign the boundaries are wrong.
  • The rules are checked by the build. An architecture test, a module system or a build-tool constraint fails the build when someone reaches across a boundary.

That last point is what separates a modular monolith from a big ball of mud with nice folder names. Good intentions erode under deadline pressure. A failing build doesn't.

Why it's the right default

You'll get the boundaries wrong, and that's fine

Early in a product's life, your understanding of the domain is at its weakest. The first boundaries you draw will be wrong in some places. Inside a monolith, moving a boundary is a refactoring: rename a package, move some classes, update the tests, done by Friday. Across services, it's a migration: new APIs, versioning, data copied between databases, two deploys coordinated across teams. The cost of being wrong is ten times higher, at exactly the moment you're most likely to be wrong.

Distributed systems tax everything

The moment a function call becomes a network call, you inherit a list of new problems. Partial failure. Retries and idempotency. Timeouts. Eventual consistency between data stores. Tracing a request across five logs. Each one is solvable, but each one costs engineering time that isn't going into the product. A small team pays that tax on every feature.

A function call never times out, never gets retried twice, and never arrives out of order. Give that up only when you're getting something real in return.

Operations stay simple

One thing to deploy, monitor and roll back. One database to back up. Local development is "run the app", not "start fourteen containers and hope the laptop survives". New engineers are productive in days instead of weeks.

How to draw the boundaries

Start from the business, not the technology. The question is not "what are our layers?" but "which parts of this domain change for different reasons, at different speeds, for different people?"

A few heuristics I've relied on for years:

  1. Follow the nouns the business uses. If the finance team talks about invoices and the warehouse talks about shipments, those are probably different modules, even if they share an order ID.
  2. Look for different rates of change. Pricing rules that change weekly shouldn't live in the same module as the customer record that changes once a year.
  3. Keep transactions inside a module where you can. If two things must always change together, atomically, they probably belong together.
  4. Prefer fewer, larger modules at first. Splitting a module later is easy. Merging two that have grown separate vocabularies is not.

Make the boundary visible in code

In a Java codebase, I like one top-level package per module, with an api package that other modules may use and everything else package-private or internal. An architecture test then checks the rules on every build: no module imports another module's internals, and there are no cycles. Spring Modulith, ArchUnit and the Java module system all do this well; the tool matters far less than having the check at all.

When a module should become a service

A module should be extracted because something is pushing it out, not because the diagram looks tidier with more boxes. These are the signals I look for:

  • Different scaling needs. One module needs forty instances at peak while the rest of the app is happy with four.
  • Different reliability needs. A failure in one area must never take down another, for example payments versus a recommendation feed.
  • Different release cadence. One team ships ten times a day and is constantly blocked by the release train of everything else.
  • Different technology needs. A module genuinely needs a different runtime, language or data store.
  • Team ownership. A separate, stable team owns the module end to end, and coordinating deploys has become the bottleneck.

If none of these is true, the module stays where it is. If one is, you're in a good position: the module already owns its data and already has a published interface, so extraction is mostly about swapping an in-process call for a network one. That's the payoff of doing the boundary work early.

What goes wrong

Modular monoliths fail in predictable ways, and almost all of them come back to boundaries that exist only on a slide.

  • Shared tables. Two modules writing to the same table are one module pretending to be two. Pick an owner.
  • The "common" module. It starts with a few utilities and ends up holding half the domain. Keep shared code to genuinely generic things, and be suspicious of anything with business rules in it.
  • No enforcement. Without a build check, boundaries quietly dissolve within a year. I've seen it on every team that relied on code review alone.
  • Splitting too early anyway. Teams sometimes build a modular monolith and then extract everything in the first year because it felt like the plan. Wait for the signals.

Where to start on Monday

If you're starting a new system, create the module structure and the architecture test before the first feature, while it costs almost nothing. If you're inside an existing monolith, don't plan a rewrite. Pick the one area that changes most often, give it a clear interface, move its tables under its ownership and add a test that keeps everyone else out. Then do the next one.

Microservices are a fine answer to specific problems. They're a poor starting point. Earn them.

ArchitectureMicroservicesSystem design

Written by Parvez

Engineering Manager at Innova Solutions and a software architect with 25+ years in the industry. I write about leadership, architecture, Java and the books that shaped how I work.

More about me

Keep reading

All posts

Get the next essay in your inbox

Practical notes on engineering leadership and architecture, written for people doing the work. Free, every other week.