Modular monolith before microservices
· 5 min read
I’ve worked on monoliths people were afraid to touch, and on microservice systems where a small change meant coordinating three deployments. Both happen, and the pattern is rarely to blame: it’s when and why it was chosen.
My position is simple: splitting a system before its boundaries are clear multiplies coupling instead of reducing it. Microservices don’t make code better organised. They make every organisational mistake more expensive, because now it goes over the network.
What microservices actually solve
Before deciding, it’s worth being honest about which problem you’re trying to solve. Microservices are good at these:
- Teams stepping on each other. Several teams touching the same code and having to coordinate every release.
- Different rhythms. One part of the system changes ten times a day, another once a quarter.
- Different scaling needs. One component needs far more capacity than the rest, or a very different resource profile.
- Fault isolation. A problem in one part shouldn’t take everything else down.
- Different technologies. One piece has a good reason to live in another language or on another database.
Notice that almost all of these are organisational and operational problems, not code design problems. If the code is messy, splitting it into services doesn’t tidy it: it spreads the mess across several repositories and adds latency.
Signs it’s time to split
I’d split a system when several of these show up at once:
- Deployment is the bottleneck. Teams wait on each other to ship, or a small change requires testing everything.
- There’s a boundary that already behaves like one. A module with a stable contract that rarely needs to change together with the rest.
- The load profile is very different. One part gets spikes that have nothing to do with the rest of the system.
- A local failure takes down too much. A bug in a secondary feature brings the main one down.
- There’s a team willing to own it. A service without a clear owner becomes the one nobody wants to touch.
Signs it isn’t yet
And I’d wait if I recognise any of these:
- Boundaries change every month. If you still don’t know where “orders” ends and “payments” begins, any boundary you draw now will be the wrong one, and moving it between services costs far more than moving it between folders.
- Every change touches several parts. If each new feature modifies three modules, splitting them turns each feature into three coordinated deployments.
- The team is small. A handful of people running ten services spend more time maintaining infrastructure than building product.
- There’s no operational foundation. Without automated deploys, centralised logs, tracing and alerts, debugging a failure that crosses services is guesswork.
- The reason is “that’s how it’s done”. The worst reason, and more common than it seems.
The middle ground: modules with explicit boundaries
Between the messy monolith and microservices there’s an option that is often the best one for a long time: the modular monolith.
It’s a single application and a single deployment, but organised inside as if it were services:
┌──────────────────────────── Application (one deployment) ────────────────────────────┐
│ │
│ ┌─────────────┐ public API ┌─────────────┐ public API ┌─────────────┐ │
│ │ Catalogue │ ◀────────────▶ │ Orders │ ◀────────────▶ │ Payments │ │
│ │ (own tables)│ │ (own tables)│ │ (own tables)│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Not allowed: importing another module's internals or reading its tables │
└──────────────────────────────────────────────────────────────────────────────────────┘
The rules that make it work:
- Each module exposes a public API and hides everything else. Other modules may only use that API.
- Each module owns its data. Nobody reads another module’s tables directly.
- Boundaries are checked automatically, with lint rules or architecture tests that fail when someone imports something internal. A convention that only lives in the wiki ends up ignored.
- Communication can use internal events where it makes sense, just as it would between services.
The best thing about this approach is that it keeps the decision open. If a module has a stable boundary and a real reason to split it appears, extracting it as a service is almost mechanical: the public API becomes an HTTP or event contract, and its tables were already its own. And if you got a boundary wrong, fixing it is a refactor inside one repository, not a migration between systems.
The real operational cost of each new service
A new service isn’t just one more folder. It’s all of this, multiplied by every service:
| Item | Modular monolith | Each new service |
|---|---|---|
| CI/CD pipeline | One | One more |
| Infrastructure and environments | Shared | Its own (or templates to maintain) |
| Calls between parts | An in-memory function call | Network: latency, timeouts, retries |
| Data consistency | Transactions | Sagas, events, eventual consistency |
| Debugging | One local trace | Distributed tracing |
| Contract versioning | Refactor in a single commit | Backwards compatibility across deploys |
| Monitoring and alerts | One set | One set per service |
None of these rows is impossible. But each one costs engineering time that isn’t spent on the product. That cost only pays off when the problem the service solves (independent teams, scaling, isolation) is bigger than it.
How I decide
When I have to decide whether to split something, I ask myself three questions:
- What concrete problem does the split solve? If the answer is “the code is messy”, the network won’t fix it.
- Has the boundary been stable for a while? If it’s still moving, it’s not yet time to turn it into a contract between services.
- Can I afford the operational cost? Pipelines, observability, on-call and versioned contracts, forever.
If all three answers are clear, I split without hesitation. If any of them isn’t, I first get things in order inside a modular monolith, and let the system tell me where its real boundaries are.
Microservices are an excellent tool for scaling an organisation. For organising code, the tool is the same as ever: good boundaries, even when they live inside a single deployment.