Introduction
Microservice decomposition is the act of deciding where to draw boundaries in a system - which capabilities become independent services, which data each service owns, and how those services talk to one another. It sounds like a technical exercise, but in practice it is closer to organizational design wearing a technical disguise. Get the boundaries right and you gain independent deployability, fault isolation, and teams that can move without stepping on each other's code. Get them wrong and you end up with something worse than the monolith you were trying to escape: a distributed system with all the coordination overhead of microservices and all the coupling of a monolith.
This article walks through decomposition from the ground up. We start with why decomposition matters and what problem it actually solves, move into the technical mechanics - bounded contexts, data ownership, communication patterns - then get into concrete implementation examples, the trade-offs and anti-patterns that catch teams off guard, and a set of best practices distilled from how mature engineering organizations approach this problem. The goal isn't to convince you that microservices are always the right choice; plenty of successful systems are monoliths, and decomposition done poorly is worse than no decomposition at all. The goal is to give you the mental models to decompose well when it genuinely makes sense.
Context and Problem Overview
Most systems don't start as microservices, and for good reason. Early on, a team is still discovering the shape of the domain, and a monolith lets you refactor boundaries cheaply because everything lives in one codebase with one deployment unit. The trouble starts later, once the system and the organization around it grow. A large monolith couples deployment cadence across unrelated features - a bug fix in billing can block a release of the catalog team. It couples technology choices, since everything runs in one runtime. And it couples failure domains, where a memory leak in one module can take down the entire application. These are the pressures that push teams toward decomposition, not a general belief that "microservices are modern."
Conway's Law is the unavoidable backdrop to this discussion: organizations design systems that mirror their own communication structure. This is not a cute aphorism - it is a predictive statement with real consequences. If you decompose a system into services that don't match how your teams are actually organized, you will fight the architecture constantly, because every cross-service change will require cross-team coordination. This is why many organizations now do the reverse of what seems natural: they design team structure first, using something like Team Topologies' stream-aligned teams, and let service boundaries follow team boundaries rather than the other way around.
It's also worth being explicit about what decomposition does not solve. It does not make bad code better - a poorly designed monolith split into services becomes a poorly designed distributed system, with network calls added where function calls used to be. It does not remove complexity; it relocates complexity from the codebase into the network, where it shows up as latency, partial failures, and eventual consistency. Martin Fowler and James Lewis's original 2014 article on microservices is careful to frame this as a trade-off, not a free upgrade, and that framing has aged well - the teams that struggle most are usually the ones that skipped this framing.
Deep Technical Explanation: Finding the Right Boundaries
The single most important technique for microservice decomposition is domain-driven design, specifically the concept of a bounded context, introduced by Eric Evans in Domain-Driven Design: Tackling Complexity in the Heart of Software. A bounded context is a boundary within which a particular domain model applies - the word "Customer" might mean something different in the billing context (a payer with a balance) than in the support context (a person with a ticket history). Decomposition works best when service boundaries align with bounded contexts, because each service can then own a coherent, internally consistent model instead of trying to be an all-purpose representation of every concept everyone in the company cares about.
A practical technique for discovering these boundaries is event storming, a workshop format popularized by Alberto Brandolini, where domain experts and engineers collaboratively map out business events, commands, and aggregates on a shared timeline. The output isn't a diagram you keep forever - it's the shared understanding of where natural seams exist in the domain. Seams tend to appear where vocabulary shifts, where different stakeholders care about different invariants, or where change happens at different rates. A pricing engine that changes weekly and a tax compliance module that changes on a multi-year regulatory cycle are natural candidates for separation, even if they seem related.
Once you have candidate boundaries, the next decision is data ownership. The rule that matters most here is: each service owns its data, and no other service touches that data directly - not through shared tables, not through direct database connections, only through the owning service's API or published events. This is often called the "database per service" pattern. It is non-negotiable in practice, because shared databases are the single most common way that supposedly independent services become secretly coupled - a schema migration in a shared table becomes a cross-team release, exactly the coordination cost decomposition was supposed to remove.
Communication style is the final major axis. Services can talk synchronously (REST, gRPC) or asynchronously (message queues, event streams via Kafka or similar). Synchronous calls are simpler to reason about but create temporal coupling - if the downstream service is slow or down, the caller is affected immediately. Asynchronous, event-driven communication decouples services in time, at the cost of eventual consistency and harder debugging, since a business process might now span multiple services connected only by events rather than a single call stack. Choosing between them per interaction - not globally for the whole system - is usually the more mature approach.
Implementation: Practical Decomposition Patterns
Two patterns show up repeatedly once a team commits to decomposition: the strangler fig pattern for migrating out of a monolith incrementally, and the saga pattern for coordinating business transactions that now span multiple services.
The strangler fig pattern, named by Martin Fowler after the strangler fig vine that grows around a host tree and gradually replaces it, lets you carve capabilities out of a monolith one at a time rather than attempting a risky big-bang rewrite. An API gateway or reverse proxy sits in front of the monolith, and as each capability is extracted into its own service, routing rules are updated to send that traffic to the new service instead of the old code path. The monolith shrinks over time while the system stays continuously operational.
// A simple strangler-fig routing layer using an API gateway (Express-style)
// Requests are progressively redirected to extracted services as they are peeled off.
import express, { Request, Response, NextFunction } from "express";
import httpProxy from "http-proxy";
const app = express();
const proxy = httpProxy.createProxyServer();
// Map of route prefixes to their current backend.
// As capabilities are extracted, entries move from "monolith" to a real service URL.
const routingTable: Record<string, string> = {
"/api/orders": "http://order-service.internal:8080", // already extracted
"/api/inventory": "http://inventory-service.internal:8080", // already extracted
"/api/pricing": "http://monolith.internal:9000", // not yet extracted
"/api/customers": "http://monolith.internal:9000", // not yet extracted
};
app.use((req: Request, res: Response, next: NextFunction) => {
const prefix = Object.keys(routingTable).find((p) => req.path.startsWith(p));
const target = prefix ? routingTable[prefix] : "http://monolith.internal:9000";
proxy.web(req, res, { target }, (err) => {
console.error(`Proxy error routing ${req.path} to ${target}`, err);
res.status(502).json({ error: "Upstream service unavailable" });
});
});
app.listen(8000, () => console.log("Strangler gateway listening on :8000"));
The saga pattern addresses a problem that decomposition creates: distributed transactions. In a monolith, a single database transaction can guarantee that an order is created and inventory is decremented atomically. Once those two things live in separate services with separate databases, you can no longer wrap them in one ACID transaction. A saga breaks the business transaction into a sequence of local transactions, each with a corresponding compensating action if a later step fails. Sagas can be choreographed (services react to each other's events with no central coordinator) or orchestrated (a dedicated orchestrator issues commands and tracks state). Orchestration tends to be easier to reason about and debug as the number of steps grows, at the cost of introducing a central component.
# A simplified saga orchestrator for an order-placement workflow.
# Each step has a corresponding compensation, executed in reverse on failure.
from dataclasses import dataclass
from typing import Callable, List
@dataclass
class SagaStep:
name: str
action: Callable[[dict], None]
compensate: Callable[[dict], None]
class SagaOrchestrator:
def __init__(self, steps: List[SagaStep]):
self.steps = steps
def execute(self, context: dict) -> bool:
completed: List[SagaStep] = []
try:
for step in self.steps:
step.action(context)
completed.append(step)
return True
except Exception as exc:
print(f"Saga failed at step, rolling back: {exc}")
for step in reversed(completed):
try:
step.compensate(context)
except Exception as comp_exc:
# Compensation failures require alerting; they leave the system
# in an inconsistent state that needs manual intervention.
print(f"Compensation failed for {step.name}: {comp_exc}")
return False
def reserve_inventory(ctx):
ctx["inventory_reserved"] = True
def release_inventory(ctx):
ctx["inventory_reserved"] = False
def charge_payment(ctx):
if ctx.get("card_declined"):
raise RuntimeError("Payment declined")
ctx["payment_charged"] = True
def refund_payment(ctx):
ctx["payment_charged"] = False
order_saga = SagaOrchestrator([
SagaStep("reserve_inventory", reserve_inventory, release_inventory),
SagaStep("charge_payment", charge_payment, refund_payment),
])
Trade-offs and Pitfalls
Every decomposition trades one set of problems for another, and the honest engineering conversation is about which set of problems your team is better equipped to handle, not which architecture is objectively superior. The most immediate cost is operational: instead of one deployable artifact, you now have N services, each needing its own CI/CD pipeline, monitoring, logging, and on-call ownership. Distributed tracing (via OpenTelemetry, for instance) stops being optional the moment a single user request fans out across five services, because without it, debugging a latency spike becomes archaeology.
Data consistency is the second major cost. Once each service owns its own data store, you give up cross-service ACID transactions and take on eventual consistency. This is a genuine cognitive shift for teams used to relational integrity - you now have to design for the state where an order exists but inventory hasn't yet been decremented, even if only for a few hundred milliseconds. Idempotency becomes mandatory for anything triggered by an event, because at-least-once delivery is the norm in most messaging systems, and duplicate processing will happen.
Testing complexity grows non-linearly with the number of services. A single integration test in a monolith might have exercised a full business flow in-process; the same flow across services now requires either a full environment with all dependent services running, or contract testing (tools like Pact implement consumer-driven contract testing) to verify that a service's assumptions about its dependencies still hold without spinning up the entire system for every test run. Teams that skip this step tend to discover integration breakage in production instead of CI.
Perhaps the least discussed pitfall is premature decomposition. Splitting a system before the domain boundaries are well understood locks in the wrong boundaries, and boundaries between services are far more expensive to change than boundaries between modules in a monolith - moving a capability from one service to another means data migration, contract changes, and coordinated deployment across teams, not a simple refactor. Several practitioners, including Fowler, have argued for a "monolith first" approach specifically to let boundaries emerge from real usage before they're made expensive to change.
Common Anti-Patterns
The distributed monolith is the anti-pattern that shows up most often in postmortems. It looks like microservices on an architecture diagram - separate repos, separate deployments, separate databases - but behaves like a monolith, because services can't be deployed independently without breaking each other. This usually stems from boundaries drawn along technical layers (a "user service," a "notification service," a "validation service") rather than along business capabilities, which forces nearly every business change to touch multiple services simultaneously.
Shared database is the second classic failure. Two services reading and writing the same tables might seem like a shortcut to consistency, but it silently recreates tight coupling: a schema change in service A can break service B without either team knowing until deployment. Related to this is the chatty services anti-pattern, where a single logical operation triggers a long chain of synchronous calls between services - each hop adds latency and each service in the chain becomes a new point of failure for an operation that used to be a single function call.
Finally, there's the god service - a service that keeps absorbing responsibility because it's easier to add "just one more endpoint" than to figure out where new functionality actually belongs. This typically happens when there's no clear owner accountable for the service's boundary, and it eventually recreates monolith-like coupling inside what was supposed to be a small, focused service. The fix is the same discipline that prevents scope creep anywhere: an explicit definition of what the service does and does not own, revisited deliberately rather than expanded by accretion.
Best Practices
Start decomposition from the domain, not from the org chart or from technology preference. Use techniques like event storming to find natural seams in the business domain, and validate boundary candidates against the question: "can this capability change independently of its neighbors, most of the time?" If the answer is consistently no, the boundary is probably wrong.
Invest in observability before you need it, not after an incident forces the issue. Distributed tracing, structured logging with correlation IDs propagated across service calls, and centralized metrics dashboards are prerequisites for operating a decomposed system, not nice-to-haves. The 12-Factor App methodology's guidance on treating logs as event streams and configuration as environment-specific remains directly relevant here, even though it predates the current microservices conversation.
Design for failure explicitly. Circuit breakers (patterns popularized by Netflix's Hystrix, and continued in libraries like resilience4j for the JVM) prevent a slow or failing downstream service from cascading failure upstream. Timeouts should be set deliberately on every network call, and retries should use exponential backoff with jitter to avoid synchronized retry storms across many clients hitting a recovering service at once.
Treat contracts as first-class artifacts. Whether you use OpenAPI specifications for REST, protobuf definitions for gRPC, or event schemas in a registry, these contracts should be versioned, reviewed, and tested independently of implementation. Consumer-driven contract testing lets a service verify it still satisfies its consumers' expectations without needing every consumer running in the test environment, which is what makes independent deployability actually achievable rather than aspirational.
Analogies and Mental Models
Think of bounded contexts like different departments in a hospital. Radiology and billing both use the word "patient," but radiology cares about imaging history and billing cares about insurance and payment status - trying to force one shared "Patient" model to serve both departments perfectly is exactly the trap decomposition is meant to avoid. Each department should own its own model of the patient, relevant to its own concerns, and exchange only the specific information the other department needs, through a well-defined interface (a referral, a bill), not a shared filing cabinet.
The strangler fig pattern is well named for a reason: you don't cut down the tree and plant a new one, risking a period with no tree at all. You let the new growth wrap around the old structure gradually, taking over function by function, until the original can be safely removed. This mental model is useful precisely because it discourages the tempting but risky "big rewrite" - the vine model forces you to keep the system alive and useful throughout the transition.
The 80/20 of Microservice Decomposition
If you strip away the tooling debates, a small number of decisions produce most of the outcome. Getting bounded contexts right - aligning service boundaries with actual business capabilities rather than technical layers - determines whether the rest of the architecture will feel natural or forced. Enforcing strict data ownership, with no shared databases and no backdoor access to another service's tables, prevents the majority of hidden coupling that later masquerades as an "independent" architecture. And investing early in distributed tracing and contract testing prevents the operational pain that otherwise convinces teams, incorrectly, that microservices themselves were the mistake rather than the specific decomposition or tooling gaps.
Everything else - the specific message broker, the exact service mesh, the particular API gateway product - is comparatively replaceable. Teams that get the domain boundaries and data ownership right can survive a mediocre choice of message broker; teams that get the boundaries wrong will struggle no matter how good their infrastructure is.
Key Takeaways
- Draw service boundaries around bounded contexts and business capabilities, not technical layers like "database service" or "validation service."
- Give each service exclusive ownership of its data; never allow direct cross-service database access.
- Choose synchronous versus asynchronous communication per interaction based on whether temporal coupling is acceptable, not as a system-wide default.
- Build in distributed tracing, correlation IDs, and contract testing before scaling the number of services, not after an incident.
- Prefer incremental extraction (strangler fig) over big-bang rewrites when migrating an existing monolith.
Conclusion
Microservice decomposition is a discipline, not a checklist. The frameworks and patterns covered here - bounded contexts, database-per-service, sagas, the strangler fig migration path - exist because teams have repeatedly hit the same failure modes and converged on the same fixes. None of them substitute for the harder work of genuinely understanding your domain and being honest about your organization's ability to operate a distributed system.
The teams that get the most value from decomposition are rarely the ones with the most sophisticated tooling. They're the ones that resisted decomposing prematurely, took the time to find real seams in the domain, and treated data ownership and observability as non-negotiable from day one. If you take one thing from this article, let it be that: the boundary is the architecture. Everything else is implementation detail.
References
- Fowler, M. and Lewis, J. (2014). Microservices: a definition of this new architectural term. martinfowler.com
- Newman, S. Building Microservices (2nd ed.). O'Reilly Media.
- Evans, E. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley.
- Fowler, M. StranglerFigApplication. martinfowler.com
- Richardson, C. Microservices Patterns. Manning Publications. (Source of the Saga pattern and database-per-service pattern documentation, also cataloged at microservices.io)
- Brandolini, A. Introducing EventStorming. Leanpub.
- Skelton, M. and Pais, M. Team Topologies: Organizing Business and Technology Teams for Fast Flow. IT Revolution Press.
- The Twelve-Factor App methodology, 12factor.net
- OpenTelemetry documentation, opentelemetry.io
- Pact contract testing documentation, pact.io