Saltar al contenido
Módulo 6 de 7Avanzado

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.

domain/pedido.rs — una entidad se reconoce por su identidadDominio
/// 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.

domain/dinero.rs — un value object es sus atributosDominio
/// 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ó.

Entidad — identidad estable

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.

Value object — igualdad estructural

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”.

domain/pedido.rs — la raíz protege los invariantesDominio
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
Contexto Menú

Producto

  • existencias
  • mermas
  • un proveedor
Contexto Inventario

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

Práctica

Practica lo aprendido

Tarjetas de repaso

Evaluación del módulo