Contextos Delimitados
Comprende los contextos delimitados en DDD, el mapeo de contextos, patrones de integración como la capa anticorrupción y el servicio host abierto, y cómo definir los límites de los contextos
Un contexto delimitado es un límite semántico dentro del cual se aplica un modelo de dominio concreto. Dentro del límite, todos los términos del lenguaje ubicuo tienen significados precisos y coherentes. Fuera del límite, pueden aplicarse otros términos u otros significados.
El término «contexto delimitado» se malinterpreta a menudo. No es lo mismo que un microservicio, un módulo o un namespace, aunque a menudo se corresponda con uno. Un contexto delimitado es, ante todo, un límite lingüístico. El código y los servicios se derivan de ese límite.
Por qué importan los límites
Sin límites explícitos, los modelos de dominio se mezclan entre sí. Los términos se vuelven ambiguos, las responsabilidades se difuminan y el sistema se convierte en una «Big Ball of Mud».
Qué contiene un límite
Dentro de un contexto delimitado tienes un modelo completo y autosuficiente:
- Entidades y objetos de valor propios de ese contexto
- Agregados que hacen cumplir los límites de consistencia
- Eventos de dominio que representan ocurrencias
- Repositorios para la persistencia
- Servicios de dominio para operaciones que no encajan de forma natural en las entidades
# Sales Context's complete model — it stands alone
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import Protocol
class SalesOrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
IN_TRANSIT = "in_transit"
DELIVERED = "delivered"
@dataclass
class SalesOrderLine:
product_id: str
product_name: str
quantity: int
unit_price: float
def subtotal(self) -> float:
return self.quantity * self.unit_price
@dataclass
class SalesOrder:
order_id: str
customer_id: str
lines: list[SalesOrderLine] = field(default_factory=list)
status: SalesOrderStatus = SalesOrderStatus.PENDING
created_at: datetime = field(default_factory=datetime.now)
def add_line(self, line: SalesOrderLine) -> None:
if self.status != SalesOrderStatus.PENDING:
raise ValueError("Order already confirmed")
self.lines.append(line)
def confirm(self) -> None:
if not self.lines:
raise ValueError("Cannot confirm empty order")
self.status = SalesOrderStatus.CONFIRMED
class SalesOrderRepository(Protocol):
def save(self, order: SalesOrder) -> None: ...
def find_by_id(self, order_id: str) -> SalesOrder | None: ...Mapeo de contextos
El mapeo de contextos es la práctica de identificar y documentar las relaciones entre contextos delimitados. Cada relación de contextos tiene un patrón que describe cómo se integran los dos contextos.
Patrones de integración
Patrón | Descripción | Acoplamiento | Cuándo usarlo |
|---|---|---|---|
Asociación | Dos contextos cooperan en la integración | Estrecho | Dos equipos que trabajan de cerca |
Kernel compartido | Subconjunto compartido del modelo | Estrecho | Dominios solapados |
Cliente/proveedor | El upstream suministra, el downstream consume | Flojo | Productor/consumidor estándar |
Conformista | El downstream se conforma al modelo del upstream | Flojo | Sin control sobre el upstream |
Capa anticorrupción | Una capa de traducción protege al downstream | Muy flojo | Evitar que los cambios del upstream se filtren |
Servicio host abierto | El upstream proporciona un protocolo de integración | Muy flojo | Múltiples consumidores downstream |
Lenguaje publicado | Formato de integración bien documentado y versionado | Muy flojo | API pública |
Caminos separados | Sin integración; cada contexto opera de forma independiente | Ninguno | Funcionalidad independiente |
Capa anticorrupción (ACL)
La capa anticorrupción es uno de los patrones más importantes. Impide que un contexto downstream se contamine con el modelo de un contexto upstream.
# The upstream system has its own model — we cannot change it
# Upstream's "Order" (legacy system):
class LegacyOrder:
"""This comes from the mainframe. We cannot modify it."""
def __init__(self):
self.ord_no = ""
self.cust_id = ""
self.ord_dt = ""
self.ln_itms: list[LegacyLineItem] = []
self.ord_st = ""
class LegacyLineItem:
def __init__(self):
self.itm_id = ""
self.itm_qty = 0
self.itm_pr = 0.0
self.itm_st = ""
# Anti-Corruption Layer: translates Legacy → Sales Context
class LegacyOrderTranslator:
"""Protects our Sales Context from the legacy model."""
def to_sales_order(self, legacy: LegacyOrder) -> "SalesOrder":
order = SalesOrder(
order_id=legacy.ord_no,
customer_id=legacy.cust_id,
status=self._translate_status(legacy.ord_st),
)
for item in legacy.ln_itms:
order.add_line(SalesOrderLine(
product_id=item.itm_id,
quantity=item.itm_qty,
unit_price=item.itm_pr,
))
return order
def _translate_status(self, legacy_status: str) -> SalesOrderStatus:
mapping = {
"N": SalesOrderStatus.PENDING,
"C": SalesOrderStatus.CONFIRMED,
"S": SalesOrderStatus.IN_TRANSIT,
"D": SalesOrderStatus.DELIVERED,
}
return mapping.get(legacy_status, SalesOrderStatus.PENDING)
# The ACL adapter
class LegacyOrderRepository:
"""Adapter that uses the ACL to translate."""
def __init__(self, legacy_db, translator: LegacyOrderTranslator):
self._db = legacy_db
self._translator = translator
def find_by_id(self, order_id: str) -> "SalesOrder":
legacy_data = self._db.query(f"SELECT * FROM orders WHERE ord_no = '{order_id}'")
legacy_order = self._row_to_legacy(legacy_data)
return self._translator.to_sales_order(legacy_order)
def _row_to_legacy(self, row) -> LegacyOrder:
legacy = LegacyOrder()
legacy.ord_no = row["ord_no"]
legacy.cust_id = row["cust_id"]
legacy.ord_dt = row["ord_dt"]
legacy.ord_st = row["ord_st"]
# ... map line items
return legacySin una ACL, los cambios en el modelo del upstream pueden propagarse en cascada por todo tu sistema. Renombrar un campo en un sistema legacy nunca debería obligar a cambiar tu lógica de dominio. La ACL absorbe esos cambios.
Servicio host abierto (OHS)
El servicio host abierto define un protocolo publicado que los contextos downstream usan para comunicarse con el contexto upstream. El contexto upstream se compromete a mantener ese protocolo.
# Sales Context publishes an Open-Host Service protocol
from typing import Protocol
from dataclasses import dataclass
@dataclass
class OrderCreatedEvent:
order_id: str
customer_id: str
items: list[dict]
total: float
version: int = 1
class SalesOrderNotifier(Protocol):
"""Published protocol for downstream consumers."""
def on_order_created(self, event: OrderCreatedEvent) -> None:
"""Called when a new order is created. Ordered by order_id."""
def on_order_cancelled(self, order_id: str, reason: str) -> None:
"""Called when an order is cancelled."""
# Downstream: Inventory Context implements the protocol
class InventoryOrderHandler:
"""Handles order events for inventory reservation."""
def on_order_created(self, event: OrderCreatedEvent) -> None:
for item in event.items:
self._reserve_stock(item["product_id"], item["quantity"])
def on_order_cancelled(self, order_id: str, reason: str) -> None:
self._release_reservations(order_id)
def _reserve_stock(self, product_id: str, quantity: int) -> None:
print(f"Reserving {quantity} of {product_id}")
def _release_reservations(self, order_id: str) -> None:
print(f"Releasing reservations for {order_id}")Kernel compartido
El kernel compartido es un subconjunto cuidadosamente elegido del modelo de dominio que comparten dos contextos. Debe mantenerse pequeño, estable y acordado por ambos equipos.
# Shared Kernel: both Sales and Shipping use this
from dataclasses import dataclass
@dataclass
class Address:
"""Shared across Sales and Shipping contexts."""
street: str
city: str
state: str
zip_code: str
country: str
def is_valid(self) -> bool:
return all([self.street, self.city, self.zip_code, self.country])
@dataclass
class Money:
"""Shared value object for monetary values."""
amount: float
currency: str
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
# Sales Context uses Address and Money
class SalesOrder:
def __init__(self, order_id: str, shipping_address: Address):
self._id = order_id
self._shipping_address = shipping_address
self._total = Money(0.0, "USD")
# Shipping Context uses the same Address
class Shipment:
def __init__(self, shipment_id: str, destination: Address):
self._id = shipment_id
self._destination = destinationPatrón conformista
Cuando un contexto downstream no tiene control sobre el contexto upstream, puede optar por conformarse al modelo del upstream en lugar de construir una ACL.
# Upstream ERP system defines the product catalog model
# Downstream Sales Context conforms to it
# ERP's Product model (upstream — we cannot change it)
class ERPProduct:
def __init__(self, code: str, desc: str, list_price: float):
self.code = code
self.desc = desc
self.list_price = list_price
# Sales Context conforms — uses the same terms
class SalesProduct:
"""Conforms to ERP model. Same terms, same meaning."""
def __init__(self, code: str, desc: str, list_price: float):
self.code = code
self.desc = desc
self.list_price = list_price
def calculate_discount(self, percentage: float) -> float:
return self.list_price * (1 - percentage / 100)Patrón de asociación
En una asociación, dos equipos coordinan su integración. Los cambios se planifican juntos y ambos equipos ajustan sus modelos para trabajar en conjunto.
# Sales and Billing teams agree on the "Invoice" concept together
# Partnership agreement: both teams define this jointly
@dataclass
class InvoiceData:
"""Jointly agreed data format for invoicing."""
invoice_number: str
order_id: str
customer_tax_id: str
line_items: list[dict]
subtotal: float
tax_total: float
grand_total: float
# Sales Context: produces invoice data
class SalesInvoicingService:
def generate_invoice_data(self, order: "SalesOrder") -> InvoiceData:
return InvoiceData(
invoice_number=f"INV-{order.order_id}",
order_id=order.order_id,
customer_tax_id=order.customer_tax_id,
line_items=[
{"description": line.product_name, "amount": line.subtotal()}
for line in order.lines
],
subtotal=sum(line.subtotal() for line in order.lines),
tax_total=self._calculate_tax(order),
grand_total=order.total,
)
# Billing Context: consumes invoice data
class InvoiceGenerator:
def create_from(self, data: InvoiceData) -> "Invoice":
invoice = Invoice(
number=data.invoice_number,
customer_tax_id=data.customer_tax_id,
)
for item in data.line_items:
invoice.add_line(item["description"], item["amount"])
return invoiceCaminos separados
A veces, dos contextos no tienen nada que ver entre sí. Operan de forma independiente, sin integración. Esto es perfectamente válido.
# Marketing Context and Warehouse Context — Separate Ways
# Marketing Context: campaigns, newsletters, analytics
class EmailCampaign:
def send_promotion(self, segment: str, offer: str) -> None:
print(f"Sending '{offer}' to {segment} segment")
# Warehouse Context: inventory, receiving, shipping
class WarehouseBin:
def __init__(self, bin_id: str, location: str):
self._id = bin_id
self._location = location
def store(self, product_id: str, quantity: int) -> None:
print(f"Storing {quantity} of {product_id} in {self._location}")Definir los límites de los contextos
¿Cómo decides dónde termina un contexto y dónde empieza otro? Estas son las heurísticas más comunes:
Heurística | Pregunta | Límite de ejemplo |
|---|---|---|
Lenguaje distinto | ¿Los expertos usan términos distintos? | «Pedido» en ventas vs. «Orden de trabajo» en manufactura |
Ciclo de vida distinto | ¿El concepto tiene un ciclo de vida distinto? | Registro de cliente vs. ticket de soporte |
Propiedad de equipo | ¿Lo posee otro equipo? | Equipo de checkout vs. equipo de recomendaciones |
Frecuencia de cambio | ¿Cambia a ritmos distintos? | Catálogo de productos (estable) vs. precios (dinámico) |
Independencia de despliegue | ¿Se puede desplegar por separado? | API móvil vs. panel de administración |
# Example: same company, different contexts for "Customer"
# 1. Sales Context — Customer as buyer
class Buyer:
def __init__(self, buyer_id: str, email: str, credit_limit: float):
self._id = buyer_id
self._email = email
self._credit_limit = credit_limit
def place_order(self, order_total: float) -> bool:
return order_total <= self._credit_limit
# 2. Marketing Context — Customer as target
class Lead:
def __init__(self, lead_id: str, email: str, score: int):
self._id = lead_id
self._email = email
self._score = score
def nurture(self, campaign: str) -> None:
print(f"Sending campaign '{campaign}' to lead {self._id}")
# 3. Support Context — Customer as ticket creator
class Requester:
def __init__(self, requester_id: str, name: str, tier: str):
self._id = requester_id
self._name = name
self._tier = tier
@property
def sla_minutes(self) -> int:
return {"standard": 480, "premium": 120, "enterprise": 30}.get(self._tier, 1440)Integrar contextos delimitados
La integración se produce mediante la traducción en el límite. Los mecanismos de integración más comunes:
# Integration via domain events (asynchronous)
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable
# The event
@dataclass
class OrderConfirmed:
order_id: str
confirmed_at: datetime = field(default_factory=datetime.now)
# Simple event bus
class EventBus:
def __init__(self):
self._handlers: dict[type, list[Callable]] = {}
def subscribe(self, event_type: type, handler: Callable) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def publish(self, event: object) -> None:
for handler in self._handlers.get(type(event), []):
handler(event)
# Sales Context publishes
class SalesContext:
def __init__(self, bus: EventBus):
self._bus = bus
def confirm_order(self, order_id: str) -> None:
print(f"[Sales] Confirming order {order_id}")
self._bus.publish(OrderConfirmed(order_id))
# Inventory Context subscribes
class InventoryContext:
def __init__(self, bus: EventBus):
bus.subscribe(OrderConfirmed, self.on_order_confirmed)
def on_order_confirmed(self, event: OrderConfirmed) -> None:
print(f"[Inventory] Reserving stock for order {event.order_id}")El mapa de contextos como documentación viva
Un mapa de contextos debe mantenerse actualizado a medida que evoluciona el sistema. Es un documento vivo que muestra el estado actual de las relaciones entre contextos.
# A context map expressed as code
from dataclasses import dataclass, field
from enum import Enum
class RelationshipType(Enum):
PARTNERSHIP = "partnership"
SHARED_KERNEL = "shared_kernel"
CUSTOMER_SUPPLIER = "customer_supplier"
CONFORMIST = "conformist"
ACL = "anti_corruption_layer"
OHS = "open_host_service"
PUBLISHED_LANGUAGE = "published_language"
SEPARATE_WAYS = "separate_ways"
@dataclass
class ContextRelationship:
upstream: str
downstream: str
pattern: RelationshipType
notes: str = ""
@dataclass
class ContextMap:
contexts: list[str] = field(default_factory=list)
relationships: list[ContextRelationship] = field(default_factory=list)
def add_relationship(
self, upstream: str, downstream: str,
pattern: RelationshipType, notes: str = ""
) -> None:
self.relationships.append(
ContextRelationship(upstream, downstream, pattern, notes)
)
# Define the context map
map = ContextMap(contexts=["Sales", "Inventory", "Shipping", "Billing", "Marketing"])
map.add_relationship("Sales", "Inventory", RelationshipType.CUSTOMER_SUPPLIER,
"Sales publishes OrderConfirmed, Inventory reserves stock")
map.add_relationship("Sales", "Shipping", RelationshipType.CUSTOMER_SUPPLIER,
"Sales publishes OrderShipped")
map.add_relationship("Sales", "Billing", RelationshipType.PARTNERSHIP,
"Jointly define InvoiceData format")
map.add_relationship("Inventory", "Shipping", RelationshipType.SHARED_KERNEL,
"Share Address and Money value objects")
map.add_relationship("Marketing", None, RelationshipType.SEPARATE_WAYS,
"Marketing operates independently")Mantén tu mapa de contextos en el control de versiones junto con tu código. Cuando se incorpore una persona nueva al equipo, el mapa de contextos es lo primero que debería estudiar para entender el panorama del sistema.
Los peligros del contexto monolítico
Evita la tentación de meterlo todo en un único contexto delimitado gigante. Los síntomas de un contexto monolítico incluyen:
Síntoma | Descripción | Solución |
|---|---|---|
Modelo gigante | Cientos de entidades en un solo contexto | Dividir por subdominio |
Cuellos de botella de equipo | Varios equipos tocando el mismo código | Dividir por propiedad de equipo |
Compilaciones lentas | Tiempos largos de compilación y despliegue | Dividir para lograr despliegues independientes |
Términos ambiguos | La misma palabra, distintos significados | Dividir por límite lingüístico |
Conflictos de merge | Conflictos de git frecuentes | Dividir por propiedad |
Un contexto delimitado que contiene más de unos 10 agregados es un candidato claro para dividirse. Si el modelo es tan grande que ninguna persona puede entenderlo por completo, el límite es demasiado amplio.
Ejercicios prácticos
-
Identifica límites: Para un sistema de gestión hospitalaria, enumera al menos 5 contextos delimitados. Para cada uno, describe el lenguaje ubicuo y qué entidades contiene.
-
Mapeo de contextos: Dibuja un mapa de contextos (en texto) para un marketplace en línea. Incluye al menos 6 contextos y etiqueta los patrones de relación entre ellos.
-
Construye una ACL: Un sistema CRM legacy tiene un modelo
Customercon los camposcust_id,cust_name,cust_email,cust_stat(A=activo, I=inactivo). Construye una capa anticorrupción que traduzca esto a un modeloAccountmoderno del contexto de ventas, con nombres y tipos de campos apropiados. -
Contrato de asociación: Dos equipos, Pagos y Detección de fraude, necesitan integrarse. Define el protocolo compartido (como interfaces de Python) en el que ambos equipos convienen.
-
Decisión de límite de contexto: Una empresa tiene «Catálogo de productos» y «Estrategia de precios». Actualmente forman un solo contexto. Enumera 3 razones por las que deberían dividirse y describe el patrón de integración que deberían usar.
-
Diseño de kernel compartido: Identifica un concepto que pueda compartirse entre los contextos de ventas y de envíos de un sistema de comercio electrónico. Diseña el objeto de valor o la entidad compartida con al menos 3 campos y 2 métodos.
-
Justificación de caminos separados: Describe un escenario en el que dos contextos de la misma empresa deban operar en modo «Caminos separados». Explica por qué la integración sería perjudicial.
-
Refactorización de un contexto monolítico: La siguiente clase proviene de un contexto monolítico. Divídela en al menos 3 contextos delimitados y muestra cómo se comunican entre sí:
pythonclass OrderManager: def create_order(self, customer, items): ... def process_payment(self, order, card_info): ... def reserve_inventory(self, order): ... def schedule_shipment(self, order): ... def generate_invoice(self, order): ... def send_confirmation_email(self, order): ... def update_loyalty_points(self, customer, order): ...
Has completado la Lección 3. Los contextos delimitados son la columna vertebral estratégica de DDD. Definen dónde viven tus modelos y cómo se relacionan entre sí. Comprender los patrones de mapeo de contextos es esencial para diseñar sistemas que puedan evolucionar de forma independiente.