System Design · Lesson 13 of 26
Service Boundaries — How to Split Without Regret
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.