Saltar al contenido

Domain-Driven Design: Bounded Contexts en la práctica

Aplica bounded contexts a codebases reales: límites claros entre subdominios, context mapping entre equipos y nunca compartir modelos entre servicios.

5 min de lectura
Diagrama de context map que muestra los bounded contexts de pedidos, envíos y facturación con los tipos de relación marcados entre ellos

Domain-Driven Design suena académico hasta que te topas con el problema que resuelve: tu modelo "User" tiene 47 campos porque cada equipo le añade sus propias necesidades. El equipo de facturación necesita datos del método de pago, el de envíos necesita el historial de direcciones, el de marketing necesita métricas de engagement y el de producto necesita feature flags. Los cambios en este modelo compartido se propagan por toda la codebase, y nadie puede modificarlo sin coordinarse con otros tres equipos.

Bounded contexts resuelven esto aceptando que distintas partes del sistema necesitan modelos diferentes del mismo concepto del mundo real. Un "Customer" en facturación es distinto de un "Customer" en envíos. Comparten identidad, pero tienen atributos, comportamientos e invariantes completamente diferentes. Intentar unificarlos en un solo modelo crea acoplamiento que ralentiza a todos los equipos.

Qué es en realidad un bounded context

Un bounded context es un límite dentro del cual un modelo de dominio concreto se define y es aplicable. Dentro del límite, cada término tiene un significado preciso e inequívoco. Fuera del límite, el mismo término puede significar algo distinto.

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[];
}

Cómo identificar los límites de un bounded context

Encontrar los límites adecuados requiere entender el dominio de negocio, no la arquitectura técnica. Dos heurísticas que funcionan: sigue la estructura de equipos de la organización y fíjate en las palabras que significan cosas distintas para personas distintas.

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: cómo se relacionan los bounded contexts

Los bounded contexts no viven aislados. Necesitan intercambiar información. El context map define cómo se relacionan los contextos y cómo fluyen los datos entre ellos.

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;
}

Cómo implementar los límites en el código

La estructura física del código debe reflejar los límites de los bounded contexts. Esto puede funcionar tanto en un monolito como entre servicios.

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);
  }
}

Comunicación entre contextos

Los bounded contexts se comunican mediante domain events, llamadas a API o bases de datos compartidas (último recurso). Los eventos son el enfoque más desacoplado.

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);
  }
}

Conclusiones clave

Un bounded context es un límite dentro del cual un modelo de dominio tiene un significado preciso e inequívoco: el "Customer" de facturación (con métodos de pago y facturas) es un modelo distinto del "Customer" de envíos (con direcciones y preferencias de transportista), e intentar unificarlos en un solo modelo crea acoplamiento que ralentiza a todos los equipos. Encuentra los límites fijándote en palabras que significan cosas distintas para distintos equipos, siguiendo las líneas de propiedad de los equipos y agrupando lo que cambia por la misma razón: estas señales lingüísticas y organizativas revelan límites naturales del dominio de forma más fiable que el análisis técnico. Usa anti-corruption layers para traducir entre contextos y que el modelo de cada contexto se mantenga limpio: cuando el contexto de envíos recibe un evento de pedido de ventas, traduce los datos a sus propios objetos de dominio mediante una capa de transformación explícita en lugar de depender directamente del modelo del contexto de ventas. Comunícate entre contextos mediante domain events para obtener el máximo desacoplamiento: el contexto de ventas publica "order placed" y facturación y envíos reaccionan de forma independiente, interpretando cada uno el evento a través de su propia lente de dominio sin saber ni preocuparse por la existencia del otro.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX