Back to blog
System Designadvanced

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.

LearnixoApril 15, 20269 min read
MicroservicesSystem DesignDDDBounded ContextService BoundariesArchitecture
Share:𝕏

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 query

If 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 Service

Rule 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, InventoryRestocked

Each 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 Service

This 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/SMS

The 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 Service

This 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
UserAuthService

Splitting 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 OrderPlaced event.
  • 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)             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
C#
// 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.