Splitting is easy. Splitting correctly is the whole problem, and the wrong boundary costs more than not splitting at all.
Prerequisites: Monolith vs Microservices Time to read: ~24 minutes
You’ve decided to split. Now: where?
The tempting answer — split by technical layer:
❌ API Service → Business Logic Service → Data Access Service
🚨 This is the worst possible decomposition. Every feature change touches all three services, requiring three coordinated deploys across three teams. You have all the distributed-systems cost and zero independence. It’s a distributed monolith, and it’s a common mistake because layering is how we’re taught to structure code within an application.
The other tempting answer — split by database table:
❌ User Service, Address Service, Preference Service
Also wrong. Those are one aggregate — nobody changes an address without a user context — so every operation becomes a distributed transaction.
The right axis: split by business capability.
✅ Ordering, Payments, Inventory, Shipping, Catalogue
Each owns a complete vertical slice: its API, its logic, its data. A change to how ordering works touches ordering.
🚨 The single most useful heuristic:
A typical feature should require changing one service.
If most features span three or four services, the boundaries don’t match how the system actually changes. That’s measurable — look at your last 50 pull requests and count the services each touched.
Two supporting tests:
Deployment independence. Can you deploy this service without deploying anything else? If service A’s release always requires a matching release of B, they are one service wearing two hats.
Data ownership. Does this service exclusively own its data? If two services write the same tables, they’re coupled at the deepest possible level and cannot evolve independently.
DDD provides the standard language for this, and using it precisely is a strong signal.
A boundary within which a term has one unambiguous meaning.
🚨 The insight that makes DDD click: the same word means different things to different parts of the business, and that’s not a modelling failure to be resolved — it’s the boundary itself.
"Customer" in Sales: leads, pipeline stage, deal size, sales rep
"Customer" in Support: open tickets, satisfaction score, entitlement tier
"Customer" in Billing: payment method, invoices, credit limit, tax status
"Customer" in Shipping: delivery addresses, preferences, access instructions
Trying to build one universal Customer object serving all four produces a class with 80 fields
that every team must coordinate to change — the classic god object. Four bounded contexts, each
with its own narrow model of “customer,” is correct.
🎙️ “‘Order’ means something different in fulfilment than in billing. Rather than force one shared model, I’d let each context own its own — the shared identifier is the user ID, not a shared object.”
🚨 The most practically useful DDD concept for system design.
An aggregate is a cluster of objects treated as one unit for data changes, with a single root that is the only entry point.
Order (aggregate root)
├── OrderLine ← only reachable through Order
├── ShippingAddress ← a value object, copied not referenced
└── OrderStatus
The rules:
🚨 Why this decides service boundaries: an aggregate is the largest thing you can change atomically. So a service boundary must never cut through an aggregate — if it does, every operation on that aggregate becomes a distributed transaction.
Conversely, aggregate boundaries are candidate service boundaries, because operations across them were already going to be eventually consistent.
🎙️ “Order and OrderLine are one aggregate — they change together and share invariants, so they must be in one service. Order and Customer are separate aggregates referenced by ID, so they can be separate services.”
That reasoning is exactly what interviewers are looking for when they ask “how would you split this?”
How contexts relate:
| Relationship | Meaning |
|---|---|
| Shared kernel | A shared model both depend on — creates coupling; use sparingly |
| Customer–supplier | Downstream’s needs influence upstream’s roadmap |
| Conformist | Downstream just accepts upstream’s model (e.g. a third-party API) |
| Anti-corruption layer | A translation layer protecting your model from theirs |
| Published language | A well-defined shared contract (events, an API spec) |
🚨 The anti-corruption layer is worth knowing by name. When integrating with a legacy system or a vendor whose model is a poor fit, put a translation layer at the boundary so their concepts don’t leak into your domain. It’s the standard answer to “how do you integrate with the legacy system without inheriting its design?”
1. Follow the data. Data that changes together belongs together. If two things are always updated in the same transaction, don’t separate them.
2. Follow the teams (Conway’s Law). Services should map to teams. A service owned by nobody rots; a service owned by three teams is a coordination problem.
3. Follow the change rate. Components that change at very different rates are good split candidates — a stable catalogue and a constantly-experimented-on pricing engine have genuinely different lifecycles.
4. Follow the scaling profile. Video transcoding needs GPUs and burst capacity; the API needs steady modest instances. Different scaling shapes justify separation.
5. Follow the failure requirements. Payments needs four nines; recommendations doesn’t. Separating them means recommendations’ deploys can’t take down payments.
6. Follow compliance boundaries. Card data in a PCI-scoped service keeps the scope of your audit small. This is a genuinely strong reason and often the most concrete one available.
7. Start coarse. 🚨 Merging two services is much easier than splitting one. Begin with fewer, larger services and split when you have a specific reason. The opposite order is painful.
Wrong — by technical layer:
❌ Web API, Business Logic, Data Access
Every feature touches all three.
Wrong — by database entity:
❌ User, Address, Order, OrderLine, Product, Price, Inventory, Payment
Creating an order becomes a distributed transaction across four services.
Right — by business capability, respecting aggregates:
flowchart TB
subgraph Ordering
O["Order aggregate<br/>Order + OrderLines + status"]
end
subgraph Catalogue
P["Product aggregate<br/>Product + variants + description"]
end
subgraph Inventory
I["Stock aggregate<br/>per SKU, per warehouse"]
end
subgraph Payments
PM["Payment aggregate<br/>charges, refunds, ledger"]
end
subgraph Fulfilment
S["Shipment aggregate<br/>packages, tracking"]
end
O -.OrderPlaced event.-> I
O -.OrderPlaced event.-> PM
PM -.PaymentCaptured event.-> S
Why these boundaries hold up:
product_id and a copy of the price at
time of order. → Relational Modeling🚨 Note the availability consequence: placing an order requires ordering + inventory (synchronous, because you must not oversell) but not shipping or notifications (asynchronous). That’s a deliberate choice about which dependencies are in the critical path, and articulating it is a strong signal.
Synchronous (REST/gRPC) — the caller needs an answer now. ✅ Simple, immediately consistent, easy to reason about. ❌ Temporal coupling — the callee must be available. Availability multiplies. Latency adds.
Asynchronous (events) — publish what happened; others react. ✅ Decoupled in time and knowledge. The publisher doesn’t know or care who listens. Adding a consumer requires no change to the producer. ❌ Eventual consistency. Harder to debug. Duplicate delivery. → Event-Driven Architecture
🚨 The rule of thumb worth stating: “Synchronous for queries, asynchronous for state changes.” You need an answer to “what’s the price?” now. You don’t need to wait for “an order was placed” to be processed by five downstream systems.
The critical constraint: no shared database.
❌ Order Service and Inventory Service both read/write the inventory tables
This is the deepest possible coupling — a schema change breaks the other service, neither can migrate independently, and there’s no encapsulation. A service that shares a database with another is not a separate service.
🚨 If two services need each other’s data, options are: an API call, an event-driven local read model (each keeps its own copy, updated by events), or acknowledging that they should be one service.
Symptoms, and what they mean:
| Symptom | Diagnosis |
|---|---|
| Features routinely require coordinated multi-service deploys | Boundaries don’t match change patterns |
| Chatty synchronous calls between two services | They’re one service; merge them |
| Distributed transactions everywhere | A boundary cuts through an aggregate |
| Shared database | Not actually separate services |
| One service is always the bottleneck for changes | It’s a god service; decompose it |
| Services that only ever call one other service | Merge them |
🚨 Merging is a legitimate and underused fix. “We split this wrong, let’s merge them back” is a sign of a healthy team, not a failure. The industry’s reluctance to merge services is a cultural problem, not a technical one.
| Decision | Gain | Cost |
|---|---|---|
| By business capability | Features touch one service; clear ownership | Requires real domain understanding |
| By technical layer | Familiar structure | ❌ Every feature spans all layers |
| Fewer, larger services | Easier to change; fewer distributed problems | Less independence; larger blast radius |
| More, smaller services | Fine-grained scaling and deployment | Coordination overhead; more operations |
| Synchronous communication | Simple, immediately consistent | Temporal coupling; availability multiplies |
| Event-driven | Decoupled, extensible | Eventual consistency; harder to trace |
| Local read models via events | Fast local reads; no runtime dependency | Data duplication; staleness |
1. Do an event storming session. Take a domain you know. On a wall (or a Miro board), write every
domain event in past tense: OrderPlaced, PaymentCaptured, ItemShipped, StockReserved. Order
them in time. Clusters of related events reveal bounded contexts, and the gaps between clusters
are your service boundaries. This is the standard technique and it works remarkably well in an hour.
2. Measure your real boundaries. Take the last 50 commits or pull requests in a codebase. For each, list which modules or services it touched. Build a co-change matrix. Things that always change together belong together — and the matrix will show you boundaries you didn’t design.
3. Find the aggregate violations. In an existing system, find every database transaction that updates more than one “entity cluster.” Each is either a correctly-modelled aggregate or a boundary you can’t split along.
4. Try to extract one. Pick a module and write down everything that would need to change to make it a service: which tables move, which transactions become sagas, which joins become API calls or duplicated data. The length of that list is the honest cost of the split.