DDD in Practice: Bounded Contexts and Context Mapping
A practical guide to bounded contexts and context mapping in real applications: the strategic DDD patterns that stop domain models from rotting.

Why Bounded Contexts Matter More Than Aggregates
Most introductions to Domain-Driven Design focus on tactical patterns: entities, value objects, aggregates, repositories. These patterns are useful, but they are implementation details. The strategic patterns—bounded contexts, context maps, and ubiquitous language—are what determine whether a system scales with organizational complexity or collapses under it.
A bounded context is a boundary within which a particular domain model is defined and applicable. The same real-world concept—"customer," "order," "product"—means different things in different parts of your system. Trying to force a single unified model across all contexts creates a fragile, compromise-laden mess that satisfies no one.
Identifying Bounded Context Boundaries
Bounded contexts align with areas where language changes meaning. When the sales team says "customer" they mean something different from what the billing team means, which differs from what the support team means.
// ❌ Single "Customer" model trying to serve every context
interface UnifiedCustomer {
id: string;
name: string;
email: string;
// Sales context fields
leadScore: number;
salesRepId: string;
pipelineStage: string;
// Billing context fields
paymentMethod: string;
billingAddress: string;
taxId: string;
creditLimit: number;
// Support context fields
supportTier: string;
openTickets: number;
satisfactionScore: number;
preferredContactMethod: string;
}
// ✅ Each context owns its own model
// --- Sales Context ---
interface Lead {
id: string;
contactName: string;
contactEmail: string;
score: number;
assignedRepId: string;
stage: "prospect" | "qualified" | "proposal" | "negotiation" | "closed";
}
// --- Billing Context ---
interface BillingAccount {
accountId: string;
accountHolder: string;
paymentMethod: PaymentMethod;
billingAddress: Address;
taxId: string | null;
creditLimit: number;
}
// --- Support Context ---
interface SupportContact {
contactId: string;
displayName: string;
tier: "basic" | "premium" | "enterprise";
preferredChannel: "email" | "phone" | "chat";
}Each model contains only what its context needs. The sales context does not care about billing addresses. The support context does not need lead scores. This is not redundancy—it is appropriate modeling.
Context Mapping Patterns
Bounded contexts do not exist in isolation. They need to exchange information. Context mapping defines the relationships and integration patterns between contexts.
enum ContextRelationship {
PARTNERSHIP = "partnership",
SHARED_KERNEL = "shared-kernel",
CUSTOMER_SUPPLIER = "customer-supplier",
CONFORMIST = "conformist",
ANTICORRUPTION_LAYER = "anticorruption-layer",
OPEN_HOST_SERVICE = "open-host-service",
PUBLISHED_LANGUAGE = "published-language",
SEPARATE_WAYS = "separate-ways",
}
interface ContextMapEntry {
upstream: string;
downstream: string;
relationship: ContextRelationship;
integrationPattern: string;
dataFlow: string;
}
const contextMap: ContextMapEntry[] = [
{
upstream: "Sales",
downstream: "Billing",
relationship: ContextRelationship.CUSTOMER_SUPPLIER,
integrationPattern: "Domain events via message queue",
dataFlow: "Sales publishes LeadConverted, Billing creates BillingAccount",
},
{
upstream: "Legacy ERP",
downstream: "Order Management",
relationship: ContextRelationship.ANTICORRUPTION_LAYER,
integrationPattern: "ACL translates ERP XML into domain events",
dataFlow: "ERP exports orders, ACL transforms into Order aggregate",
},
{
upstream: "Order Management",
downstream: "Shipping",
relationship: ContextRelationship.OPEN_HOST_SERVICE,
integrationPattern: "REST API with published schema",
dataFlow: "Shipping queries order details via versioned API",
},
];The context map is not a technical diagram—it is a political document. It describes who depends on whom, who has the power to change interfaces, and where translation layers are needed.
Implementing an Anti-Corruption Layer
The anti-corruption layer (ACL) is the most important integration pattern for protecting your domain model from external influence. It translates between the language of an upstream context and your own.
// External legacy system model (upstream — we don't control this)
interface LegacyOrderRecord {
ORD_NUM: string;
CUST_ID: string;
ORD_DT: string; // Format: YYYYMMDD
ITEM_LST: string; // Pipe-separated: "SKU1|QTY1|PRC1||SKU2|QTY2|PRC2"
TOT_AMT: number; // In cents
STAT_CD: number; // 1=pending, 2=confirmed, 3=shipped, 4=delivered, 9=cancelled
}
// Our domain model (downstream — this is our bounded context)
interface Order {
orderId: string;
customerId: string;
placedAt: Date;
items: OrderItem[];
total: Money;
status: OrderStatus;
}
interface OrderItem {
sku: string;
quantity: number;
unitPrice: Money;
}
interface Money {
amount: number;
currency: string;
}
type OrderStatus =
| "pending"
| "confirmed"
| "shipped"
| "delivered"
| "cancelled";
// Anti-Corruption Layer
class OrderAntiCorruptionLayer {
private static readonly STATUS_MAP: Record<number, OrderStatus> = {
1: "pending",
2: "confirmed",
3: "shipped",
4: "delivered",
9: "cancelled",
};
translate(legacy: LegacyOrderRecord): Order {
return {
orderId: legacy.ORD_NUM,
customerId: legacy.CUST_ID,
placedAt: this.parseDate(legacy.ORD_DT),
items: this.parseItems(legacy.ITEM_LST),
total: { amount: legacy.TOT_AMT / 100, currency: "USD" },
status: this.mapStatus(legacy.STAT_CD),
};
}
private parseDate(dateStr: string): Date {
const year = parseInt(dateStr.slice(0, 4), 10);
const month = parseInt(dateStr.slice(4, 6), 10) - 1;
const day = parseInt(dateStr.slice(6, 8), 10);
return new Date(year, month, day);
}
private parseItems(itemList: string): OrderItem[] {
const parts = itemList.split("||");
return parts.map((part) => {
const [sku, qty, price] = part.split("|");
return {
sku,
quantity: parseInt(qty, 10),
unitPrice: { amount: parseFloat(price) / 100, currency: "USD" },
};
});
}
private mapStatus(code: number): OrderStatus {
const status = OrderAntiCorruptionLayer.STATUS_MAP[code];
if (!status) {
throw new Error(`Unknown legacy status code: ${code}`);
}
return status;
}
}The ACL shields the domain model from the legacy system's naming conventions, data formats, and structural choices. If the upstream system changes, only the ACL needs updating—the domain model remains pure.
Domain Events for Context Integration
Domain events are the primary mechanism for bounded contexts to communicate without coupling. Each context publishes events about significant state changes; other contexts subscribe to the events they care about.
interface DomainEvent {
eventId: string;
eventType: string;
occurredAt: Date;
aggregateId: string;
payload: Record<string, unknown>;
}
// Sales context publishes when a lead converts
interface LeadConvertedEvent extends DomainEvent {
eventType: "sales.lead.converted";
payload: {
leadId: string;
contactName: string;
contactEmail: string;
contractValue: number;
salesRepId: string;
};
}
// Billing context subscribes and creates a billing account
class BillingEventHandler {
constructor(private readonly accountRepo: AccountRepository) {}
async handleLeadConverted(event: LeadConvertedEvent): Promise<void> {
const account: BillingAccount = {
accountId: crypto.randomUUID(),
accountHolder: event.payload.contactName,
paymentMethod: { type: "pending-setup" },
billingAddress: { type: "pending-collection" },
taxId: null,
creditLimit: this.calculateInitialCredit(event.payload.contractValue),
};
await this.accountRepo.save(account);
}
private calculateInitialCredit(contractValue: number): number {
return Math.min(contractValue * 0.1, 10000);
}
}
// Support context subscribes and creates a support record
class SupportEventHandler {
constructor(private readonly contactRepo: ContactRepository) {}
async handleLeadConverted(event: LeadConvertedEvent): Promise<void> {
const contact: SupportContact = {
contactId: crypto.randomUUID(),
displayName: event.payload.contactName,
tier: "basic",
preferredChannel: "email",
};
await this.contactRepo.save(contact);
}
}Each downstream context translates the event into its own language and creates its own domain objects. The sales context does not need to know that billing and support exist—it simply announces what happened.
Shared Kernel: When Contexts Need Common Ground
Sometimes two contexts are so closely related that maintaining completely separate models creates more problems than sharing. The shared kernel pattern allows two contexts to share a small, explicitly defined subset of the domain model.
// Shared kernel between Order and Shipping contexts
// This module is co-owned by both teams and changes require agreement
// shared-kernel/money.ts
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) throw new Error("Money amount cannot be negative");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
// shared-kernel/address.ts
export interface Address {
street: string;
city: string;
state: string;
postalCode: string;
country: string;
}
// shared-kernel/product-reference.ts
export interface ProductReference {
sku: string;
name: string;
weight: { value: number; unit: "kg" | "lb" };
}The shared kernel must be small, stable, and jointly maintained. If one team wants to change it unilaterally, the kernel is too large or the relationship is actually customer-supplier, not shared kernel.
Key Takeaways
Bounded contexts are the most impactful DDD pattern because they prevent the domain model corruption that makes large systems unmanageable. Identify boundaries where language changes meaning, not where you think microservice boundaries should be. Use context mapping to make the political relationships between contexts explicit.
Protect your domain model from external systems with anti-corruption layers. Communicate between contexts using domain events rather than direct API calls—events preserve autonomy and enable independent deployment. When two contexts genuinely need shared concepts, use a shared kernel, but keep it minimal and co-owned.
The goal is not to eliminate all coupling—it is to make coupling intentional, explicit, and manageable. A well-drawn context map is worth more than any amount of tactical DDD patterns applied within a single, sprawling context.


