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.

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.
// ❌ 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.
## 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.
// 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.
// 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.
// 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.


