Microservices Decomposition: How to Split an Application
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
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
- Map the capabilities. List what the business does, with people from the business, not only developers.
- 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.
- Check against real change data. Your version control history shows which modules actually change together, as in example 6.2.
- 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.
- 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.
- 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.
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:
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);
}
}
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:
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);
}
}
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:
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"));
}
}
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
| Strategy | Splits by | Strength | Watch out for |
|---|---|---|---|
| Business capability | What the business does | Stable, easy to explain | Capabilities that are too large |
| Subdomain (DDD) | Bounded contexts and language | Precise models, clear ownership | Needs domain experts and practice |
| Team | Who builds and runs it | Matches how people work | Reorganisations move boundaries |
| Volatility or scale | Change rate or resource needs | Fast releases, cheaper scaling | Too many small services |
| Technical layer (avoid) | UI, logic, data | Familiar | Every feature crosses all services |
| Entity (avoid) | Tables or nouns | Looks tidy | Chatty 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.
Microservices Decomposition: Interview Questions
Common interview questions on microservices decomposition, from basic to scenario-based, with short answers.
Basic questions
Q. What is the difference between a business capability and a subdomain?
Answer: A business capability describes what the business does, such as "take orders", and is usually found by talking to the business. A subdomain is a DDD term for a part of the business problem, classified as core, supporting or generic. They often line up, but subdomain thinking adds the idea of bounded contexts and of investing most in the core parts.
Q. What is a modular monolith, and how do you enforce its boundaries in Java?
Answer: A modular monolith is one deployable application split into modules with strict boundaries: each module has its own package, tables and a small public API. You can enforce the boundaries with Java modules (JPMS), architecture tests in ArchUnit, or Spring Modulith, which verifies module dependencies in a test.
module com.shop.ordering { exports com.shop.ordering.api; }
Q. What is the strangler fig pattern?
Answer: It replaces a monolith gradually instead of all at once. A routing layer, such as an API gateway, sits in front of the monolith; each time a capability is rebuilt as a service, its routes are switched to the new service. The name, from Martin Fowler, comes from a fig tree that slowly grows around its host tree until it replaces it.
Q. What is a context map in DDD?
Answer: A context map is a diagram of the bounded contexts and how they relate. Typical relationships are customer-supplier, where one team serves another's needs; conformist, where one side simply accepts the other's model; and anti-corruption layer, where one side translates the other's model. It makes the dependencies between future services visible before you build them.
Intermediate questions
Q. What is an anti-corruption layer?
Answer: It is a translation layer between your service's model and another system's model, often a legacy monolith or a third-party API. Your service talks only to the layer, which converts the other model into your own terms, so the other system's naming and design do not leak into your code.
Q. How do you split a shared database between services?
Answer: First decide which service owns each table. Change other services to read that data through the owner's API or events instead of SQL. Then move the tables into the owner's database, keeping them in sync during the move with change data capture or a short dual-write period. Finally, remove the old access and foreign keys. Do it one table group at a time.
Q. What replaces a foreign key between tables in two different services?
Answer: Store only the other entity's ID, without a database constraint. Validate it through the owning service's API when needed, or keep a local copy updated by events. Also decide what happens when the referenced entity is deleted, for example by listening for a CustomerDeleted event.
Q. Which part of a monolith should you extract first?
Answer: A part at the edge with few dependencies, clear ownership and a real reason to be separate, such as frequent changes or different scaling needs. Notifications, search or reporting are common first candidates. Avoid starting with the most central, most connected part: it is the riskiest to move and teaches you little about the process.
Q. How do you decide that two services should be merged?
Answer: Merge them when most changes touch both, when they call each other for nearly every request, when they need to share transactions, or when one team owns both anyway. Merging is a normal correction, not a failure, and it is cheaper than living with a chatty pair forever.
Tricky and scenario questions
Q. The Order Service calls the Customer Service on every order just to check the account status. Is that a problem?
Answer: It couples the two services' availability: if the Customer Service is down, nobody can order. Let the Order Service keep its own small copy of account statuses, updated by CustomerStatusChanged events, and check it locally. The copy may be slightly behind, so decide with the business whether that is acceptable.
Q. A team proposes one microservice for each of its 40 database tables. What do you say?
Answer: That creates entity services: lots of data holders with no behaviour, and long chains of calls for every business operation. Group the tables by the business capability that writes them, so each service owns a meaningful set of data and logic. You will probably end up with a handful of services, not 40.
Q. Two teams need to change the Order class for different reasons. What does that suggest?
Answer: Probably that "order" means different things in two bounded contexts, for example checkout and warehouse fulfilment. Give each context its own model with only the fields it needs, linked by the order ID and events, instead of one shared class that both teams fight over.
Q. Is it fine for two services to share a library?
Answer: Technical libraries are fine, such as logging setup, HTTP clients or security configuration. Libraries that contain business classes, such as a shared Order or Customer model, are a problem. They force services to upgrade together and bring back the coupling that the split was meant to remove.
Q. You inherit a large monolith with a 2,000-table database. Where do you start?
Answer: Do not start by drawing 30 services. First map the business capabilities and find which parts change most and cause the most pain, using change history and incident data. Introduce module boundaries inside the monolith, then extract one low-risk edge capability with the strangler fig approach, including its data, and learn from it before the next one.