Splitting a system into services is usually described as a scaling decision. In practice it is a coupling decision, and the scaling part is the easy half. Where the lines fall determines which changes are cheap forever and which are expensive forever, and that verdict is delivered years after the diagram was drawn.
The symptom is recognisable long before anyone names the cause. A feature that touches one concept requires changes in four repositories. Deploys have to be ordered. There is a shared spreadsheet for release coordination. Nobody can explain why a small change is expensive, only that it is.
Split on what changes together
The most common mistake is drawing boundaries around nouns. Orders, Users, Products, Payments — each becomes a service because each is a table, and the resulting architecture is a distributed schema with network calls where the joins used to be.
The better question is which things change at the same time and for the same reason. Two concepts that are always modified in the same pull request belong on the same side of a boundary, whatever the entity diagram says. Two that change for unrelated reasons on unrelated schedules can be separated cheaply.
Every boundary you cross is a transaction you lose
Inside one database, making three changes atomically is a transaction. Across two services it is a saga: a state machine, a compensating action for each step, an idempotency key on every handler, and a reconciliation job for the cases the compensations do not cover.
None of that is unreasonable when the boundary earns it. A payment processor and an order service genuinely are separate concerns with separate failure modes, and the saga is the honest representation of that. What is unreasonable is paying it because Orders and Inventory ended up in different repositories for organisational reasons.
| Operation | In one service | Across a boundary |
|---|---|---|
| Consistency | A transaction | A saga with compensations |
| Failure handling | Rollback | Idempotency keys and reconciliation |
| Refactoring a shared concept | One pull request | Coordinated, versioned, ordered |
| Debugging one request | A stack trace | Distributed tracing across hops |
| Latency | Microseconds | Milliseconds, plus tail latency |
Start with fewer services than you think
A boundary is far cheaper to add than to move. Inside a single deployable, an incorrect module boundary is a refactor an IDE can mostly perform. Once it is a network boundary with its own datastore, moving it is a migration, a deprecation window, and a coordinated release.
So the asymmetry argues for restraint. Build the modular monolith first, with genuine internal boundaries — separate schemas, no cross-module table access, communication through explicit interfaces. Extract a service when there is a concrete reason: an independent scaling profile, a compliance boundary, a team that needs to deploy on its own cadence.
- Enforce module boundaries in the codebase before enforcing them over a network.
- Give each module its own schema and forbid cross-schema queries in review.
- Let the interfaces stabilise under real feature work for a few months.
- Extract the module whose scaling, compliance, or release cadence genuinely differs.
- Leave the rest alone until they give you the same reason.
You can always cut a monolith along a seam that has proven itself. You cannot easily re-join two services that should never have been separated.
When the boundary is right
A well-placed boundary is quiet. Its interface changes rarely. Its team ships without asking anyone's permission. Incidents on one side do not page the other. Most features live entirely within one side of it.
If a split has been in place for six months and none of that is true, the honest conclusion is that the line is in the wrong place. That is worth acting on early, while moving it is still a refactor rather than a programme of work.
- Architecture
- Microservices
- Domain modelling
Related capability
Custom Software Development