Una vez trazadas las fronteras estratégicas con los Bounded Contexts, necesitamos construir el modelo de dominio dentro de cada contexto. El diseño táctico de DDD nos proporciona un conjunto de patrones de implementación —Value Objects, Entidades, Agregados, Repositorios, Servicios de Dominio y Eventos de Dominio— que nos permiten plasmar las reglas del negocio en código robusto y expresivo. Esta lección es la más práctica del módulo: trabajaremos con ejemplos en Java que verás reflejados en proyectos reales. Dominar estos patrones es lo que diferencia un modelo anémico (datos sin reglas) de un modelo rico que protege la integridad del negocio.

Contenido

  1. Value Objects (objetos de valor)
  2. Entidades
  3. Agregados y raíz de agregado
  4. Repositorios
  5. Servicios de dominio
  6. Eventos de dominio

  1. Value Objects (objetos de valor)

Un Value Object es un objeto que se define por sus atributos, no por una identidad. Dos Value Objects con los mismos valores son intercambiables. Son inmutables: una vez creados, no cambian.

Ejemplos típicos: una cantidad de dinero, una fecha, una dirección, un rango de fechas.

public final class Dinero {
    private final BigDecimal importe;
    private final String moneda;

    public Dinero(BigDecimal importe, String moneda) {
        if (importe == null || moneda == null) {
            throw new IllegalArgumentException("Importe y moneda son obligatorios");
        }
        this.importe = importe;
        this.moneda = moneda;
    }

    public Dinero sumar(Dinero otro) {
        if (!this.moneda.equals(otro.moneda)) {
            throw new IllegalArgumentException("No se pueden sumar monedas distintas");
        }
        return new Dinero(this.importe.add(otro.importe), this.moneda);  // devuelve NUEVO objeto
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Dinero)) return false;
        Dinero d = (Dinero) o;
        return importe.compareTo(d.importe) == 0 && moneda.equals(d.moneda);
    }

    @Override
    public int hashCode() { return Objects.hash(importe, moneda); }
}

Puntos clave de este Dinero:

  • La clase es final y todos sus campos son final: es inmutable, no hay setters.
  • El método sumar no modifica el objeto actual; devuelve un nuevo Dinero. Esta es la esencia de la inmutabilidad.
  • Se incluye una regla de negocio (no sumar monedas distintas) dentro del propio objeto.
  • Se sobrescriben equals y hashCode basándose en los valores: dos Dinero de 10 EUR son iguales aunque sean instancias distintas. Esto es lo que define a un Value Object.

Ventaja práctica: usar Dinero en lugar de un BigDecimal suelto evita errores como sumar euros con dólares o pasar el importe sin la moneda.

  1. Entidades

Una Entidad es un objeto que tiene una identidad única y continua en el tiempo, independiente de sus atributos. Aunque cambien todos sus datos, sigue siendo la misma entidad. Una Poliza, un Asegurado o un Siniestro son entidades: identificadas por su número/ID, no por sus atributos.

public class Siniestro {
    private final IdSiniestro id;     // identidad: nunca cambia
    private EstadoSiniestro estado;   // los atributos pueden cambiar
    private Dinero importeReclamado;

    public Siniestro(IdSiniestro id, Dinero importeReclamado) {
        this.id = Objects.requireNonNull(id);
        this.importeReclamado = Objects.requireNonNull(importeReclamado);
        this.estado = EstadoSiniestro.ABIERTO;
    }

    public void cerrar() {
        if (estado == EstadoSiniestro.CERRADO) {
            throw new IllegalStateException("El siniestro ya está cerrado");
        }
        this.estado = EstadoSiniestro.CERRADO;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Siniestro)) return false;
        return this.id.equals(((Siniestro) o).id);   // igualdad por IDENTIDAD
    }

    @Override
    public int hashCode() { return id.hashCode(); }
}

Diferencias con el Value Object:

  • La identidad id es final y define la entidad. Aunque cambien estado e importeReclamado, sigue siendo el mismo siniestro.
  • equals y hashCode se basan solo en el id, no en todos los atributos. Dos siniestros con el mismo id son el mismo, aunque difieran en estado.
  • La entidad puede mutar (cerrar() cambia el estado), pero siempre a través de métodos que protegen las reglas.
Aspecto Value Object Entidad
Identidad No tiene; se define por sus valores Tiene un ID propio y estable
Mutabilidad Inmutable Mutable (de forma controlada)
Igualdad Por valores (equals sobre atributos) Por identidad (equals sobre el ID)
Ejemplos Dinero, Fecha, Dirección Póliza, Siniestro, Asegurado

  1. Agregados y raíz de agregado

Un Agregado es un grupo de entidades y Value Objects que se tratan como una unidad de consistencia. Tiene una entidad principal llamada raíz del agregado (Aggregate Root), que es la única puerta de entrada al agregado: todo acceso desde fuera pasa por ella.

Reglas fundamentales de los agregados:

  • Solo se referencia la raíz desde fuera. Los objetos internos no se exponen directamente.
  • La raíz protege las invariantes (reglas que siempre deben cumplirse) de todo el agregado.
  • Un agregado se guarda y se carga completo, como una unidad transaccional.
  • Entre agregados, las referencias se hacen por identidad (ID), no por objeto.
// Poliza es la RAÍZ del agregado; Cobertura es una entidad interna
public class Poliza {
    private final IdPoliza id;
    private final List<Cobertura> coberturas = new ArrayList<>();
    private Dinero primaTotal;

    // El acceso a las coberturas pasa SIEMPRE por la raíz
    public void anadirCobertura(TipoCobertura tipo, Dinero capital) {
        if (coberturas.size() >= 10) {
            throw new IllegalStateException("Máximo 10 coberturas por póliza");
        }
        Cobertura c = new Cobertura(tipo, capital);
        coberturas.add(c);
        recalcularPrima();          // la raíz mantiene la INVARIANTE de coherencia
    }

    private void recalcularPrima() {
        Dinero total = new Dinero(BigDecimal.ZERO, "EUR");
        for (Cobertura c : coberturas) {
            total = total.sumar(c.calcularPrima());
        }
        this.primaTotal = total;
    }

    // Se expone una copia de solo lectura, nunca la lista interna mutable
    public List<Cobertura> getCoberturas() {
        return Collections.unmodifiableList(coberturas);
    }
}

Análisis detallado:

  • Poliza es la raíz. Cobertura es una entidad interna que no se manipula directamente desde fuera.
  • Para añadir una cobertura, se usa anadirCobertura() en la raíz, que aplica la invariante "máximo 10 coberturas" y recalcula la prima. Si dejáramos modificar la lista por fuera, esa regla podría romperse.
  • recalcularPrima() garantiza que la prima total siempre sea coherente con las coberturas: esa es una invariante del agregado.
  • getCoberturas() devuelve una lista inmodificable: el exterior puede leer, pero no alterar el estado interno saltándose las reglas.
graph TD
    subgraph Agregado_Poliza[Agregado: Poliza]
        R[Poliza - RAIZ] --> C1[Cobertura 1]
        R --> C2[Cobertura 2]
        R --> PT[primaTotal - Value Object]
    end
    EXT[Codigo externo] -->|solo accede a la raiz| R

Este diagrama muestra que el código externo solo "ve" la raíz Poliza; las coberturas y la prima son internas y protegidas.

Regla de oro: una transacción modifica un solo agregado. Si necesitas cambiar varios, normalmente es señal de que debes coordinarlos con eventos de dominio (apartado 6), no en la misma transacción.

  1. Repositorios

Un Repositorio proporciona la ilusión de una colección en memoria de raíces de agregado, ocultando los detalles de persistencia (base de datos, etc.). Hay un repositorio por agregado (por su raíz), no por cada entidad interna.

// La INTERFAZ vive en la capa de dominio: habla de Poliza, no de SQL
public interface RepositorioPolizas {
    Optional<Poliza> buscarPorId(IdPoliza id);
    void guardar(Poliza poliza);          // guarda el agregado COMPLETO
    List<Poliza> buscarVigentesDe(IdAsegurado asegurado);
}

Comentarios sobre la interfaz:

  • Vive en el dominio y usa exclusivamente conceptos del dominio (Poliza, IdPoliza). No menciona JDBC, JPA ni SQL: el dominio no debe saber cómo se persiste.
  • guardar(Poliza) persiste el agregado completo (la póliza con sus coberturas), respetando que el agregado es la unidad de consistencia.
  • Solo expone operaciones con sentido en el dominio (buscar pólizas vigentes de un asegurado).
// La IMPLEMENTACIÓN vive en la capa de infraestructura
@Repository
public class RepositorioPolizasJpa implements RepositorioPolizas {
    private final EntityManager em;

    public RepositorioPolizasJpa(EntityManager em) { this.em = em; }

    @Override
    public Optional<Poliza> buscarPorId(IdPoliza id) {
        return Optional.ofNullable(em.find(Poliza.class, id.valor()));
    }

    @Override
    public void guardar(Poliza poliza) { em.merge(poliza); }

    @Override
    public List<Poliza> buscarVigentesDe(IdAsegurado asegurado) {
        return em.createQuery(
                "SELECT p FROM Poliza p WHERE p.asegurado = :a AND p.vigente = true",
                Poliza.class)
            .setParameter("a", asegurado.valor())
            .getResultList();
    }
}

Lo importante de esta implementación:

  • Implementa la interfaz del dominio pero contiene los detalles técnicos (JPA, EntityManager, JPQL). Esta separación es lo que permite cambiar la base de datos sin tocar el dominio.
  • Es una aplicación directa del principio de inversión de dependencias: el dominio define la interfaz, la infraestructura la implementa.

  1. Servicios de dominio

A veces una operación del negocio no pertenece de forma natural a ninguna entidad ni Value Object. Cuando una lógica importante del dominio involucra a varios agregados o conceptos, se modela como un Servicio de Dominio: un objeto sin estado que expone una operación con nombre del negocio.

public class ServicioTarificacion {

    private final TablaActuarial tabla;

    public ServicioTarificacion(TablaActuarial tabla) { this.tabla = tabla; }

    // Operación del dominio que no encaja en una sola entidad
    public Dinero calcularPrima(PerfilRiesgo perfil, TipoCobertura cobertura) {
        BigDecimal factor = tabla.factorPara(perfil.edad(), perfil.profesion());
        BigDecimal base = cobertura.capitalAsegurado().importe();
        return new Dinero(base.multiply(factor), "EUR");
    }
}

Por qué esto es un Servicio de Dominio y no un método de una entidad:

  • Calcular la prima combina un PerfilRiesgo, una TablaActuarial y un TipoCobertura. No "pertenece" a ninguno de ellos en exclusiva.
  • El servicio no tiene estado propio: solo orquesta el cálculo con los datos que recibe.
  • Su nombre, calcularPrima, es un verbo del Lenguaje Ubicuo. No es un "Helper" ni un "Utils" genérico.

Cuidado: no abuses de los servicios de dominio. Si toda la lógica acaba en servicios y las entidades quedan vacías, has vuelto al modelo anémico. Usa servicios solo cuando la operación realmente no cabe en una entidad.

  1. Eventos de dominio

Un Evento de Dominio representa algo relevante que ha ocurrido en el dominio y que a otros les puede interesar. Se nombra en pasado: PolizaSuscrita, SiniestroCerrado. Permiten que distintos agregados (incluso de distintos Bounded Contexts) reaccionen sin acoplarse directamente.

// El evento es inmutable y describe un hecho consumado del pasado
public final class PolizaSuscrita {
    private final IdPoliza idPoliza;
    private final IdAsegurado idAsegurado;
    private final Instant fecha;

    public PolizaSuscrita(IdPoliza idPoliza, IdAsegurado idAsegurado) {
        this.idPoliza = idPoliza;
        this.idAsegurado = idAsegurado;
        this.fecha = Instant.now();
    }
    // getters de solo lectura...
}
// La raíz publica el evento cuando ocurre el hecho
public class Poliza {
    private final List<Object> eventos = new ArrayList<>();

    public void suscribir(IdAsegurado asegurado) {
        // ... lógica de suscripción y validación de invariantes ...
        this.eventos.add(new PolizaSuscrita(this.id, asegurado));  // registra el evento
    }

    public List<Object> eventosPendientes() {
        return Collections.unmodifiableList(eventos);
    }
}

Cómo funciona y por qué importa:

  • Cuando se ejecuta suscribir(), la póliza registra un evento PolizaSuscrita en lugar de llamar directamente a otros módulos.
  • Tras guardar el agregado, la infraestructura publica esos eventos. Otros contextos (por ejemplo, Facturación o Notificaciones) pueden suscribirse y reaccionar (emitir el primer recibo, enviar la bienvenida).
  • Esto desacopla los agregados: la Poliza no sabe ni le importa quién reacciona a su suscripción. Es la base de las arquitecturas dirigidas por eventos.

Representado como flujo:

sequenceDiagram
    participant U as Usuario
    participant P as Poliza (Suscripcion)
    participant B as Facturacion
    participant N as Notificaciones
    U->>P: suscribir()
    P->>P: registra PolizaSuscrita
    P-->>B: PolizaSuscrita
    P-->>N: PolizaSuscrita
    B->>B: emite primer recibo
    N->>N: envia email de bienvenida

El diagrama de secuencia muestra cómo un único evento PolizaSuscrita desencadena reacciones independientes en Facturación y Notificaciones, sin que Suscripción conozca esos detalles.

Errores Comunes y Consejos

  • Agregados demasiado grandes. Meter media base de datos dentro de un solo agregado genera bloqueos y problemas de rendimiento. Mantén los agregados pequeños; referencia otros agregados por ID.
  • Modificar varios agregados en una transacción. Rompe la regla de consistencia. Usa eventos de dominio para coordinar cambios entre agregados.
  • Repositorios por entidad interna. Solo las raíces de agregado tienen repositorio. No crees un RepositorioCoberturas si Cobertura vive dentro del agregado Poliza.
  • Value Objects mutables. Si añades setters a un Value Object, pierdes sus garantías. Mantenlos inmutables y devuelve copias nuevas en las operaciones.
  • Abusar de servicios de dominio. Vacían las entidades y reaparece el modelo anémico. Pregúntate siempre si la lógica cabe en una entidad antes de crear un servicio.
  • Consejo: empieza por los Value Objects. Sustituir primitivos (BigDecimal, String, int) por conceptos del dominio (Dinero, Email, IdPoliza) ya mejora enormemente la expresividad y seguridad del modelo.

Ejercicios

Ejercicio 1. Decide, para cada concepto del dominio de una biblioteca, si lo modelarías como Entidad o como Value Object, justificándolo: (a) un ejemplar físico de un libro; (b) un ISBN; (c) un usuario de la biblioteca; (d) un periodo de préstamo (fecha inicio y fin).

Ejercicio 2. Dado un agregado Pedido que contiene LineaPedido, escribe la firma de un método en la raíz que añada una línea respetando la invariante "el total del pedido no puede superar 5.000 EUR". Explica por qué el método va en la raíz y no en LineaPedido.

Ejercicio 3. Identifica qué patrón táctico (Value Object, Entidad, Servicio de Dominio, Evento de Dominio o Repositorio) usarías para cada caso: (a) guardar y recuperar pedidos; (b) representar un porcentaje de descuento; (c) transferir saldo entre dos cuentas; (d) notificar que "un pedido ha sido enviado".

Soluciones

Solución 1. (a) Entidad: cada ejemplar físico tiene identidad propia (se puede perder o deteriorar uno concreto). (b) Value Object: un ISBN se define por su valor; dos ISBN iguales son intercambiables. (c) Entidad: el usuario tiene identidad estable aunque cambien sus datos. (d) Value Object: un periodo es inmutable y se define por sus fechas.

Solución 2. Una firma válida: public void anadirLinea(Producto producto, int cantidad). El método va en la raíz Pedido porque la invariante "el total no supera 5.000 EUR" depende de todas las líneas en conjunto. Una sola LineaPedido no conoce el total del pedido, así que no puede garantizar esa regla; solo la raíz, que ve el conjunto completo, puede hacerlo.

Solución 3. (a) Repositorio (de la raíz Pedido). (b) Value Object (un porcentaje inmutable, definido por su valor). (c) Servicio de Dominio (la transferencia involucra dos agregados Cuenta y no pertenece a uno solo). (d) Evento de Dominio (PedidoEnviado, un hecho consumado del pasado).

Conclusión

En esta lección hemos recorrido el arsenal táctico de DDD: los Value Objects inmutables definidos por sus valores, las Entidades con identidad estable, los Agregados que agrupan objetos bajo una raíz que protege las invariantes, los Repositorios que ocultan la persistencia, los Servicios de Dominio para la lógica que no encaja en una entidad, y los Eventos de Dominio que desacoplan reacciones entre partes del sistema. Con estos patrones puedes construir un modelo rico y fiel al negocio dentro de cada Bounded Context.

Hasta ahora hemos visto los contextos por separado. En la última lección del módulo, "Mapeo de Contextos (Context Mapping)", estudiaremos cómo se relacionan e integran los distintos Bounded Contexts entre sí mediante patrones como ACL, Shared Kernel u Open Host Service.

Curso de Arquitectura de Aplicaciones

Módulo 1: Fundamentos de la Arquitectura de Aplicaciones

Módulo 2: Principios y Tácticas de Diseño

Módulo 3: Estilos y Patrones Arquitectónicos

Módulo 4: Arquitecturas Distribuidas y Microservicios

Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería

Módulo 6: Diseño Dirigido por el Dominio (DDD)

Módulo 7: Datos y Persistencia

Módulo 8: Arquitectura en la Nube y Despliegue

Módulo 9: Calidad, Seguridad y Observabilidad

Módulo 10: Evolución, Gobernanza y Casos Prácticos

© Copyright 2026. Todos los derechos reservados