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 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.