System Design Fundamentals

Service Boundaries & DDD

A

Service Boundaries & DDD

Introduction

Imagine two teams at an e-commerce company building microservices independently. Team A creates a Product Service that manages catalog information—names, descriptions, images, pricing. Team B builds an Inventory Service to track stock levels, warehouse locations, and reorder thresholds. Both teams create a “Product” entity. Both teams store it in the same database table. Within weeks, changes to the Product table break one service or the other. A price update in Team A’s code affects inventory allocation in Team B’s code. Nobody owns the Product domain anymore. Everyone’s confused, and every deployment becomes a coordination nightmare.

This scenario haunts teams building distributed systems, and the root cause isn’t technical—it’s organizational and conceptual. The two teams operate in different bounded contexts. In the Catalog context, a Product is fundamentally about discovery and marketing. In the Inventory context, a Product is fundamentally about supply chain management. They’re not the same thing wearing different clothes; they’re genuinely different concepts that happen to share a name.

Domain-Driven Design (DDD) gives us the language, tools, and patterns to prevent this chaos. It helps us recognize that “Product” has different meanings in different parts of our system, and that’s not a bug—it’s a feature. We can embrace this multiplicity instead of fighting it.

In this chapter, we’ll explore how DDD helps you draw service boundaries that align with your business domain, not arbitrary technical layers. We’ll learn the frameworks to communicate effectively within your team, and the patterns to orchestrate services that speak different languages about the same real-world entities.

Bounded Contexts: The Cornerstone of DDD

A Bounded Context is a boundary within which a particular model is valid and consistent. Within this boundary, terms have precise meanings, rules apply consistently, and a single ubiquitous language exists. Step outside the boundary, and terms shift meaning, rules change, and you need translation.

This is the most important concept in DDD, especially for microservices architecture.

Consider the word “Product” again. In the Catalog Bounded Context, a Product has:

  • Name, description, images, marketing copy
  • Category, tags, similar items
  • List price, promotional pricing history

In the Inventory Bounded Context, a Product has:

  • SKU (stock keeping unit), bar code
  • Current stock level, minimum threshold, reorder quantity
  • Warehouse locations, expiration dates
  • Unit cost, storage requirements

These aren’t the same entity viewed from different angles. They’re different aggregates serving different purposes, and they happen to represent the same real-world item. Recognizing this distinction is liberating. You no longer need to build a universal Product entity that satisfies everyone. Each context owns its version.

graph TB
    subgraph Catalog["🏪 Catalog Bounded Context"]
        CP["Product<br/>name, description,<br/>images, price"]
        CL["ProductListing<br/>category, tags,<br/>related items"]
        CP ---|"has"| CL
    end

    subgraph Inventory["📦 Inventory Bounded Context"]
        IP["Product<br/>sku, barcode<br/>unit_cost"]
        IS["StockLevel<br/>warehouse, qty<br/>reorder_point"]
        IP ---|"tracks"| IS
    end

    Catalog -->|"ProductCreated<br/>Domain Event"| Inventory
    style Catalog fill:#e1f5ff
    style Inventory fill:#fff3e0

Ubiquitous Language and Communication

DDD emphasizes the Ubiquitous Language: a common vocabulary that both business stakeholders and developers use to discuss the domain. This language lives in your code, your tests, your documentation, and your conversations.

Why does this matter for microservices? Because when teams don’t share language, they build services that don’t fit together. If your Inventory team talks about “allocating stock” and your Shipping team talks about “reserving inventory,” you have the same concept with two names. Confusion multiplies. Integration becomes painful.

Within each Bounded Context, establish the language:

  • The Ordering context uses: Order, OrderItem, OrderStatus, Payment, Shipment
  • The Inventory context uses: Product, StockLevel, Warehouse, Allocation, Deallocation
  • The Shipping context uses: Shipment, ShipmentItem, TrackingNumber, Carrier, Route

When contexts interact, they translate through well-defined interfaces (more on this below). But within a context, terminology is consistent and precise.

Aggregates: Consistency Boundaries

An Aggregate is a cluster of domain objects that must be kept consistent as a unit. When you update one part of an aggregate, all parts update together within a single transaction. Aggregates form natural consistency boundaries in your services.

Consider the Order Aggregate in an e-commerce system:

Order (aggregate root)
├── OrderStatus
├── Customer reference
├── OrderItem (collection)
│   ├── Product reference (just the ID)
│   ├── Quantity
│   ├── Unit price
│   └── Line total
└── Totals
    ├── Subtotal
    ├── Tax
    └── Grand total

The Order is the aggregate root. When you place an order, you modify the entire aggregate atomically. You don’t let external services modify OrderItems directly; they request changes through the Order aggregate. This maintains consistency.

Here’s why aggregates matter for service design:

  1. Consistency boundary: Everything within an aggregate updates together
  2. Transaction boundary: One aggregate = one database transaction
  3. Service ownership: A service typically owns one or more aggregates
  4. Reference boundary: Aggregates reference other aggregates by ID only, not by embedding them

This last point is critical. Your Order aggregate should hold a customerId, not a full Customer object. This decouples the Order service from the Customer service. If Customer changes, Order isn’t affected.

Context Mapping: How Bounded Contexts Relate

Once you’ve identified your bounded contexts, you need to understand how they interact. Context Mapping defines these relationships. There are several patterns:

Shared Kernel

Two contexts share a small amount of code (e.g., domain classes, interfaces). Use sparingly—shared code creates coupling between teams. Only share what’s genuinely needed.

Customer-Supplier

One context (Customer) depends on another (Supplier). The Supplier team controls the interface; the Customer team adapts to it. This reflects real-world upstream/downstream relationships. The Shipping context depends on the Inventory context (Inventory is upstream, tells Shipping how much stock is available).

Conformist

The Customer context simply conforms to the Supplier’s model, no translation. Risky, but sometimes pragmatic. Your service uses the Supplier’s language directly.

Anti-Corruption Layer

The Customer context translates the Supplier’s model into its own language through a translation layer. This insulates you from changes upstream and lets you maintain your own language. When the Inventory context publishes InventoryUpdated events, your Shipping context’s Anti-Corruption Layer translates them into internal StockAvailableForShipment events.

Published Language

The Supplier context publishes a well-defined, standardized API contract (REST, events, RPC). The Customer context uses this as-is. Think of this as the API version of Shared Kernel. Example: your Payments context publishes a clear API contract; the Ordering context consumes it without translation.

Here’s what these relationships look like:

graph LR
    A["Ordering<br/>Customer"] -->|"depends on"| B["Inventory<br/>Supplier"]
    C["Shipping<br/>Customer"] -->|"via ACL"| B
    D["Notifications"] -->|"Published<br/>Language"| E["Multiple Contexts"]
    F["Payments"] -->|"Shared<br/>Kernel"| G["Accounting"]

    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9
    style E fill:#e8f5e9
    style F fill:#fce4ec
    style G fill:#fce4ec

Running Event Storming to Discover Boundaries

Event Storming is a collaborative workshop technique where your team maps out the domain by identifying events, commands, aggregates, and bounded contexts. It’s one of the best ways to discover natural service boundaries.

Here’s a simplified process:

  1. List domain events (sticky notes on a timeline). Example: “OrderPlaced,” “PaymentProcessed,” “InventoryAllocated,” “ShipmentCreated”

  2. Identify commands that triggered each event. “PlaceOrder” triggers “OrderPlaced.” “ProcessPayment” triggers “PaymentProcessed”

  3. Find aggregates. Which aggregate handles each command? Order handles PlaceOrder, Payment aggregate handles ProcessPayment

  4. Spot bounded contexts. Which aggregates naturally cluster together? Order and OrderItem cluster (Ordering context). Warehouse and StockLevel cluster (Inventory context)

  5. Draw context boundaries. This is where services naturally form

  6. Identify policies (automated responses). “When OrderPlaced, reserve inventory” implies a policy that links Ordering and Inventory contexts

A simple example for an e-commerce system:

Timeline:
ProductCreated → OrderPlaced → PaymentProcessed → InventoryAllocated → ShipmentCreated → ShipmentDelivered

Commands:
CreateProduct → PlaceOrder → ProcessPayment → AllocateInventory → CreateShipment → MarkDelivered

Aggregates:
Product (Catalog) | Order (Ordering) | Payment (Payments) | Allocation (Inventory) | Shipment (Shipping)

Bounded Contexts:
[Catalog] [Ordering] [Payments] [Inventory] [Shipping]

This simple exercise reveals your service architecture.

Pro Tip: Run Event Storming with domain experts, product managers, and engineers together. The conversations are as valuable as the output. You’ll discover assumptions you didn’t know you had.

Anti-Corruption Layer: Translating Between Worlds

When your Ordering service consumes events from the Inventory service, you don’t want Inventory’s language polluting your code. An Anti-Corruption Layer (ACL) is a translation boundary.

Example: Inventory publishes:

{
  "event": "ProductInventoryUpdated",
  "product_sku": "SKU-12345",
  "warehouse_code": "WH-A",
  "new_quantity": 47,
  "reorder_level": 20
}

Your Ordering service doesn’t understand warehouse codes or SKUs at that level. It cares about “can I ship this?” Your ACL translates:

// Anti-Corruption Layer in Ordering service
class InventoryEventAdapter {
  handleInventoryUpdate(event: ProductInventoryUpdated) {
    // Translate Inventory's model to Ordering's model
    const availability = this.mapToOrderingModel(event);
    // Ordering now processes its own event
    this.publish(new StockLevelChangedForOrdering(
      productId: availability.productId,
      isAvailable: availability.quantity > 0
    ));
  }

  private mapToOrderingModel(event: ProductInventoryUpdated) {
    return {
      productId: this.skuToProductId(event.product_sku),
      quantity: event.new_quantity,
      isLow: event.new_quantity <= event.reorder_level
    };
  }
}

This layer:

  • Decouples your service from upstream changes
  • Lets each context maintain its language
  • Makes testing easier (you control the translation)
  • Documents assumptions about how other services work

Domain Events as the Integration Mechanism

When bounded contexts interact, they don’t call each other’s methods. Instead, they publish and subscribe to Domain Events—facts about things that happened in the domain.

Example flow in an e-commerce system:

1. Customer places order
   → Ordering service publishes: OrderPlaced event

2. Inventory service listens for OrderPlaced
   → Publishes: InventoryAllocated event

3. Payments service listens for OrderPlaced
   → Processes payment
   → Publishes: PaymentReceived event

4. Shipping service listens for PaymentReceived and InventoryAllocated
   → Publishes: ShipmentCreated event

5. Ordering service listens for ShipmentCreated
   → Updates order status to "Shipped"

This is loosely coupled integration. Services don’t know about each other; they know about events. Adding a new service (e.g., Analytics) is simple—it just subscribes to events it cares about.

Here’s what domain events look like:

// In Ordering service
class OrderPlacedEvent {
  orderId: string;
  customerId: string;
  items: OrderItem[];
  totalAmount: decimal;
  occurredAt: Date;
  version: number; // for event sourcing
}

// In Inventory service
class InventoryAllocatedEvent {
  orderId: string; // reference to order
  allocations: {
    productId: string;
    quantity: number;
    warehouseId: string;
  }[];
  occurredAt: Date;
}

Practical Example: E-Commerce Domain Modeled with DDD

Let’s map a real e-commerce domain using what we’ve learned.

Bounded Contexts Identified

ContextOwnsKey AggregatesIntegration
CatalogProduct definitions, descriptions, images, marketingProduct, Category, ReviewPublishes ProductCreated, PriceChanged
InventoryStock levels, warehouses, allocationStockLevel, Warehouse, AllocationListens to ProductCreated; publishes AllocationAvailable
OrderingOrders, order lifecycleOrder, OrderItemListens to AllocationAvailable; publishes OrderPlaced, OrderCancelled
PaymentsPayment processing, refundsPayment, PaymentMethod, TransactionListens to OrderPlaced; publishes PaymentReceived, PaymentFailed
ShippingShipment creation, trackingShipment, TrackingEventListens to PaymentReceived; publishes ShipmentCreated, DeliveryConfirmed
NotificationsEmail, SMS alertsNotification, SubscriptionListens to all domain events; publishes nothing

Context Relationships

  • Catalog → Inventory: Customer relationship. Inventory depends on Catalog definitions. When a product is created, Inventory is notified
  • Ordering → Inventory: Customer relationship via ACL. Ordering doesn’t embed Inventory’s allocation model
  • Ordering → Payments: Customer relationship. Ordering depends on Payments processing
  • Payments → Ordering: Supplier. Payments publishes PaymentReceived; Ordering consumes it
  • Shipping → Ordering & Inventory: Depends on both. Shipping listens to PaymentReceived and pulls inventory allocation data

Sample Domain Event Flow

Customer clicks "Checkout"

Ordering service creates Order aggregate

Publishes: OrderPlaced(orderId, customerId, items[])

Inventory service listens, allocates stock

Publishes: InventoryAllocated(orderId, allocations[])

Payments service listens to OrderPlaced, processes payment

Publishes: PaymentReceived(orderId, amount)

Shipping service listens to PaymentReceived, creates Shipment

Publishes: ShipmentCreated(shipmentId, orderId)

Ordering service listens, updates Order status to "Processing"

No service calls another directly. Each service reacts to events in its domain.

Trade-Offs and When to Use DDD

DDD is powerful but not free. Consider the costs and benefits:

Upfront Modeling Investment

Event Storming sessions, domain workshops, and careful boundary design take time. For simple CRUD applications (a blog, a todo app), this is overkill. For complex business domains, it’s an investment that pays dividends.

Organizational Alignment

DDD works best when your teams align with your bounded contexts. If one team owns Ordering and another owns Inventory, DDD helps clarify responsibilities. If one team owns everything, DDD adds bureaucracy.

Learning Curve

Your team needs to learn DDD concepts. Some developers find this intuitive; others struggle with the abstraction. Budget training time.

Risk of Over-Engineering

It’s easy to create too many contexts, too much abstraction, too many layers. Not every decision needs a domain event. Not every service needs an anti-corruption layer. Judge carefully.

When DDD Is Overkill

  • Simple CRUD applications with few business rules
  • Prototypes and MVPs where you’re still exploring the domain
  • Services with straightforward, non-overlapping responsibilities

When DDD Is Essential

  • Complex business domains with subtle rules and policies
  • Multiple teams building interdependent services
  • Domains where terminology is ambiguous (Product, Account, Order)
  • Systems that evolve frequently; clarity reduces rework

Key Takeaways

  • Bounded Contexts let the same term mean different things in different parts of your system, eliminating forced universal models
  • Aggregates are consistency boundaries; build services around aggregate ownership, not layers
  • Context Mapping defines how bounded contexts relate—through shared kernels, customer-supplier relationships, conformism, or anti-corruption layers
  • Domain Events are the primary integration mechanism between services, enabling loose coupling
  • Event Storming is a practical technique to discover bounded contexts and design service architectures collaboratively
  • Anti-Corruption Layers insulate your service from upstream changes and let you maintain your own ubiquitous language

Practice Scenarios

Scenario 1: The Hospital Domain

A hospital system has doctors, patients, medical records, and billing. Event Storm this domain. Identify bounded contexts. What’s a “Patient” in the Medical Records context vs. the Billing context? How would these services communicate?

Scenario 2: The SaaS Subscription Service

A SaaS platform has user accounts, subscriptions, billing, and usage analytics. Define the bounded contexts. What’s a “User” to the Account context vs. the Usage context? Draw a context map showing dependencies and integration patterns.

Scenario 3: The Marketplace

An online marketplace has sellers, buyers, products, orders, reviews, and payouts. Model this with DDD. Which contexts are customers, which are suppliers? How would you handle conflicts if both sellers and buyers want to be called “Users”?

Next Steps: Communication Between Services

Now that we’ve defined clear boundaries and identified how contexts relate, we need the concrete patterns to make them communicate. In the next section, we’ll explore synchronous communication (REST, RPC) and asynchronous patterns (events, message queues) that bring these designs to life.

The key insight: good service boundaries make communication patterns explicit. You know exactly which services talk to which, why they talk, and what protocol fits best. Without clear boundaries, you’re guessing.