Service Boundaries β How to Split a Monolith Without Regret
The most important microservices decision: where to draw service boundaries. Learn how to use bounded contexts from DDD, avoid the distributed monolith anti-pattern, and correctly decompose an e-commerce system.
Splitting a monolith into microservices is easy. Splitting it correctly is the hard part.
Wrong boundaries give you all the costs of microservices with none of the benefits β a distributed monolith that's harder to deploy, debug, and change than the monolith you started with.
This article gives you the tools to find the right seams.
The Distributed Monolith Anti-Pattern
A distributed monolith looks like microservices but behaves like a tightly coupled monolith.
Signs you have a distributed monolith:
Anti-pattern 1: Shared database
Order Service βββ
Product Service ββΌββ Same database
User Service ββββ
β One schema change breaks all three services
β Teams step on each other's migrations
β Not independently deployable at the data layer
Anti-pattern 2: Synchronous chain of calls
API Request β Service A β Service B β Service C β Service D
(if any service is slow or down, the whole request fails)
(changing Service C's response shape breaks B and A)
Anti-pattern 3: Chatty services
"Get user with their orders" requires:
1. Call User Service
2. Call Orders Service with userId
3. For each order, call Product Service
4. For each product, call Inventory Service
β 4N+2 network calls for what should be one queryIf deploying one service requires deploying three others, you don't have microservices β you have a distributed monolith.
Bounded Contexts β The Primary Tool
The concept comes from Domain-Driven Design (DDD). A bounded context is a boundary within which a particular model has a consistent, unambiguous meaning.
The same word can mean different things in different contexts:
The word "Customer" means different things:
In Sales context:
Customer = { id, name, email, leadSource, salesRep, dealValue }
In Support context:
Customer = { id, name, email, ticketHistory, tier, SLA }
In Shipping context:
Customer = { id, name, deliveryAddresses, preferredCarrier }If you put all of this in one service, you get a "God object" that means everything and nothing clearly. Each bounded context has its own model, its own data, and its own language.
Bounded contexts map almost 1:1 to microservices. One bounded context = one service (usually).
High Cohesion and Low Coupling
These are the core structural principles for service boundaries.
High cohesion: Things that change together, belong together.
High cohesion:
Order Service owns:
- Create order
- Update order status
- Cancel order
- Get order history
All relate to the lifecycle of an order.
Low cohesion (bad):
"Transaction Service" owns:
- Create order
- Process payment
- Update inventory
- Send email
These all happen at checkout but belong to different domains.Low coupling: Services should be able to change independently.
Low coupling (good):
Order Service needs to know: "payment was successful" (event)
It does NOT need to know: how Payment Service processes cards,
which gateway it uses, or what its internal data model looks like.
High coupling (bad):
Order Service calls Payment Service and directly reads
payment.internalState.processorResponse.cardNetwork.authCode
β Any change to Payment Service's internals breaks Order ServiceRule of thumb: If you find yourself changing two services every time you change one feature, the boundary is wrong.
Database Per Service β The Painful Prerequisite
Each service must own its own database. This is non-negotiable for true independence.
Correct:
Order Service β Orders DB (PostgreSQL)
User Service β Users DB (PostgreSQL)
Product Service β Products DB (MongoDB)
(each service has exclusive access to its DB)
Incorrect:
All services β Shared DB
(anyone can read/write anyone else's tables)Why this is painful:
- Queries that span services require API calls instead of JOINs
- "Get order with user details" = one call to Order Service + one call to User Service
- Aggregating data across services requires application-level joining
Why it's necessary:
- If Service A can read Service B's tables directly, they're coupled at the schema level
- A migration in Service B's tables can break Service A silently
- You can't change Service B's database technology (e.g., PostgreSQL β MongoDB) without updating Service A
The pain is the price of independence.
Finding Service Boundaries in Practice
Method 1: Event Storming
Gather domain experts and developers. Map out all domain events on a timeline:
Events (past tense β things that happened):
CustomerRegistered β OrderPlaced β PaymentReceived
β InventoryReserved β OrderShipped β OrderDelivered
Group events by the aggregate that owns them:
Customer aggregate: CustomerRegistered, CustomerUpdated
Order aggregate: OrderPlaced, OrderCancelled, OrderShipped
Payment aggregate: PaymentReceived, PaymentRefunded
Inventory aggregate: InventoryReserved, InventoryRestockedEach aggregate often becomes a service.
Method 2: Identify Autonomy Requirements
Ask: "Which teams should be able to deploy independently?"
Team structure often mirrors service structure:
Checkout team β Cart Service, Order Service
Catalog team β Product Service, Search Service
Identity team β User Service, Auth Service
Logistics team β Shipping Service, Inventory ServiceThis is Conway's Law: organizations design systems that mirror their communication structure. Work with it, not against it.
Method 3: Find Seams in Existing Code
Look for places in the monolith where:
- There are already module/namespace boundaries
- Teams rarely touch each other's code
- Database tables have clear ownership
- You can draw a line and have few cross-boundary dependencies
Existing codebase analysis:
/src
/orders β touches: orders, order_items tables
/products β touches: products, categories, images tables
/users β touches: users, addresses, roles tables
/payments β touches: payments, refunds tables (external gateway)
/notifications β reads from orders, users β sends emails/SMSThe notifications module is tricky β it reads data from others. This often becomes an event-driven service that reacts to events rather than calling other services.
Functional vs Technical Decomposition
Functional decomposition (by business capability): Order Service, Payment Service, User Service
Technical decomposition (by technical concern): Database Service, Cache Service, Notification Service, Authentication Service
Always prefer functional decomposition. Technical decomposition creates shared infrastructure services that everything depends on β a shared Auth Service that all other services call synchronously is a single point of failure. A shared Database Service is just remoting your database with extra latency.
The exception: Authentication/Authorization can be a centralized concern if handled via an API Gateway pattern (the gateway validates JWTs so services don't each implement auth).
Real Example: E-Commerce Monolith Decomposition
Let's split a typical e-commerce app correctly.
Wrong way (by technical layer):
Frontend Service
API Service
Database Service
Cache ServiceThis is the worst possible split β you've just distributed a 3-tier app.
Wrong way (too granular):
UserProfileService
UserAddressService β do these need to be separate?
UserPreferencesService
UserRolesService
UserAuthServiceSplitting by data field rather than business capability β all of these likely change together and belong in one service.
Right way (by bounded context / business capability):
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Gateway / BFF β
β (auth validation, routing, rate limiting) β
ββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββ¬ββββββββββ
β β β β
ββββββΌβββββ βββββΌβββββ βββββΌβββββ ββββΌβββββββ
β User β βCatalog β β Order β β Payment β
β Service β βService β βService β β Service β
β β β β β β β β
β Users β βProductsβ β Orders β βPayments β
β Auth β βCategoryβ β Items β β Refunds β
β Profilesβ βReviews β β Status β β β
βββββββββββ ββββββββββ ββββββββββ βββββββββββ
β β β β
ββββββΌβββββ βββββΌβββββ βββββΌβββββ ββββΌβββββββ
βUsers DB β β Cat DB β β Ord DB β β Pay DB β
βββββββββββ ββββββββββ ββββββββββ βββββββββββ
ββββββββββββββββββββββ ββββββββββββββββββββββββ
β Inventory Service β β Notification Service β
β (stock levels, β β (email, SMS, push β β
β reservations) β β reacts to events) β
ββββββββββββββββββββββ ββββββββββββββββββββββββWhy these boundaries:
- User Service β owns identity, authentication, profiles. One team, one lifecycle.
- Catalog Service β owns products, categories, search. Product team manages it.
- Order Service β owns order lifecycle. Communicates with Inventory (sync) and publishes
OrderPlacedevent. - Payment Service β owns payment processing. Sensitive enough to isolate. Communicates with external payment gateway.
- Inventory Service β owns stock levels. Order Service calls it to reserve inventory.
- Notification Service β consumes events (OrderPlaced, PaymentReceived) and sends notifications. No one calls it directly.
The Anti-Corruption Layer
When integrating with a legacy system or third-party service, use an Anti-Corruption Layer (ACL) to prevent the external model from leaking into your domain.
βββββββββββββββββββββββ ββββββββββββββββββββββββ
β Your Service β β Legacy System / β
β βββACLβββ β External API β
β Clean domain model β β (messy / alien β
β ββββACLββ β model) β
βββββββββββββββββββββββ ββββββββββββββββββββββββ// ACL: translate legacy payment gateway response into your domain model
public class StripePaymentAdapter : IPaymentGateway
{
private readonly StripeClient _stripe;
public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
{
// Call external API in their format
var charge = await _stripe.ChargeCreateAsync(new ChargeCreateOptions
{
Amount = (long)(request.Amount * 100), // Stripe uses cents
Currency = request.Currency.ToLower(),
Source = request.PaymentToken,
});
// Translate to YOUR domain model β Stripe specifics don't leak in
return new PaymentResult(
TransactionId: charge.Id,
Status: MapStatus(charge.Status),
ProcessedAt: charge.Created
);
}
private PaymentStatus MapStatus(string stripeStatus) => stripeStatus switch
{
"succeeded" => PaymentStatus.Successful,
"pending" => PaymentStatus.Pending,
_ => PaymentStatus.Failed,
};
}Key Takeaways
- Wrong boundaries = distributed monolith: shared databases, synchronous chains, chatty services.
- Bounded contexts (from DDD) are the primary tool for finding service boundaries. One bounded context = one service.
- High cohesion (things that change together belong together) + low coupling (services don't know each other's internals).
- Database per service is non-negotiable. It's painful but necessary for true independence.
- Find boundaries via Event Storming, team structure (Conway's Law), and code seam analysis.
- Functional decomposition (by business capability) always beats technical decomposition (by layer or technology).
- Real e-commerce split: User, Catalog, Order, Payment, Inventory, Notification β each with clear ownership.
- Use an Anti-Corruption Layer to prevent external/legacy system models from leaking into your domain.
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.