Microservices Decomposition Patterns
Finding service boundaries that hold: decomposing by business capability and subdomain, context mapping patterns, the anti-corruption layer, and the strangler fig migration.
On this page
Splitting a system is easy. Splitting it in the right places is the entire problem, and getting it wrong produces a distributed monolith: all the operational cost of microservices with none of the independence.
Key Takeaways
- Decompose by business capability, not by technical layer. A "database service" is a distributed monolith with extra steps.
- A service boundary is a data ownership boundary. Shared tables mean shared deployments.
- Event storming finds boundaries faster than architecture diagrams, because it starts from what the business actually does.
- An anti-corruption layer stops a legacy model leaking into a new one.
- Migrate with the strangler fig: route, extract, verify, delete. Never rewrite wholesale.
The wrong cut and the right one
The test is what happens when a feature arrives. In the layered arrangement, adding a field to an order touches all three services and the shared schema — three deploys, coordinated, for one change. In the capability arrangement, it touches one service and its own database.
Finding the boundaries
Event storming is the fastest technique available and needs nothing but wall space. Get domain
experts and engineers in a room and write every significant business event on an orange note in past
tense — OrderPlaced, PaymentReceived, StockReserved, ShipmentDispatched. Arrange them on a
timeline. Add the commands that cause them, the actors who issue those commands, and the data each
one reads.
Boundaries then appear as clusters. Events that share vocabulary and always occur together belong to one context; the seams are where the language changes. When "order" means a shopping basket on one side of a gap and a fulfilment instruction on the other, you have found a bounded context boundary — and the fact that the same word means two things is the strongest possible signal.
The complementary technique is subdomain classification. Sort each capability into one of three buckets: core domain, the thing your company is actually good at, which you build in-house and invest in; supporting domain, necessary but not differentiating, which you build simply or buy; and generic domain, a commodity like authentication or invoicing, which you buy or adopt open source. Teams routinely spend their best engineers on generic subdomains, and this classification is what makes that visible.
Context mapping
Once you have contexts, the relationships between them need naming — an unnamed integration becomes a coupling nobody owns.
| Pattern | Relationship | When it fits |
|---|---|---|
| Partnership | Two teams succeed or fail together | Closely coupled features, temporary |
| Shared Kernel | Shared code and model | Small, stable, one owner; expensive otherwise |
| Customer–Supplier | Downstream needs drive upstream priorities | Normal internal relationship |
| Conformist | Downstream adopts upstream's model wholesale | Upstream will not change; model is acceptable |
| Anti-Corruption Layer | Downstream translates upstream's model | Legacy or third-party systems |
| Open Host Service | Upstream publishes a general protocol | Many consumers |
| Published Language | A shared schema everyone speaks | Events on a bus |
The anti-corruption layer is the one that repeatedly saves projects:
/**
* Translates the legacy CRM model into our domain model. Nothing outside this
* package ever sees a LegacyCustomerDto, so when the CRM is replaced, exactly
* one class changes.
*/
@Component
public class LegacyCustomerAcl implements CustomerDirectory {
private final LegacyCrmClient crm;
@Override
public Optional<Customer> find(CustomerId id) {
return crm.lookup(id.value()).map(this::toDomain);
}
private Customer toDomain(LegacyCustomerDto dto) {
return new Customer(
new CustomerId(dto.getCustNo()),
// The legacy system encodes tier as a magic number; our domain
// has an enum. The mapping lives here and nowhere else.
switch (dto.getTierCode()) {
case 1 -> Tier.STANDARD;
case 2 -> Tier.PREMIUM;
case 9 -> Tier.INTERNAL;
default -> Tier.STANDARD;
},
Address.parse(dto.getAddrLine1(), dto.getAddrLine2(), dto.getPostCode()));
}
}Without this layer, getTierCode() and its magic numbers spread through the new codebase, and the
legacy model outlives the legacy system by years.
The strangler fig migration
Named after the vine that grows around a tree and eventually replaces it, this is the only migration strategy with a good track record. A big-bang rewrite requires the new system to reach feature parity with a moving target before it delivers any value, which is why so few finish.
The sequence for each extraction is the same. Put a facade in front so routing can change without clients knowing. Pick one capability, ideally one with few dependencies and clear ownership. Extract the data it owns, usually with a period of dual-write or change-data-capture so both systems stay consistent. Route reads to the new service and compare results against the old one in production before switching writes. Cut over, then — the step that gets skipped — delete the old code. A strangler migration that never deletes leaves you maintaining both.
Order the extractions by value, not by ease. The capability that most needs independent scaling or is most often blocked by someone else's release train should go first, because that is where the payoff justifies the disruption.
Sizing heuristics that actually work
Ignore line counts. Four tests are more useful.
Independent deployability. Can you deploy this service on a Tuesday afternoon without asking anyone? If not, the boundary is wrong.
Data ownership. Does exactly one service write each table? Shared writes mean shared migrations and shared outages.
Team fit. Can one team own it end to end, including on-call? Conway's Law is descriptive, not aspirational — your architecture will end up mirroring your org chart whether you plan for it or not.
Change locality. Do typical features touch one service? Track this. If most changes span three services, the boundary is in the wrong place and the fix is to merge, not to add more coordination.
That last point deserves emphasis: merging two services back together is a legitimate and healthy move. Boundaries drawn early are hypotheses, and evidence that a hypothesis was wrong should change the design rather than be worked around.
What to take away
Decompose by capability and by data ownership. Find the seams with event storming rather than guessing from a diagram. Protect new models from old ones with an anti-corruption layer. Migrate incrementally behind a facade, delete what you replace, and treat frequent cross-service changes as evidence that a boundary needs moving.
Frequently Asked Questions
How big should a microservice be?
Should every service have its own database?
Should we start with microservices?
Related tutorials
- Service Discovery & RegistrationClient-side versus server-side discovery, running Eureka properly including self-preservation, Consul as an alternative, and why Kubernetes usually makes a separate registry unnecessary.
- API Gateway with Spring Cloud GatewayBuilding an edge gateway: route predicates, the filter catalogue, custom global filters for auth and correlation, Redis rate limiting, and circuit breakers at the edge.
- Inter-Service Communication PatternsChoosing how services talk: synchronous REST and gRPC versus asynchronous messaging, the coupling each creates, correlation propagation, and graceful degradation.
- Circuit Breakers & Resilience4jResilience4j in production: how the circuit breaker state machine works, tuning the sliding window, combining retry and bulkhead correctly, and the decorator order that matters.