Splitting a system into microservices is often treated as the mature, "senior" choice. In practice it's a trade: you gain independent deployment and scaling, and you pay with network calls, partial failures, distributed data and a lot more infrastructure.
For most products, especially early ones, the better default is a modular monolith: one deployable application, organized internally into modules with clear boundaries. It keeps the operational simplicity of a monolith and the design discipline that makes a later split cheap.
What Microservices Actually Cost
Each service boundary turns a function call into a network call. That brings:
- Latency and failure modes a function call never had: timeouts, retries, partial failure.
- No more transactions across boundaries. Data that used to be updated together now needs events, sagas or careful ordering.
- More infrastructure: service discovery, per-service pipelines, versioned APIs between services, distributed tracing to debug one request.
- Harder local development and testing. Running "the system" means running many things.
These costs are worth paying when the problems they solve are real. They're pure overhead when they aren't.
What a Modular Monolith Looks Like
One codebase, one deployment, but organized by business capability, not by technical layer:
src/
modules/
orders/
api/ <- the only part other modules may call
domain/
data/
payments/
api/
domain/
data/
catalog/
...
shared/ <- small, stable utilities only
The rules that make it modular rather than a "big ball of mud":
- Modules talk only through each other's public API (
orders/api), never by reaching into another module's internals or tables. - Each module owns its data. Other modules don't query its tables directly.
- Dependencies point one way. If
ordersdepends oncatalog,catalogdoesn't depend onorders; use events for the other direction. - Enforce it with tooling, not good intentions: lint rules on imports (for example dependency-cruiser or ESLint boundaries in TypeScript, or architecture tests in .NET).
Tip
If you can't draw clean boundaries inside one codebase, splitting into services won't create them. It will just turn tangled function calls into tangled network calls.
Signals That It's Time to Split
Split a module into its own service when there's a concrete reason, such as:
- Independent scaling. One part has very different load (image processing, search indexing) and scaling the whole app for it is wasteful.
- Independent teams. Several teams are blocked on each other's releases and want to deploy on their own schedule.
- Different reliability or security needs. Payments needs stricter isolation, auditing or compliance boundaries than the rest.
- Different technology. One capability genuinely needs a different runtime or language.
Notice that "the codebase is big" and "microservices are modern" aren't on the list.
Splitting Well When the Time Comes
Because the module already has a public API and owns its data, extracting it is mostly mechanical:
- Turn the module's API into a network API (HTTP or messaging), keeping the same contract.
- Move its tables to its own database, migrating with the expand, migrate and contract approach.
- Replace in-process calls from other modules with calls to the new service, behind the same interface, with timeouts and circuit breakers.
- Run both paths side by side behind a feature flag until the new service is proven, then remove the old path.
This is the strangler pattern: replace one piece at a time while the system keeps running.
Summary
Start with one deployable application and real internal boundaries. You get the speed and simplicity of a monolith now, and when a specific module needs to scale, deploy or be secured on its own, you can lift it out cleanly. The senior decision isn't "microservices" or "monolith"; it's knowing which costs you're taking on, and why.



