Where to Draw a Service Boundary
Most microservice pain is a boundary drawn along the wrong axis. Data ownership is the rule; team structure and technical layers are how you get a distributed monolith.
Splitting a system into services is usually presented as a scaling decision, which is why so many splits go badly. Scale is rarely the binding constraint. The reason to separate two things is that they change for different reasons, at different times, and coordinating those changes has become expensive.
If the boundary does not reflect that, you get the operational cost of distribution with none of the independence it was meant to buy.
The two wrong axes
By technical layer. An API service, a business logic service, a data access service. This feels organised and is the worst possible split, because every feature crosses all three. A new field means three repositories, three pull requests, three deploys in order, and a rollback that has to unwind in reverse. You have taken the layers of a monolith and put a network between them, so a function call became an HTTP request that can fail.
By team structure. More defensible, since teams do need autonomy, and it goes wrong when the team boundary does not align with a data boundary. Two teams owning services that both need to write orders will end up either sharing a database or building a chatty protocol to coordinate every write. Conway's law says the architecture will mirror the org chart; it does not say the org chart is right.
The useful axis is neither. It is: what data does this own, and what invariants must it enforce?
Data ownership as the rule
One rule, and most of the design follows from it: exactly one service writes a given piece of data. Everyone else reads it through that service's API, or receives it as events, or keeps a local copy they know is a copy.
The reason is invariants. An invariant is a rule that must always be true — an order total equals the sum of its lines, stock never goes negative, an invoice cannot be modified after issue. A single writer with a transaction can guarantee those. Two writers cannot, at any price short of distributed consensus, and every attempt in between is a race condition waiting for load.
So the practical test for a proposed boundary is: can this service enforce its rules using only data it owns, in one transaction? If enforcing a rule requires reading another service's data at the moment of the write and trusting it not to change, the boundary is in the wrong place — those two things are one consistency domain and should be one service.
This is also why the useful boundaries tend to fall along business capabilities rather than nouns. Not “a User service” — users appear in everything — but identity, billing, catalogue, fulfilment. Each owns a coherent set of decisions.
The shared database undoes the whole exercise
Two services reading and writing the same tables are not two services. They are one system with two deployment units and no enforced interface.
- The schema becomes an undeclared public API. Renaming a column requires knowing every service that touches it, and nothing tells you which do.
- Nobody can enforce anything. Service A validates before writing; service B writes directly. A's rule is now a suggestion.
- Deployment coupling returns. A migration must be compatible with every service simultaneously, so releases have to be coordinated — which was the thing you split to avoid.
Read replicas of another service's database are the same problem in a softer form: you have coupled to their schema, and they do not know.
The legitimate version of “I need their data” is a local copy, maintained from published events, that you treat as a cache rather than as truth. The shipping service holds the addresses it needs and keeps serving when the customer service is down — at the cost of the copy being briefly stale, which is usually acceptable and is at least honest about what it is.
Recognising that you have built one
The symptoms are specific and worth checking against honestly:
- Services must be deployed together, in an order. The clearest signal. Independent deployability is the main benefit; without it you are paying the cost for nothing.
- A typical feature touches three or more services. The boundary is cutting across the grain of how the product changes.
- One service being down makes several others unusable. You have distributed the system without distributing the failure domains.
- Local development requires running everything. If a developer cannot work on one service in isolation, neither can the service.
- There is a shared library that every service must upgrade together. A synchronised upgrade is a synchronised deploy wearing a disguise.
Two or more of those means the boundaries are wrong, and the honest response is usually to merge services back together rather than to add tooling that makes coordinated deploys less painful. Merging is unglamorous and it is frequently the correct move.
Synchronous chains and cascading failure
Even with correct boundaries, wiring them synchronously reintroduces coupling at runtime. If checkout calls inventory, which calls pricing, which calls tax, then checkout's availability is the product of all four, and its latency is their sum. Adding a service to the chain makes the whole thing worse, which is a strange property for an architecture chosen for flexibility.
Worse, a slow dependency propagates upward: requests pile up waiting, connections exhaust, and the caller fails for reasons that have nothing to do with the caller. That is how one degraded service takes down four.
Three things prevent it, in order of importance:
- Make the call asynchronous when the caller does not need the answer. Most cross-service calls exist to inform, not to decide. Those should be events.
- Timeouts on everything, always. A call with no timeout is an unbounded liability. Set them shorter than your own caller's timeout, or the chain outlives its purpose.
- Circuit breakers and fallbacks. Fail fast when a dependency is already failing, and decide in advance what a degraded answer looks like — recommendations disappear, a cached price is used, a non-essential section renders empty.
The design question worth asking for each dependency: what should happen if this is down? If the answer is “the whole feature fails,” it should probably not have been a separate service.
Where to start, which is not with services
The strongest argument in this whole area is one about sequencing. You do not know where the boundaries are on day one. Nobody does — they are discovered by watching which parts of the system change together, and that takes months of real usage.
A boundary drawn early is drawn on a guess, and a wrong boundary is dramatically more expensive to move once it is a network interface with its own database and deployment.
So: start with a modular monolith. Enforce module boundaries in the code — explicit public interfaces per module, no reaching into another module's internals, ideally checked by a lint rule so violations fail CI. Give each module its own tables and forbid cross-module joins. That is the same discipline as separate services, with function calls instead of network calls.
Then extract when there is a specific reason: this component genuinely needs to scale independently, this team needs to deploy on its own cadence, this workload needs different infrastructure, this part has different compliance requirements. Extraction from a well-modularised monolith is mechanical, because the boundary already exists and has been validated by a year of changes.
Extraction from a tangled one is the project everyone has stories about — and the tangle is not caused by being a monolith. It is caused by never enforcing internal boundaries, which distribution does not fix and does make more expensive.
The short version
Draw boundaries by data ownership: one writer per piece of data, and a service that can enforce its invariants in its own transaction. Never share a database — take events and keep a copy you know is a copy. Check for the distributed monolith symptoms honestly, and merge services back when you find them. Make cross-service calls asynchronous wherever the caller does not need an answer, and put a timeout on the ones that remain. Start as a modular monolith with enforced internal boundaries, and extract when there is a reason you can name.