Domain-Driven Design Bounded Contexts in Practice
Practical strategies for identifying, implementing and integrating bounded contexts: context mapping, anti-corruption layers and shared kernels.

Bounded contexts are the most important tactical pattern in domain-driven design, yet they're the most misunderstood. Teams either draw boxes on whiteboards that never influence the code, or they split every concept into its own microservice and drown in integration complexity. The practical middle ground starts from the language people use and lets the boundaries emerge from real modeling conversations.
A bounded context is a boundary within which a particular model is defined and applicable. The same word—"Order"—means different things in sales, fulfillment, and accounting. Rather than forcing one universal Order model, each context defines its own, and explicit translations happen at the boundaries.
Discovering Bounded Contexts Through Language
The strongest signal for context boundaries is when the same word means different things to different groups. Event storming or domain storytelling sessions make this visible.
// ❌ One "Product" model serving all contexts
interface Product {
id: string;
name: string;
sku: string;
price: number;
weight: number;
dimensions: Dimensions;
stockLevel: number;
reorderPoint: number;
description: string;
seoTitle: string;
images: string[];
supplier: string;
costPrice: number;
taxCategory: string;
// 30 more fields — every team adds their concerns
}
// The catalog team doesn't care about reorderPoint
// The warehouse team doesn't care about seoTitle
// Changes for one team's needs affect every other team// ✅ Each context defines "Product" in its own terms
// Catalog Context — what customers see
interface CatalogProduct {
id: string;
name: string;
description: string;
seoTitle: string;
images: string[];
price: Money;
availability: "in-stock" | "low-stock" | "out-of-stock";
}
// Inventory Context — what the warehouse tracks
interface InventoryItem {
sku: string;
stockLevel: number;
reorderPoint: number;
location: WarehouseLocation;
weight: Weight;
dimensions: Dimensions;
}
// Procurement Context — what purchasing manages
interface ProcurableGood {
sku: string;
supplier: SupplierId;
costPrice: Money;
leadTimeDays: number;
minimumOrderQuantity: number;
}Each model is lean and focused. The catalog team can evolve their CatalogProduct without touching inventory concerns. The warehouse team can restructure InventoryItem without affecting the storefront. The explicit separation forces you to think about how information flows between contexts rather than assuming a shared database will keep everything in sync.
Context Mapping Patterns
Once you've identified bounded contexts, you need to define how they relate to each other. Context mapping gives you a vocabulary for these relationships.
// Anti-Corruption Layer: translate external models to your own
// Used when integrating with a context you don't control
class CatalogAntiCorruptionLayer {
// Translate from Inventory context's model to Catalog's
translateAvailability(
inventoryItem: {
sku: string;
stockLevel: number;
reservedQuantity: number;
}
): "in-stock" | "low-stock" | "out-of-stock" {
const available =
inventoryItem.stockLevel -
inventoryItem.reservedQuantity;
if (available <= 0) return "out-of-stock";
if (available < 10) return "low-stock";
return "in-stock";
}
// Don't leak inventory's internal representation
// into catalog's domain
translateProduct(
externalProduct: Record<string, unknown>
): Partial<CatalogProduct> {
return {
availability: this.translateAvailability(
externalProduct as any
),
// Only extract what this context needs
};
}
}
// Shared Kernel: deliberately shared model between
// two closely-related contexts
// Used when two teams co-own a small, stable model
// shared-kernel/money.ts — owned by both Catalog
// and Pricing contexts
interface Money {
amount: number;
currency: Currency;
}
type Currency = "USD" | "EUR" | "GBP";
function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error("Cannot add different currencies");
}
return { amount: a.amount + b.amount, currency: a.currency };
}// Published Language: a well-documented schema for
// inter-context communication
// Used for events that multiple contexts consume
// Domain events as the published language
interface OrderPlacedEvent {
type: "order.placed";
version: "2.0";
data: {
orderId: string;
customerId: string;
items: Array<{
sku: string;
quantity: number;
unitPrice: { amount: number; currency: string };
}>;
placedAt: string; // ISO 8601
};
}
// Each consuming context interprets the event
// through its own lens
// Inventory context: reserves stock
function handleOrderPlaced_Inventory(
event: OrderPlacedEvent
): ReservationCommand[] {
return event.data.items.map((item) => ({
type: "reserve-stock",
sku: item.sku,
quantity: item.quantity,
orderId: event.data.orderId,
}));
}
// Accounting context: creates receivable
function handleOrderPlaced_Accounting(
event: OrderPlacedEvent
): AccountingEntry {
const total = event.data.items.reduce(
(sum, item) =>
sum + item.unitPrice.amount * item.quantity,
0
);
return {
type: "accounts-receivable",
customerId: event.data.customerId,
amount: total,
currency: event.data.items[0]?.unitPrice.currency ?? "USD",
reference: event.data.orderId,
};
}Implementing Context Boundaries in Code
Bounded contexts don't require microservices. In a monolith, module boundaries and dependency rules enforce context separation.
// ❌ Contexts sharing database tables and imports
// catalog/ProductService.ts
import { InventoryRepository } from "../inventory/repo";
import { PricingEngine } from "../pricing/engine";
// Direct coupling between contexts
// ✅ Contexts communicate through defined interfaces
// Each context exposes a public API module
// inventory/public-api.ts
export interface InventoryQueryService {
getAvailability(
skus: string[]
): Promise<Map<string, AvailabilityStatus>>;
}
// catalog/dependencies.ts
// Catalog depends on the interface, not the implementation
export interface CatalogDependencies {
inventory: InventoryQueryService;
}
// catalog/CatalogService.ts
class CatalogService {
constructor(private deps: CatalogDependencies) {}
async getProductWithAvailability(
productId: string
): Promise<CatalogProduct> {
const product = await this.productRepo.findById(
productId
);
const availability =
await this.deps.inventory.getAvailability([
product.sku,
]);
return {
...product,
availability:
availability.get(product.sku) ?? "out-of-stock",
};
}
}// Module structure enforcing boundaries
// src/
// contexts/
// catalog/
// public-api.ts ← Only this is importable
// internal/ ← Module-private
// CatalogService.ts
// ProductRepository.ts
// models.ts
// inventory/
// public-api.ts
// internal/
// InventoryService.ts
// StockRepository.ts
// models.ts
// shared-kernel/
// money.ts
// types.ts
// ESLint rule to enforce boundaries
// eslint-plugin-boundaries configuration
const boundaryRules = {
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
{
from: "catalog",
allow: ["shared-kernel", "catalog"],
},
{
from: "inventory",
allow: ["shared-kernel", "inventory"],
},
// Contexts can only import from shared-kernel
// and their own internals
],
},
],
};Testing Bounded Context Integration
Integration points between contexts need contract tests to ensure events and APIs remain compatible as contexts evolve independently.
// Contract test: verify event schema compatibility
import Ajv from "ajv";
const orderPlacedSchema = {
type: "object",
required: ["type", "version", "data"],
properties: {
type: { const: "order.placed" },
version: { const: "2.0" },
data: {
type: "object",
required: ["orderId", "customerId", "items"],
properties: {
orderId: { type: "string" },
customerId: { type: "string" },
items: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["sku", "quantity", "unitPrice"],
},
},
},
},
},
};
describe("Order context event contracts", () => {
const ajv = new Ajv();
it("OrderPlacedEvent matches published schema", () => {
const event: OrderPlacedEvent = createTestOrderPlacedEvent();
const validate = ajv.compile(orderPlacedSchema);
expect(validate(event)).toBe(true);
});
});
describe("Inventory context event consumption", () => {
it("handles OrderPlacedEvent v2.0", () => {
const event = createTestOrderPlacedEvent();
const commands = handleOrderPlaced_Inventory(event);
expect(commands).toHaveLength(event.data.items.length);
for (const cmd of commands) {
expect(cmd.type).toBe("reserve-stock");
expect(cmd.sku).toBeTruthy();
expect(cmd.quantity).toBeGreaterThan(0);
}
});
});Key Takeaways
Bounded contexts are identified by language differences—when the same word means different things to different teams, you've found a context boundary that should be explicit in the code. The anti-corruption layer pattern translates models at context boundaries, preventing one context's internal concepts from leaking into another and creating tight coupling. Shared kernels should be small, stable, and deliberately co-owned; if a shared model changes frequently, the contexts should own separate models with explicit translation instead. Bounded contexts don't require microservices—module boundary enforcement via dependency rules and public API interfaces achieves isolation within a monolith. Domain events serve as the published language between contexts: the producing context defines the event schema, and each consuming context interprets it through its own domain model, transforming the event into context-specific commands. Contract tests at integration points verify that event schemas and API interfaces remain compatible as contexts evolve independently, catching breaking changes before they reach production.


