00:00

Microservices Decomposition: How to Split an Application

Last updated:

Microservices decomposition is the process of deciding how to split an application into separate services: which features go together, which data each service owns, and where the boundaries between them lie. Good decomposition creates services that can be changed, deployed and scaled on their own. Bad decomposition creates services that must always change together, which is worse than the monolith you started with.

This tutorial explains what makes a good service boundary, the main decomposition strategies, and a step-by-step way to apply them. Three runnable Java 21 examples show bounded contexts, finding boundaries from your commit history, and measuring the cost of a bad split. Interview questions follow at the end.

1. What Is Microservices Decomposition?

A monolith is one application that contains every feature and usually one shared database. When it grows, teams start to slow each other down: every release includes everyone's changes, and one bug can stop the whole system. Moving to microservices means cutting that application into smaller services. If this comparison is new to you, read monolithic vs microservices architecture first.

Decomposition answers three questions for every service:

  • What does it do? Its responsibility, in business terms.
  • What data does it own? The tables only it may write.
  • How does it talk to others? Its API and the events it publishes.

2. Why Decomposition Is the Hardest Decision

Code inside a monolith is cheap to move: an IDE refactoring takes minutes. A boundary between services is expensive to move: it involves APIs, data migration, two deployments and often two teams. That is why the cost of wrong boundaries is so high.

Wrong boundaries create a distributed monolith. Services are deployed separately, but every feature needs changes in several of them, they call each other constantly, and they must be released in a fixed order. You pay for network calls and extra operations, and get none of the independence.

3. What Makes a Good Service Boundary

Splitting by technical layer vs by business capabilityOn the left, an application is split by technical layer into a web layer service, a business logic service and a data access service, which call each other in a chain and share one database. Adding a gift wrap feature changes all three services. On the right, the same application is split by business capability into Ordering, Catalog, Inventory and Payment services. Each contains its own API and logic and owns its own database: orders, products, stock and payments. Adding gift wrap changes only the Ordering service. Split by technical layer Web layer service Business logic service Data access service one shared DB "Add gift wrap" changes all three services Split by business capability Ordering API + logic orders Catalog API + logic products Inventory API + logic stock Payment API + logic payments "Add gift wrap" changes only Ordering
Figure 1: a good decomposition keeps one feature change inside one service.

A good boundary has these properties:

  • High cohesion: things that change together live together. Cohesion means how strongly the parts of one service belong together.
  • Loose coupling: a service can change its internals without others noticing. Coupling means how much one service depends on another.
  • Owns its data: no other service reads or writes its tables. This is the database per service pattern.
  • Few synchronous calls: a request can be answered mostly by one service.
  • One owning team: a service is small enough for one team to understand fully.

A simple test: take the last ten features you built and ask how many services each would have touched. Most should touch one.

4. Microservices Decomposition Strategies

4.1 Decompose by Business Capability

A business capability is something the business does to create value, such as taking orders, managing the catalog, holding stock or collecting payments. Capabilities are stable: an online shop will take orders in ten years too, even if the screens and technology change. Each capability becomes a service, owned by one team.

4.2 Decompose by Subdomain

This strategy comes from Domain-Driven Design (DDD). The domain is the whole business problem, and subdomains are its parts. DDD sorts them into core subdomains, which make the business special, such as pricing for an airline; supporting subdomains, which are needed but not special; and generic subdomains, such as email delivery, often best bought rather than built.

Inside each subdomain, DDD finds a bounded context: a boundary inside which every word has one exact meaning. "Product" means a description and price to the catalog, a stock count to inventory, and a weight and size to shipping. Trying to build one shared Product class for all three is a classic decomposition mistake; example 6.1 shows the alternative.

4.3 Decompose by Team

Conway's law says that systems end up mirroring the communication structure of the organisation that builds them. If three teams build one service, its code will develop three tangled parts. Many companies use this deliberately, shaping teams first so that the architecture follows, which is called the inverse Conway manoeuvre.

4.4 Decompose by Volatility and Scale

Sometimes a part of a capability deserves its own service because it behaves differently. Examples are a recommendation engine that changes every week while checkout stays stable, or image resizing that needs ten times more CPU than anything else. Splitting these out lets you release or scale them independently.

4.5 What Not to Decompose By

  • Technical layer: a "web service", a "logic service" and a "data service". Every feature then crosses all of them, as the diagram shows.
  • Entity: one service per table or noun, such as Customer, Product, Price and Discount. These entity services hold data but no real behaviour, so all the logic ends up in callers making chains of network calls. Example 6.3 measures the cost.

5. How to Decompose, Step by Step

  1. Map the capabilities. List what the business does, with people from the business, not only developers.
  2. Find the language boundaries. Workshops such as event storming, where people put domain events on a timeline with sticky notes, reveal where words change meaning. Those are your bounded contexts.
  3. Check against real change data. Your version control history shows which modules actually change together, as in example 6.2.
  4. Build or refactor into a modular monolith. Put each candidate service in its own module, with its own package, its own tables, and calls only through a public interface. Moving a wrong boundary is still cheap at this stage.
  5. Extract one service at a time using the strangler fig approach. Route one capability's traffic to the new service, keep the rest in the monolith, and repeat. Start with a part that changes often or needs different scaling, and has few dependencies.
  6. Split the data, not just the code. This is the step many teams skip and later regret. A service that still reads the monolith's tables is not independent. Copy the data it owns into its own database, and replace cross-table joins with API calls or events.
Transactions are a boundary signal. If two candidate services need to change their data in one atomic transaction most of the time, they probably belong together. Splitting them means replacing a simple database transaction with a Saga, which is far more complex.

6. Decomposition in Java: Examples

These programs use only the JDK on Java 21. They are not services themselves; they show the ideas you use when deciding where services begin and end.

6.1 One Word, Three Bounded Contexts

Instead of one large shared Product class, each service models only what its job needs. The SKU (stock keeping unit) is the only shared value:

BoundedContexts.java
import java.math.BigDecimal;
import java.util.List;

public class BoundedContexts {

    // The same real-world "product", modelled separately by each service for its own job.
    // Only the SKU is shared; nothing else is.

    // Catalog Service: what the customer sees
    record CatalogProduct(String sku, String title, String description, BigDecimal price, List<String> images) {}

    // Inventory Service: how many we have and where
    record StockItem(String sku, String warehouse, int onHand, int reserved) {
        int available() { return onHand - reserved; }
    }

    // Shipping Service: what the courier needs
    record Parcel(String sku, int weightGrams, int lengthCm, int widthCm, int heightCm, boolean fragile) {}

    public static void main(String[] args) {
        String sku = "KTL-17";

        CatalogProduct listing = new CatalogProduct(sku, "Steel Kettle 1.7L", "Boils in 3 minutes",
                new BigDecimal("1499.00"), List.of("front.jpg", "side.jpg"));
        StockItem stock = new StockItem(sku, "PUNE-2", 40, 12);
        Parcel parcel = new Parcel(sku, 1350, 25, 20, 24, true);

        System.out.println("Catalog sees:   " + listing.title() + " for " + listing.price());
        System.out.println("Inventory sees: " + stock.available() + " available in " + stock.warehouse());
        System.out.println("Shipping sees:  " + parcel.weightGrams() + " g, fragile=" + parcel.fragile());

        // A price change touches only Catalog; a new warehouse only Inventory
        System.out.println("Fields per model: catalog=" + CatalogProduct.class.getRecordComponents().length
                + ", inventory=" + StockItem.class.getRecordComponents().length
                + ", shipping=" + Parcel.class.getRecordComponents().length);
    }
}
Output
Catalog sees:   Steel Kettle 1.7L for 1499.00
Inventory sees: 28 available in PUNE-2
Shipping sees:  1350 g, fragile=true
Fields per model: catalog=5, inventory=4, shipping=6

Each model is small, and each one changes for one reason. A shared 15-field Product class in a common library would force all three services to update and redeploy whenever any one of them needs a new field.

6.2 Finding Boundaries in Your Commit History

Most tutorials skip this practical check: your Git history already records which modules change together. The program counts how often each pair of modules appears in the same commit:

CoChangeAnalyzer.java
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class CoChangeAnalyzer {

    // Which modules of the monolith each commit touched (from: git log --name-only)
    static final List<Set<String>> COMMITS = List.of(
            Set.of("cart", "checkout"),
            Set.of("cart", "checkout", "pricing"),
            Set.of("catalog", "search"),
            Set.of("checkout", "pricing"),
            Set.of("catalog", "search"),
            Set.of("shipping"),
            Set.of("cart", "checkout"),
            Set.of("catalog", "search", "reviews"),
            Set.of("shipping", "checkout"),
            Set.of("reviews"));

    public static void main(String[] args) {
        Map<String, Integer> pairCounts = new TreeMap<>();
        for (Set<String> commit : COMMITS) {
            List<String> modules = commit.stream().sorted().toList();
            for (int i = 0; i < modules.size(); i++) {
                for (int j = i + 1; j < modules.size(); j++) {
                    pairCounts.merge(modules.get(i) + " + " + modules.get(j), 1, Integer::sum);
                }
            }
        }

        System.out.println("Modules that change together (out of " + COMMITS.size() + " commits):");
        pairCounts.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed().thenComparing(Map.Entry.comparingByKey()))
                .forEach(e -> System.out.printf("  %-20s %d%s%n", e.getKey(), e.getValue(),
                        e.getValue() >= 3 ? "   <- keep in the same service" : ""));

        // Modules that are often changed alone are good candidates to split out first
        Map<String, Long> alone = COMMITS.stream().filter(c -> c.size() == 1)
                .collect(Collectors.groupingBy(c -> c.iterator().next(), TreeMap::new, Collectors.counting()));
        System.out.println("Changed alone: " + alone);
    }
}
Output
Modules that change together (out of 10 commits):
  cart + checkout      3   <- keep in the same service
  catalog + search     3   <- keep in the same service
  checkout + pricing   2
  cart + pricing       1
  catalog + reviews    1
  checkout + shipping  1
  reviews + search     1
Changed alone: {reviews=1, shipping=1}

Cart and checkout change together in 3 out of 10 commits, so splitting them into two services would turn each of those commits into a coordinated release of two services. Shipping and reviews often change alone, so they are good first candidates to extract. In a real project, run this over a year of commits; tools such as CodeScene do the same analysis at scale.

6.3 Measuring the Cost of a Bad Split

The same "place order" flow, split in two ways. Every service in the chain must be up, so availabilities multiply:

DecompositionComparison.java
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

public class DecompositionComparison {

    record Step(String from, String to) {}

    // The steps of "place order", as calls between pieces of logic
    static final List<Step> PLACE_ORDER = List.of(
            new Step("order", "cart"),              // items and the prices the customer saw
            new Step("order", "customer-status"),   // is the account allowed to order?
            new Step("order", "price"),             // final price
            new Step("price", "discount"),          // apply the coupon
            new Step("order", "stock"),             // reserve stock
            new Step("stock", "item-details"),      // pack size and warehouse
            new Step("order", "payment"));          // charge the card

    static void evaluate(String name, Map<String, String> serviceOf) {
        long remote = PLACE_ORDER.stream()
                .filter(s -> !serviceOf.get(s.from()).equals(serviceOf.get(s.to()))).count();
        long services = PLACE_ORDER.stream().flatMap(s -> Stream.of(s.from(), s.to()))
                .map(serviceOf::get).distinct().count();
        double availability = Math.pow(0.999, services) * 100;      // every service must be up; each is 99.9%
        System.out.printf("%-23s services=%d, remote calls=%d, availability %.1f%%%n",
                name + ":", services, remote, availability);
    }

    public static void main(String[] args) {
        // One service per noun (entity): almost every step becomes a network call
        evaluate("By entity", Map.of("order", "Order", "cart", "Cart", "customer-status", "Customer",
                "price", "Price", "discount", "Discount", "stock", "Stock",
                "item-details", "Product", "payment", "Payment"));

        // By business capability: each service owns, or keeps a copy of, the data its job needs
        evaluate("By business capability", Map.of("order", "Ordering", "cart", "Ordering", "customer-status", "Ordering",
                "price", "Pricing", "discount", "Pricing", "stock", "Inventory",
                "item-details", "Inventory", "payment", "Payment"));
    }
}
Output
By entity:              services=8, remote calls=7, availability 99.2%
By business capability: services=4, remote calls=3, availability 99.6%

The entity split doubles the number of services in the request path and more than doubles the network calls. At 99.9% per service, it also means almost 3 more hours of downtime per month for this one flow. The capability split wins because each service keeps the data its job needs, such as Inventory holding its own pack sizes, often updated through events.

7. Comparing the Strategies

StrategySplits byStrengthWatch out for
Business capabilityWhat the business doesStable, easy to explainCapabilities that are too large
Subdomain (DDD)Bounded contexts and languagePrecise models, clear ownershipNeeds domain experts and practice
TeamWho builds and runs itMatches how people workReorganisations move boundaries
Volatility or scaleChange rate or resource needsFast releases, cheaper scalingToo many small services
Technical layer (avoid)UI, logic, dataFamiliarEvery feature crosses all services
Entity (avoid)Tables or nounsLooks tidyChatty calls, logic in the wrong place

In practice, teams combine them: start with capabilities, refine with subdomains and bounded contexts, and check the result against team structure and change data.

8. Common Mistakes and Pitfalls

  • Splitting too early. Before the domain is understood, boundaries land in the wrong places. Many successful systems started as a monolith.
  • Services that are too small. "Nano-services" with one endpoint each create long call chains and huge operational overhead.
  • Shared domain libraries. A common JAR with business classes couples every service that uses it.
  • Splitting code but keeping the shared database. The schema still couples the services.
  • Copying the monolith's module structure blindly. Old packages often reflect history, not business boundaries.
  • Ignoring the organisation. A boundary that cuts across teams will be eroded by Conway's law.

9. Best Practices

  • Start from business capabilities and refine them into bounded contexts.
  • Validate boundaries with data: co-change history, call graphs and transaction patterns.
  • Prove boundaries inside a modular monolith before paying the cost of separate services.
  • Extract incrementally, one service at a time, and move its data with it.
  • Prefer slightly larger services at first; splitting one later is easier than merging two.
  • Let each service keep the data it needs, updated by events, instead of calling others for every request. The event-driven architecture guide shows how.

10. Summary

  • Microservices decomposition decides which features, data and APIs each service owns.
  • Good boundaries have high cohesion, loose coupling, private data and one owning team.
  • The main strategies are by business capability, by subdomain with bounded contexts, by team, and by volatility or scale. Avoid splitting by technical layer or by entity.
  • Validate boundaries with change history and transaction patterns, prove them in a modular monolith, then extract services one at a time, data included.
  • Careful microservices decomposition is what separates independent services from a distributed monolith.