DDD dentro del hexágono: entidades, agregados y fronteras
El patrón te dice dónde vive la lógica de negocio; el Domain-Driven Design te dice con qué construirla: entidades con identidad, value objects inmutables, agregados que protegen invariantes y las fronteras que mantienen honesto el modelo.
Cinco módulos llevamos hablando de la frontera: puertos, adaptadores, actores, el composition root, los test doubles. Pero la arquitectura hexagonal tiene un silencio deliberado: te dice dónde vive la lógica de negocio (dentro, aislada de la tecnología) y no te dice cómo modelarla. Ese vacío lo llena el Domain-Driven Design de Eric Evans (2003), cuyo vocabulario táctico —entidad, value object, agregado— es el material de construcción natural del interior del hexágono. Este módulo puebla, por fin, el centro del dibujo.
Entidad: lo que sigue siendo lo mismo cuando todo cambia
Vuelve a la cafetería de los módulos anteriores. El pedido de la mesa 7 nace vacío, acumula dos capuchinos y un croissant, se confirma, se paga y se entrega. Casi todo su estado interno cambió en ese recorrido y, sin embargo, nunca dudaste de que era el mismo pedido. Eso es una entidad: un objeto con identidad y ciclo de vida propios, que puede mutar conservando quién es. Dos entidades no son iguales por parecerse, sino por compartir identidad.
/// Identidad del pedido: es lo único que lo hace "ser él".
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PedidoId(Uuid);
pub struct Pedido {
id: PedidoId,
lineas: Vec<LineaDePedido>,
estado: EstadoPedido,
entrega: Option<Direccion>,
}
impl Pedido {
/// Dos pedidos son el mismo si comparten id, aunque todo su
/// estado interno difiera en este instante.
pub fn id(&self) -> &PedidoId {
&self.id
}
}Value object: lo que es idéntico a sus atributos
Ahora mira el total del pedido: $85.00 MXN. ¿Tiene sentido preguntarse si este billete de $85 es el mismo que aquel de $85? No: el dinero es puro valor. Un value object no tiene identidad conceptual; está definido enteramente por sus atributos, y por eso conviene hacerlo inmutable: nunca “cambias” un importe, construyes otro. Martin Fowler pone como ejemplos canónicos los puntos, los importes de dinero y los rangos de fechas. La dirección de entrega funciona igual: dos direcciones con los mismos campos son intercambiables; si la clienta se muda, no editas la dirección — la reemplazas completa.
/// Sin identidad, inmutable, comparable campo a campo. En Rust,
/// `derive` hace el trabajo: la igualdad estructural es gratis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dinero {
centavos: u64,
}
impl Dinero {
pub fn mxn(centavos: u64) -> Self {
Self { centavos }
}
/// Toda "modificación" devuelve un valor nuevo.
pub fn mas(self, otro: Dinero) -> Dinero {
Dinero { centavos: self.centavos + otro.centavos }
}
}La prueba rápida cuando dudes: si dos instancias con los mismos datos son intercambiables sin que a nadie le importe, es un value object; si necesitas rastrear “cuál de las dos” a través del tiempo, es una entidad.
Consolida el contraste antes de subir de nivel: a la izquierda, una identidad que sobrevive a los cambios; a la derecha, cambia tú el campo y mira qué le pasa a la igualdad.
El pedido de la mesa 7
Abierto → Confirmado → Entregado: casi todo su estado cambió en el
recorrido y sigue siendo el mismo pedido, porque su id nunca se movió.
Dinero
Sin alterar:Dinero { centavos: 8500 } =Dinero { centavos: 8500 }. Mismos atributos: son el mismo valor, intercambiables sin que a nadie le importe.
Con un campo cambiado:Dinero { centavos: 8500 } ≠Dinero { centavos: 8600 }. Cambiar un atributo no modificó el valor: creó otro distinto.
El agregado y su raíz: una sola puerta de entrada
Un pedido no viaja solo: lleva dentro sus líneas, su estado y su dirección. Martin Fowler lo define así:
“A DDD aggregate is a cluster of domain objects that can be treated as a single unit.”
Un agregado es ese racimo de objetos que se cargan, se validan y se guardan como una sola unidad. Dentro del racimo hay una entidad distinguida, la raíz del agregado (aggregate root), que es la única puerta de entrada desde el exterior:
“Any references from outside the aggregate should only go to the aggregate root.”
Simula los dos intentos de referencia sobre el agregado del pedido y comprueba la regla en acción:
Referencia vía Pedido:permitida — Todo acceso externo pasa por Pedido: es la única puerta.
Referencia directa a LineaDePedido:bloqueada — Nadie de fuera referencia LineaDePedido directamente — no existe un LineaDePedidoRepository.
En nuestro modelo, Pedido es la raíz; LineaDePedido es una entidad
interna con identidad local (puedes cambiarle la cantidad y sigue
siendo la misma línea), y Dinero y Direccion son value objects que
viajan dentro. ¿Para qué
tanta ceremonia? Para proteger invariantes: reglas que deben ser
verdaderas al final de cada operación, como “no se modifica un pedido
cerrado” o “el total es exactamente la suma de sus líneas”.
impl Pedido {
/// Única puerta para tocar las líneas: nadie de fuera muta
/// el `Vec<LineaDePedido>` directamente.
pub fn agregar_linea(
&mut self,
producto: ProductoId,
cantidad: u32,
precio_unitario: Dinero,
) -> Result<(), PedidoError> {
if self.estado != EstadoPedido::Abierto {
return Err(PedidoError::PedidoCerrado);
}
if cantidad == 0 {
return Err(PedidoError::CantidadInvalida);
}
self.lineas
.push(LineaDePedido::nueva(producto, cantidad, precio_unitario));
Ok(())
}
/// El invariante "total = suma de líneas" no puede romperse
/// porque solo la raíz tiene acceso mutable a las líneas.
pub fn total(&self) -> Dinero {
self.lineas
.iter()
.map(LineaDePedido::subtotal)
.fold(Dinero::mxn(0), Dinero::mas)
}
}Este diseño conecta directo con los puertos que ya conoces: los
repositorios se definen por raíz de agregado. Existe un
PedidoRepository que guarda y recupera pedidos completos; no existe
—ni debe existir— un LineaDePedidoRepository, porque una línea suelta,
fuera de su pedido, no significa nada.
Las transacciones no cruzan agregados
La regla más citada —y más incómoda— de los agregados también viene de la definición que resume Fowler:
“Transactions should not cross aggregate boundaries.”
El agregado es la frontera de consistencia: todo lo que debe ser
consistente ahora mismo vive dentro de un mismo agregado, y una
transacción carga y guarda exactamente uno. En la cafetería, Pedido
e Inventario son agregados distintos: confirmar un pedido no
descuenta granos de café en la misma transacción. El pedido se confirma,
emite un evento (PedidoConfirmado) y el inventario se actualiza
después, en su propia transacción. Esa consistencia eventual no es un
defecto: es el precio explícito de tener fronteras claras. Y la señal
de alarma funciona en ambos sentidos: si sientes la necesidad de
guardar dos agregados en una misma transacción, probablemente trazaste
mal la frontera de uno de los dos.
Bounded context: donde cambia el idioma, cambia el modelo
Subamos un nivel: ¿qué tan grande debe ser el modelo entero? La respuesta estratégica de DDD es el bounded context (contexto delimitado). Fowler lo justifica sin rodeos:
“Total unification of the domain model for a large system will not be feasible or cost-effective.”
Y da el criterio para cortar: “you need a different model when the language changes”. En la cafetería, la palabra “producto” no significa lo mismo en todas partes — compárala en dos contextos:
Producto
- un nombre
- un precio
- una receta
Producto
- existencias
- mermas
- un proveedor
Para Contabilidad, el tercero en discordia, es una línea fiscal con
impuestos. Forzar una sola clase Producto que sirva a los tres es
fabricar un monstruo. Se modelan tres contextos, cada uno con su
Producto propio y legítimo.
La conexión con este curso es directa: un hexágono normalmente coincide con un bounded context. El núcleo que llevas cinco módulos protegiendo es un modelo, válido dentro de una frontera lingüística. Dos contextos delimitados son dos hexágonos que conversan por sus puertos, no un hexágono con dos cerebros.
Anti-corruption layer: el traductor en la aduana
¿Y cuando el exterior no es una base de datos dócil sino otro sistema con su propio modelo — uno legado, o de un tercero? Para eso Evans propuso la anti-corruption layer (ACL). La descripción operativa del patrón, en el catálogo de Microsoft:
“Implement a facade or adapter layer between different subsystems that don’t share the same semantics. This layer translates requests that one subsystem makes to the other subsystem.”
Imagina que la cafetería se integra con una plataforma de reparto cuyo
API modela los pedidos como “items” con “modifiers” anidados e importes
en cadenas de texto. Un adaptador ingenuo dejaría que ese vocabulario se
filtrara al dominio. El adaptador con vocación de ACL traduce modelo y
semántica, no solo protocolo: recibe el formato ajeno y entrega
Pedido, Dinero, Direccion — tu idioma, tus invariantes. Es el
mismo patrón de puertos y adaptadores del módulo 2, elevado al caso más
hostil: el exterior también tiene opiniones.
Practica: ¿entidad o value object?
Clasifica cada pieza. La pregunta de oro: ¿necesitas rastrear cuál es a través del tiempo (entidad), o dos copias iguales son intercambiables (value object)?
El puente: así se ve en un sistema real
Todo lo que modelamos aquí con capuchinos existe, con las mismas
formas, en software de producción. En el módulo final vas a leer
fragmentos reales de un sistema de firma electrónica y evidencia
digital escrito en Rust: value objects como un digest SHA-256 o un
sobre cifrado que cruzan puertos sin exponer ninguna biblioteca
concreta, entidades con identidad y versión, y agregados cuyas reglas
—quién puede firmar, qué se borra de memoria y cuándo— se protegen
exactamente como Pedido protege sus líneas. Además verás lo que pasa
entre las capas: errores que se traducen en cada frontera, tests que
vigilan la arquitectura y decisiones documentadas por escrito. La
cafetería fue el ensayo; lo que sigue es el escenario.
Fuentes
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — origen de los términos entidad, value object, agregado, bounded context y anti-corruption layer.
- Martin Fowler, DDD_Aggregate (bliki): martinfowler.com/bliki/DDD_Aggregate.html
- Martin Fowler, BoundedContext (bliki): martinfowler.com/bliki/BoundedContext.html
- Martin Fowler, ValueObject (bliki): martinfowler.com/bliki/ValueObject.html
- Microsoft Learn, Anti-corruption Layer pattern (Azure Architecture Center): learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer