system-design

Service Decomposition: Where to Draw the Lines

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


The problem

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 test that matters

🚨 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.


Domain-Driven Design: the vocabulary

DDD provides the standard language for this, and using it precisely is a strong signal.

Bounded context

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.”

Aggregates: the transactional boundary

🚨 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:

  1. Reference other aggregates by ID only, never by object reference.
  2. One transaction modifies one aggregate. Changes spanning aggregates are eventually consistent.
  3. Invariants are enforced within an aggregate.

🚨 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?”

Context mapping

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?”


Practical heuristics

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.


Worked example: an e-commerce platform

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:

🚨 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.


Communication between services

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.


When you’ve split wrong

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.


⚖️ Trade-offs

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

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

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.


Check yourself

1. Why is splitting by technical layer the worst decomposition? Because it slices *across* the axis along which the system actually changes. A feature — "add a discount code field" — needs a change to the API layer, the business logic layer, and the data access layer. So every feature requires three coordinated deploys, three code reviews, possibly three teams, and a release order. You've taken on every cost of distribution — network calls, timeouts, tracing, versioning, availability multiplication — while making changes *harder* than in a monolith, where the same feature is one commit. Layers are a good way to structure code *within* a service and a terrible way to divide services.
2. What is an aggregate and why does it determine service boundaries? An aggregate is a cluster of domain objects treated as a single unit for data changes, with one root entity as the only entry point, and invariants enforced within it — an Order with its OrderLines, for instance, where the total must match the lines. The rule is that **one transaction modifies one aggregate**. This determines service boundaries because an aggregate is the largest thing you can change atomically: a boundary that cuts through one turns every ordinary operation into a distributed transaction requiring sagas and compensation. Conversely, aggregate boundaries are natural service boundaries, because operations *across* aggregates were already going to be eventually consistent.
3. Why can't two services share a database? Because it's coupling at the deepest possible level, which eliminates the independence that justified splitting. Neither service can change its schema without breaking the other, so migrations require coordination — which is exactly the deployment coupling microservices were meant to remove. There's no encapsulation: internal implementation details are exposed as a public contract, so you can't refactor. Data invariants can be violated by either writer without the other knowing. And you can't scale, back up, or tune the databases independently. Two services sharing a database are one service with two deployment artifacts.
4. What's a bounded context, and why is it OK for "Customer" to mean different things? A bounded context is a boundary within which a term has one unambiguous meaning. It's fine — in fact correct — for "Customer" to differ between contexts, because the business genuinely means different things: Sales cares about pipeline stage and deal size, Support about tickets and entitlements, Billing about payment methods and credit limits, Shipping about addresses. Forcing one universal Customer model produces an object with dozens of fields that every team must coordinate to change, and which serves none of them well. The correct approach is separate models per context, linked by a shared *identifier* rather than a shared *object*. The divergence in meaning is the signal that tells you where the boundary is.
5. You've split into services and every feature requires three coordinated deploys. What now? You have a distributed monolith — the boundaries don't match how the system changes. Diagnose first: build a co-change matrix from recent pull requests to see which services always change together. Then **merge them.** Services that always change together, always deploy together, or communicate chattily and synchronously should be one service, and merging is far easier than splitting. This is a legitimate fix, not an admission of failure — the reluctance to merge services is cultural rather than technical. Afterwards, re-derive boundaries from business capabilities and aggregate boundaries rather than from the technical structure or the original guess.

Further reading