Bounded Contexts de Domain-Driven Design en la práctica
Estrategias prácticas para identificar, implementar e integrar bounded contexts: context mapping, anti-corruption layers y shared kernels.

Los bounded contexts son el patrón táctico más importante de Domain-Driven Design, y también el más malentendido. Los equipos o dibujan cajas en pizarras que nunca llegan al código, o dividen cada concepto en su propio microservicio y se ahogan en complejidad de integración. El terreno práctico parte del lenguaje que usa la gente y deja que los límites emerjan de conversaciones reales de modelado.
Un bounded context es un límite dentro del cual un modelo particular se define y aplica. La misma palabra —«Order»— significa cosas distintas en ventas, fulfillment y contabilidad. En lugar de forzar un modelo universal de Order, cada contexto define el suyo propio, y las traducciones explícitas ocurren en los límites.
Descubriendo bounded contexts a través del lenguaje
La señal más fuerte de los límites de contexto aparece cuando la misma palabra significa cosas distintas para distintos grupos. Las sesiones de event storming o domain storytelling hacen esto 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;
}Cada modelo es ligero y enfocado. El equipo de catálogo puede evolucionar su CatalogProduct sin tocar preocupaciones de inventario. El equipo de almacén puede reestructurar su InventoryItem sin afectar la tienda. La separación explícita obliga a pensar en cómo fluye la información entre contextos en lugar de asumir que una base de datos compartida mantendrá todo sincronizado.
Patrones de context mapping
Una vez identificados los bounded contexts, hay que definir cómo se relacionan entre sí. El context mapping da un vocabulario para estas relaciones.
// 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,
};
}Implementando límites de contexto en el código
Los bounded contexts no requieren microservicios. En un monolito, los límites de módulo y las reglas de dependencias hacen cumplir la separación de contextos.
// ❌ 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
],
},
],
};Probando la integración de bounded contexts
Los puntos de integración entre contextos necesitan contract tests para asegurar que los eventos y las APIs sigan siendo compatibles a medida que los contextos evolucionan de forma independiente.
// 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);
}
});
});Conclusiones clave
Los bounded contexts se identifican por diferencias de lenguaje: cuando la misma palabra significa cosas distintas para distintos equipos, has encontrado un límite de contexto que debería ser explícito en el código. El patrón anti-corruption layer traduce modelos en los límites del contexto, evitando que los conceptos internos de un contexto se filtren a otro y generen un acoplamiento fuerte. Los shared kernels deben ser pequeños, estables y deliberadamente co-propiedad; si un modelo compartido cambia con frecuencia, los contextos deberían tener modelos separados con traducción explícita. Los bounded contexts no requieren microservicios: hacer cumplir límites de módulo mediante reglas de dependencias e interfaces de API públicas logra aislamiento dentro de un monolito. Los eventos de dominio sirven como published language entre contextos: el contexto productor define el esquema del evento, y cada contexto consumidor lo interpreta a través de su propio modelo de dominio, transformándolo en comandos específicos del contexto. Los contract tests en puntos de integración verifican que los esquemas de eventos y las interfaces de API sigan siendo compatibles a medida que los contextos evolucionan de forma independiente, detectando cambios rupturistas antes de que lleguen a producción.


