Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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

Layered services must all change together for any feature. Capability services change independently.

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.

PatternRelationshipWhen it fits
PartnershipTwo teams succeed or fail togetherClosely coupled features, temporary
Shared KernelShared code and modelSmall, stable, one owner; expensive otherwise
Customer–SupplierDownstream needs drive upstream prioritiesNormal internal relationship
ConformistDownstream adopts upstream's model wholesaleUpstream will not change; model is acceptable
Anti-Corruption LayerDownstream translates upstream's modelLegacy or third-party systems
Open Host ServiceUpstream publishes a general protocolMany consumers
Published LanguageA shared schema everyone speaksEvents on a bus

The anti-corruption layer is the one that repeatedly saves projects:

LegacyCustomerAcl.java
/**
 * 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.

A routing facade sends one capability to the new service while everything else stays put. Repeat until the monolith is empty.

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?
Size is the wrong measure. The useful tests are whether it can be deployed without coordinating with another team, whether it owns its data outright, and whether one team can hold the whole thing in their head. A service that satisfies those is the right size whether it is 500 lines or 50,000.
Should every service have its own database?
Yes, and this is the boundary that matters most. A shared database makes every schema change a cross-team negotiation and turns independent deployment into a fiction. If two services genuinely need the same data, one owns it and the other gets a copy through an event or an API.
Should we start with microservices?
Almost never. Boundaries are the hardest part and you understand the domain worst at the beginning. Start with a well-modularised monolith where boundaries are cheap to move, and extract services when a specific pressure — independent scaling, team autonomy, differing reliability needs — justifies the operational cost.

Related tutorials