Skip to content

Domain-Driven Design: Bounded Contexts in Practice

Apply DDD bounded contexts to real codebases: clear subdomain boundaries, context mapping between teams, and never sharing domain models across services.

5 min read
Context map diagram showing bounded contexts for ordering, shipping, and billing domains with relationship types marked between them

Domain-Driven Design sounds academic until you hit the problem it solves: your "User" model has 47 fields because every team adds their own needs to it. The billing team needs payment method details, the shipping team needs address history, the marketing team needs engagement metrics, and the product team needs feature flags. Changes to this shared model cascade across the codebase, and nobody can modify it without coordinating with three other teams.

Bounded contexts solve this by accepting that different parts of your system need different models of the same real-world concept. A "Customer" in billing is different from a "Customer" in shipping. They share an identity but have completely different attributes, behaviors, and invariants. Trying to unify them into one model creates coupling that slows every team down.

What a Bounded Context Actually Is

A bounded context is a boundary within which a particular domain model is defined and applicable. Inside the boundary, every term has a precise, unambiguous meaning. Outside the boundary, the same term might mean something different.

tstypescript
// ❌ One shared User model used everywhere
interface User {
  id: string;
  name: string;
  email: string;
  // Billing concerns
  stripeCustomerId: string;
  paymentMethods: PaymentMethod[];
  billingAddress: Address;
  // Shipping concerns
  shippingAddresses: Address[];
  preferredCarrier: string;
  // Marketing concerns
  emailPreferences: EmailPreferences;
  lastEngagementDate: Date;
  segmentIds: string[];
  // Auth concerns
  passwordHash: string;
  mfaSecret: string;
  lastLoginAt: Date;
  // Product concerns
  featureFlags: Record<string, boolean>;
  plan: string;
  trialEndsAt: Date;
}
 
// ✅ Separate models per bounded context
// Each context owns its own definition of the domain
 
// Billing Context
interface BillingCustomer {
  customerId: string;          // Same real person
  stripeId: string;
  paymentMethods: PaymentMethod[];
  billingAddress: Address;
  currentPlan: Plan;
}
 
// Shipping Context
interface ShippingRecipient {
  recipientId: string;         // Same real person, different name
  shippingAddresses: Address[];
  preferredCarrier: Carrier;
  deliveryInstructions: string;
}
 
// Identity Context
interface AuthIdentity {
  identityId: string;
  email: string;
  passwordHash: string;
  mfaEnabled: boolean;
  sessions: Session[];
}

Identifying Bounded Context Boundaries

Finding the right boundaries requires understanding the business domain, not the technical architecture. Two heuristics that work: follow the organization's team structure, and watch for words that mean different things to different people.

markdownmarkdown
## Boundary identification heuristics
 
### 1. Linguistic boundaries
When the same word means different things to different teams,
you've found a context boundary.
 
"Order" in the sales context:
  - A quote that could be modified or cancelled
  - Has line items with negotiated prices
  - Belongs to a sales rep
 
"Order" in the fulfillment context:
  - An instruction to pick, pack, and ship items
  - Has warehouse locations and pick paths
  - Belongs to a fulfillment queue
 
"Order" in the billing context:
  - An invoice to collect payment for
  - Has payment terms and tax calculations
  - Belongs to an accounts receivable process
 
### 2. Team boundaries (Conway's Law)
If different teams own different aspects of a concept,
each team probably needs its own model.
 
### 3. Rate of change
Parts of a model that change together should be in 
the same context. Parts that change independently 
should be in different contexts.
 
Billing rules change when pricing changes.
Shipping rules change when logistics partners change.
These change for different reasons → different contexts.

Context Mapping: How Bounded Contexts Relate

Bounded contexts don't live in isolation. They need to exchange information. The context map defines how contexts relate and how data flows between them.

tstypescript
// Common context relationship patterns
 
// 1. ANTI-CORRUPTION LAYER
// Protects your context from another context's model
class ShippingAntiCorruptionLayer {
  // Translate from the order context's model to shipping's model
  translateOrder(salesOrder: ExternalSalesOrder): ShippingRequest {
    return {
      recipientId: salesOrder.customerId,
      items: salesOrder.lineItems.map((item) => ({
        sku: item.productSku,
        quantity: item.quantity,
        // Shipping doesn't care about price — that's billing's job
        weight: this.lookupWeight(item.productSku),
        dimensions: this.lookupDimensions(item.productSku),
      })),
      priority: this.mapPriority(salesOrder.shippingTier),
      address: this.validateAddress(salesOrder.shippingAddress),
    };
  }
 
  private mapPriority(
    tier: 'standard' | 'express' | 'overnight'
  ): ShippingPriority {
    const mapping: Record<string, ShippingPriority> = {
      standard: ShippingPriority.NORMAL,
      express: ShippingPriority.HIGH,
      overnight: ShippingPriority.CRITICAL,
    };
    return mapping[tier] ?? ShippingPriority.NORMAL;
  }
}
 
// 2. SHARED KERNEL
// A small, explicitly shared model between two contexts
// Both teams must agree on changes
interface SharedOrderId {
  value: string;
  format: 'ORD-YYYYMMDD-XXXXX';
}
 
// 3. PUBLISHED LANGUAGE
// A well-documented integration format
interface OrderPlacedEvent {
  eventType: 'order.placed';
  version: '2.0';
  orderId: string;
  customerId: string;
  items: Array<{
    sku: string;
    quantity: number;
    unitPriceCents: number;
  }>;
  totalCents: number;
  currency: string;
  timestamp: string;
}

Implementing Boundaries in Code

The physical structure of your code should reflect the bounded context boundaries. This can work in a monolith or across services.

tstypescript
// Monolith with clear bounded context boundaries
// src/
// ├── contexts/
// │   ├── billing/
// │   │   ├── domain/
// │   │   │   ├── BillingCustomer.ts
// │   │   │   ├── Invoice.ts
// │   │   │   └── PaymentMethod.ts
// │   │   ├── application/
// │   │   │   ├── CreateInvoice.ts
// │   │   │   └── ProcessPayment.ts
// │   │   ├── infrastructure/
// │   │   │   ├── StripeGateway.ts
// │   │   │   └── BillingRepository.ts
// │   │   └── api/
// │   │       └── billingRoutes.ts
// │   ├── shipping/
// │   │   ├── domain/
// │   │   ├── application/
// │   │   ├── infrastructure/
// │   │   └── api/
// │   └── identity/
// │       ├── domain/
// │       ├── application/
// │       ├── infrastructure/
// │       └── api/
// └── shared/
//     └── kernel/
//         ├── OrderId.ts
//         └── Money.ts
 
// ❌ Cross-context imports break boundaries
// In shipping/application/CreateShipment.ts:
import { Invoice } from '../../billing/domain/Invoice'; // NO!
 
// ✅ Communicate through events or explicit integration points
// In shipping/application/CreateShipment.ts:
import { OrderPlacedEvent } from '../../shared/kernel/events';
 
class CreateShipment {
  async handle(event: OrderPlacedEvent): Promise<void> {
    // Transform event data into shipping domain objects
    const acl = new ShippingAntiCorruptionLayer();
    const request = acl.fromOrderPlacedEvent(event);
 
    const shipment = Shipment.create(request);
    await this.shipmentRepo.save(shipment);
  }
}

Communication Between Contexts

Bounded contexts communicate through domain events, API calls, or shared databases (last resort). Events are the most decoupled approach.

tstypescript
// Event-driven communication between contexts
 
// Sales context publishes when an order is placed
class PlaceOrder {
  constructor(
    private orderRepo: OrderRepository,
    private eventBus: EventBus
  ) {}
 
  async execute(command: PlaceOrderCommand): Promise<void> {
    const order = Order.create(command);
    await this.orderRepo.save(order);
 
    // Publish event — other contexts react independently
    await this.eventBus.publish({
      eventType: 'order.placed',
      version: '2.0',
      orderId: order.id,
      customerId: command.customerId,
      items: order.items.map((item) => ({
        sku: item.sku,
        quantity: item.quantity,
        unitPriceCents: item.price.cents,
      })),
      totalCents: order.total.cents,
      currency: order.total.currency,
      timestamp: new Date().toISOString(),
    });
  }
}
 
// Billing context subscribes and creates an invoice
class OrderPlacedHandler {
  async handle(event: OrderPlacedEvent): Promise<void> {
    const customer = await this.customerRepo.findById(
      event.customerId
    );
 
    const invoice = Invoice.createFromOrder({
      orderId: event.orderId,
      customerId: customer.customerId,
      lineItems: event.items.map((item) => ({
        description: item.sku,
        quantity: item.quantity,
        unitPrice: Money.fromCents(item.unitPriceCents, event.currency),
      })),
      paymentTerms: customer.currentPlan.paymentTerms,
    });
 
    await this.invoiceRepo.save(invoice);
  }
}
 
// Shipping context subscribes and creates a shipment
class OrderPlacedShippingHandler {
  async handle(event: OrderPlacedEvent): Promise<void> {
    const recipient = await this.recipientRepo.findById(
      event.customerId
    );
 
    const shipment = Shipment.create({
      orderId: event.orderId,
      recipient,
      items: event.items.map((item) => ({
        sku: item.sku,
        quantity: item.quantity,
      })),
    });
 
    await this.shipmentRepo.save(shipment);
  }
}

Key Takeaways

A bounded context is a boundary within which a domain model has precise, unambiguous meaning—"Customer" in billing (with payment methods and invoices) is a different model from "Customer" in shipping (with addresses and carrier preferences), and trying to unify them into one model creates coupling that slows every team. Find boundaries by watching for words that mean different things to different teams, following team ownership lines, and grouping things that change for the same reason—these linguistic and organizational signals reveal natural domain boundaries more reliably than technical analysis. Use anti-corruption layers to translate between contexts so each context's model stays clean—when the shipping context receives an order event from sales, it translates the data into its own domain objects through an explicit transformation layer rather than depending on the sales context's model directly. Communicate between contexts through domain events for maximum decoupling—the sales context publishes "order placed" and billing and shipping react independently, each interpreting the event through their own domain lens without knowing or caring about each other's existence.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX