Monolith First: When (and When Not) to Split into Microservices#
I have been in two kinds of painful meetings. In the first, a team of five explains why their twelve microservices need a dedicated platform engineer just to keep local development working. In the second, a team of eighty explains why a one-line change takes three days to deploy because everything lives in one tangled codebase.
Both teams chose an architecture that did not match their situation. The decision is not "monolith or microservices". It is when to split, and along which lines.
What microservices actually cost#
Microservices are often sold as a scalability solution. Their real benefit is organizational: independent teams that can build, deploy and scale their part of the system without coordinating with everyone else.
That benefit comes with a long list of costs:
- Network calls instead of function calls, with latency, timeouts and partial failure on every hop.
- Distributed data: no more joins or transactions across boundaries, so consistency becomes eventual and harder to reason about.
- Operational overhead: many deploy pipelines, many dashboards, service discovery, distributed tracing.
- Harder local development and testing: running a realistic environment means running many services.
- Versioned contracts between services that can no longer change in a single commit.
A small team pays all these costs and gets little of the benefit, because it never had a coordination problem to begin with.
Start with a modular monolith#
For a new product, I start with a single deployable application with strong internal boundaries:
1src/modules/2āāā billing/3ā āāā billing.module.ts4ā āāā billing.service.ts # public API of the module5ā āāā internal/ # not importable from other modules6āāā campaigns/7āāā affiliates/8āāā notifications/
The rules that make it modular instead of a big ball of mud:
- Modules talk to each other only through a small public service interface, never through each other's internals.
- Each module owns its tables. Other modules do not query them directly.
- Cross-module side effects go through in-process events:
PayoutCompleted is published by billing and consumed by notifications.
Enforce the boundaries with lint rules or architecture tests, because conventions alone erode under deadline pressure.
Signals that it is time to split#
Extract a service when there is a concrete, painful reason ā not because it seems more professional. The signals I look for:
- Team coordination pain. Multiple teams constantly block each other on the same deploy pipeline.
- Very different scaling needs. One component needs 50 instances while the rest needs two, or needs GPUs.
- Different reliability requirements. The payment path must stay up even when the reporting module is overloaded.
- Different release cadences or tech stacks, for example a machine-learning service in Python.
- Isolation for security or compliance, such as keeping card data in a separate, tightly audited system.
If none of these apply, the monolith is still the right answer.
How to split safely#
Because the modular monolith already has clean boundaries, extraction is a series of small steps rather than a rewrite:
- Pick the module with the clearest boundary and the strongest reason to move.
- Replace in-process calls to it with an interface that could be backed by either a local call or a network call.
- Move its tables to a separate database, migrating data with care.
- Deploy it as a separate service and switch the interface to the network implementation, behind a feature flag.
- Replace in-process events with a message broker and the transactional outbox pattern.
This is the strangler fig approach: the new service grows around the old code until the old path can be removed.
Mistakes I would avoid#
- Splitting by technical layer (a "database service", an "API service"). Every feature change then touches all of them. Split by business capability.
- A distributed monolith: services that must be deployed together and call each other synchronously in long chains. You get all the costs and none of the independence.
- Sharing a database between services. It couples them more tightly than shared code would.
- Splitting before understanding the domain. Boundaries drawn too early are expensive to move later. A monolith lets you discover the right ones cheaply.
Conclusion#
Start with a well-structured modular monolith: one deploy, clear module boundaries, owned data and in-process events. Split out a service only when a real organizational, scaling or reliability reason appears, and do it incrementally along boundaries you have already proven. Architecture should follow the problems you actually have, not the ones you might have someday.