Service Boundaries: How to Know When to Split a Service
Where you draw a service boundary decides which calls become network calls, which transactions become sagas, and which changes need three teams to coordinate a deploy. Split along the wrong line and you get a distributed monolith. Here's how to measure coupling, the signals that justify a split, the ones that don't, and how to find a boundary before you commit to it.
Seven Services, One Deploy
The team split their e-commerce monolith into seven services, one for each major noun: catalog, pricing, cart, checkout, inventory, payments, notifications. Each had its own repository, pipeline, and database. The architecture diagram looked clean.
Six months later, a checkout request makes nine network calls across five services. Adding a "buy two, get one free" promotion took changes to pricing, cart, and checkout, released in a specific order over two days. Checkout and inventory used to update stock and create the order in one transaction; now there's a saga with compensating actions, and every so often it leaves inventory reserved for an order that doesn't exist. Nobody can run the system on a laptop.
They didn't get the benefits of microservices. They got a monolith whose function calls are now network calls. This is usually called a distributed monolith, and it comes from drawing boundaries where the code was easy to divide rather than where the coupling was low.
A Boundary Is a Decision About Coupling
Splitting a service doesn't remove the relationship between two pieces of code. It changes what that relationship costs.
| Inside one service | Across a service boundary |
|---|---|
| A function call: nanoseconds, never fails on its own | Across a service boundaryA network call: milliseconds, can time out, fail, or succeed without a reply |
| One database transaction | Across a service boundaryA saga with compensating actions, or eventual consistency |
| A foreign key | Across a service boundaryA reference that can dangle, plus reconciliation |
| Rename a field in one commit | Across a service boundaryVersion an API, support old and new clients, deprecate |
| One deploy | Across a service boundaryCoordinated deploys, or backward-compatible changes in a specific order |
Each row on the right is something you now have to build and run. (The Fallacies of Distributed Systems covers why the network column is harder than it looks.) A boundary is worth it when what you get back (independent deploys, independent scaling, team autonomy, fault isolation) outweighs that cost. That only happens when very little crosses the boundary.
How much one part of the system has to know about, wait for, or change with another. For a boundary, the kinds that matter most are runtime coupling (synchronous calls on the request path), data coupling (shared tables or a shared transaction), and change coupling (features that touch both sides).
A term from domain-driven design: a part of the business with its own model and language, where a word like "product" or "customer" has one precise meaning. Inventory's "product" is a stock-keeping unit with a quantity per warehouse; catalog's "product" has descriptions and photos. Bounded contexts are usually good candidates for service boundaries, because the models inside them change together and the models across them don't.
Measure Before You Cut
You don't have to guess where coupling is. Most of it can be read from the code and its history:
- Change coupling from version control. Which modules show up together in the same commits or pull requests? If 40% of changes to checkout also touch pricing, a boundary between them will make 40% of those features cross-service projects.
- Runtime coupling from traces. In a request trace, count the calls from one module to another on the hot path. (Metrics vs Logs vs Traces covers getting this data.)
- Data coupling from the schema and transactions. Which tables are written in the same transaction? Which modules read other modules' tables directly? Every shared transaction you cut becomes a saga.
The simulator applies these three measures to the seven modules from the opening story. Try the presets, then click modules to move them between services and watch what crosses the boundaries.
Coupling numbers are illustrative for a small shop. A boundary is cheap where little crosses it: few synchronous calls, no shared transaction, and rare changes that touch both sides. Network calls assume about 12 ms each; the monolith has none, but also can't deploy or scale its parts separately.
What to compare:
- One service per noun. Every link crosses a boundary: nine network calls per checkout, three transactions turned into sagas, and every change spans services. This is the opening story.
- By capability. Cart, checkout, pricing, and inventory together form "ordering," with catalog, payments, and notifications separate. Four network calls, no split transactions. The catalog–pricing link still crosses, which is a reasonable thing to discuss: move pricing to catalog and checkout's link to pricing crosses instead, and that one is a shared transaction.
- Split along a transaction. Moving inventory and pricing out of ordering cuts straight through checkout's transaction, which is the worst place to put a boundary.
- Monolith. Zero network calls, nothing to coordinate. Also no independent deploys or scaling. The simulator can't show that side of the trade, which is the point of the next section.
Good Reasons to Split
The decision to split should come from a specific benefit you need, not from a general belief that services are better.
Teams are blocking each other
When several teams work in one codebase, they share a release train, a test suite, and a deploy schedule, and one team's broken build stops everyone. A service boundary that matches team ownership lets each team ship on its own schedule. This is Conway's law in practice: systems tend to mirror the communication structure of the organization that builds them, so drawing boundaries along team lines tends to stick.
Parts of the system need very different scaling or resources
If image processing needs GPUs and the rest of the app needs a few small containers, or the search tier handles 50Ă— the traffic of the admin area, scaling them together wastes money. Separate services scale separately. (Stateless vs Stateful Services covers what makes a service easy to scale out.)
Failures need to be contained
A memory leak in report generation shouldn't take down checkout. A separate process, with its own resources and a circuit breaker on calls to it, keeps one part's failure from becoming everyone's.
Security or compliance requires isolation
Card data under PCI DSS, health records, or anything with strict access rules is easier to audit and protect when it lives in a small, separate system with its own credentials. Moving payments out can shrink the scope of a compliance audit from your whole platform to one service.
Parts change at very different rates
A pricing engine that changes daily and a ledger that changes twice a year have different risk profiles. Separating them lets the fast-moving part ship often without putting the stable part at risk.
Bad Reasons to Split
"Each entity should be a service." Users, orders, and products aren't units of change; business capabilities are. Entity services become thin CRUD layers that every feature has to coordinate across. Splitting by technical layer (an API service, a logic service, a data service) has the same problem.
"The codebase is messy." A service boundary doesn't fix tangled code; it turns tangled function calls into tangled network calls. Clean up the module boundaries first.
"We need it to be faster." Splitting adds network hops. It can help when one part needs to scale independently, but it rarely makes any single request faster.
"We'll need to scale eventually." Most systems never reach the size where a well-built monolith can't keep up. A monolith with stateless app servers and a good database scales a long way.
A small team. With one or two teams, the coordination costs a split is meant to solve barely exist, while the operational costs (more pipelines, more dashboards, distributed tracing, on-call for more systems) arrive right away.
Signs a Boundary Is in the Wrong Place
If you already have services, these symptoms usually mean a boundary is cutting through something that belongs together:
- Features routinely need changes in several services, released in a specific order.
- Services call each other synchronously in chains, so the slowest or least available one sets the pace for the whole request.
- Two services share a database, or one reads another's tables directly. Neither can change its schema without breaking the other.
- You've built sagas or two-phase commit for operations that used to be one transaction, and the compensation logic is a frequent source of bugs. (The Saga Pattern covers when sagas are worth it.)
- One service is mostly a proxy, forwarding calls to another with little logic of its own.
The fix is often to merge services back together, which teams are oddly reluctant to do. It's usually much cheaper than continuing to pay for a boundary that doesn't fit.
Finding the Line Before You Commit
The safest way to find a service boundary is to build it as a module boundary first.
In a modular monolith, each module owns its tables and exposes a small public interface. Other modules call that interface, never its internals or its tables, and you enforce that with code structure and lint rules. You get most of the design discipline of services, with function calls and real transactions.
Then watch how the modules behave. If the interface between two modules stays small and stable for months, it's a good candidate to become a network boundary, and extracting it is mostly mechanical. If two modules keep reaching into each other, you've learned the boundary is wrong at the cost of a refactor, not a re-architecture.
A few tests for a candidate boundary:
- Can each side do its main job with its own data? If checkout needs to call pricing, inventory, and catalog synchronously to do anything, it isn't autonomous.
- Does any transaction cross it? If yes, either move the boundary or be sure eventual consistency is acceptable for that operation.
- Could one team own it entirely? If a boundary splits a team's work in half, or forces two teams to share one service, it'll be fought over.
- Is the interface small? A boundary with three operations is cheap. One with forty is a monolith's internals exposed over HTTP.
Is there a concrete benefit you need: team independence, separate scaling, fault isolation, or compliance scope?
Key Takeaways
- A boundary turns function calls into network calls, transactions into sagas, and commits into coordinated deploys. It only pays off where little crosses it.
- Measure coupling: co-change from version control, synchronous calls from traces, shared transactions from the schema.
- Split for a specific benefit: independent teams, different scaling, fault isolation, or compliance scope. Not because services seem more modern.
- Split by business capability, not by entity or technical layer. Bounded contexts are good starting points.
- Never cut through a transaction you need to be atomic unless you've accepted eventual consistency for it.
- Build module boundaries first. A modular monolith lets you test a boundary cheaply and extract it when there's a reason.
- Merging services is a valid fix. A boundary that causes constant coordination is worth removing.
References
- Sam Newman, Building Microservices (2nd ed.) and Monolith to Microservices, on finding boundaries and extracting services
- Eric Evans, Domain-Driven Design, for bounded contexts
- Martin Fowler, MonolithFirst
- Melvin Conway, How Do Committees Invent? (1968), the origin of Conway's law