Service Boundaries: Decomposing Without Creating a Distributed Monolith
- ShiftQuality Contributor
- Apr 21
- 5 min read
The previous post in this path covered event-driven architecture — when it helps and when it hurts. This post addresses the decision that precedes it: where do you draw the boundaries between services?
Get this right, and your services are independently deployable, independently scalable, and owned by teams that can move without coordinating with everyone else. Get it wrong, and you have a distributed monolith — all the complexity of microservices with none of the benefits. Services that must be deployed together. Changes that cascade across boundaries. Teams that cannot ship without a cross-service coordination meeting.
The distributed monolith is the worst outcome. It is worse than the monolith it replaced, because it has the same coupling plus the network between every call.
Why Boundaries Are Hard
In a monolith, everything can call everything else. A function in the billing module calls a function in the user module calls a function in the notification module. The dependencies are invisible — buried in import statements and function calls — but they don't hurt because all the code deploys together.
When you split the monolith into services, every one of those invisible dependencies becomes a network call, a serialization, a potential failure point, and a latency cost. Dependencies that were free in the monolith are expensive in the distributed system.
The goal of service decomposition is not to eliminate dependencies. It is to minimize the dependencies that cross service boundaries. The dependencies that remain within a service are cheap. The dependencies that cross boundaries are expensive. Drawing the boundary in the wrong place maximizes the expensive kind.
Bounded Contexts: The Organizing Principle
Domain-Driven Design provides the most useful framework for identifying service boundaries: the bounded context. A bounded context is a boundary within which a specific domain model applies.
Consider "customer." In the sales context, a customer has a pipeline stage, a deal value, and a primary contact. In the billing context, a customer has a payment method, an invoice history, and a billing address. In the support context, a customer has open tickets, a satisfaction score, and a support tier.
These are not three views of the same object. They are three different models that happen to refer to the same real-world entity. Each context needs different data, different behavior, and different invariants. Forcing them into a single "Customer" class creates a god object that every team depends on and nobody can change safely.
The bounded context says: each of these is its own service, with its own model of "customer," and the models communicate through well-defined interfaces — events, APIs, or shared identifiers.
When you decompose along bounded context boundaries, the services are naturally loosely coupled. The sales service does not need to know about billing internals. The billing service does not need to know about support tickets. Each service owns its model, its data, and its behavior.
The Coupling Test
Before finalizing a service boundary, apply the coupling test: can this service be deployed independently?
If deploying Service A requires simultaneously deploying Service B, the boundary between them is wrong. The services are coupled — either through shared data, synchronous communication patterns, or shared libraries that require coordinated updates.
Data coupling occurs when two services share a database. Service A writes a record. Service B reads it. Neither can change the schema without coordinating with the other. The solution: each service owns its data store. Data that needs to cross boundaries is shared through events or APIs, not through shared tables.
Temporal coupling occurs when Service A calls Service B synchronously and cannot function without B's response. If B is slow or down, A is slow or down. The solution: asynchronous communication where possible, circuit breakers and fallbacks where synchronous calls are necessary.
Deployment coupling occurs when services share code libraries that require simultaneous updates. A change to the shared library forces all consuming services to update and redeploy. The solution: version the shared library and allow services to update on their own schedules. Accept that different services may run different versions of the library simultaneously.
If a proposed boundary fails the coupling test — if the services on either side cannot be deployed, operated, and evolved independently — the boundary is in the wrong place.
Data Ownership: The Non-Negotiable Rule
Each service owns its data. No exceptions.
When two services share a database, they share a coupling surface that includes every table, every column, every index, and every stored procedure that both services touch. A schema change that Service A needs might break Service B's queries. A performance optimization for Service A might degrade Service B's workload. The shared database becomes a coordination point that eliminates the independence the decomposition was supposed to provide.
The rule is simple: each service has its own data store, and the only way to access that data from outside the service is through the service's API.
This creates a challenge: what about data that multiple services need? A customer's email is used by billing, notifications, and support. The answer is data duplication with eventual consistency. Each service stores the data it needs. When the source of truth changes (the user updates their email in the profile service), an event propagates the change to the services that need it.
Data duplication feels wrong to anyone trained in relational database normalization. But in a distributed system, the cost of duplication (storage, eventual consistency) is almost always lower than the cost of coupling (coordination, shared schema, dependent deployments).
Start Coarse, Split Later
The biggest mistake in service decomposition is starting too fine-grained. A team reads about microservices, decomposes an application into twenty services on day one, and spends the next year managing the operational complexity of twenty services that should have been three.
Start with a monolith or a small number of coarse-grained services. Let the natural boundaries emerge through usage, team structure, and scaling needs. When a specific module needs independent deployment, independent scaling, or independent team ownership, extract it into a service. Not before.
Premature decomposition creates accidental complexity. A monolith that is well-structured internally — with clear module boundaries and clean interfaces — is easy to decompose later. A premature microservice architecture with coupled services and shared databases is expensive to untangle.
The goal is not the maximum number of services. It is the minimum number of boundaries that provide the independence your organization needs.
The Takeaway
Service boundaries should follow domain boundaries — bounded contexts where different models, different data, and different teams naturally separate. Each service owns its data, communicates through contracts, and deploys independently.
The distributed monolith is the failure mode to avoid: services that are separated in the deployment diagram but coupled in practice through shared databases, synchronous dependencies, and coordinated releases.
Start coarse. Extract when there is a concrete reason. Apply the coupling test before every boundary decision. And remember: the best boundary is one the teams on either side barely think about, because the coupling across it is minimal enough to ignore.
Next in the "Architecture for Real Systems" learning path: We'll cover data consistency patterns — sagas, eventual consistency, and the strategies for maintaining correctness across service boundaries without distributed transactions.



Comments