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
- Value Objects (objetos de valor)
- Entidades
- Agregados y raíz de agregado
- Repositorios
- Servicios de dominio
- Eventos de dominio
- 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
finaly todos sus campos sonfinal: es inmutable, no hay setters. - El método
sumarno modifica el objeto actual; devuelve un nuevoDinero. Esta es la esencia de la inmutabilidad. - Se incluye una regla de negocio (no sumar monedas distintas) dentro del propio objeto.
- Se sobrescriben
equalsyhashCodebasándose en los valores: dosDinerode 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.
- 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
idesfinaly define la entidad. Aunque cambienestadoeimporteReclamado, sigue siendo el mismo siniestro. equalsyhashCodese basan solo en elid, no en todos los atributos. Dos siniestros con el mismoidson 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 |
- 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:
Polizaes la raíz.Coberturaes 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| REste 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.
- 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.
- 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, unaTablaActuarialy unTipoCobertura. 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.
- 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 eventoPolizaSuscritaen 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
Polizano 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 bienvenidaEl 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
RepositorioCoberturassiCoberturavive dentro del agregadoPoliza. - 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
- ¿Qué es la Arquitectura de Aplicaciones?
- El Rol del Arquitecto de Software
- Atributos de Calidad y Requisitos No Funcionales
- Decisiones Arquitectónicas y Compromisos (Trade-offs)
- Documentación de Arquitectura: Vistas y el Modelo C4
Módulo 2: Principios y Tácticas de Diseño
- Acoplamiento, Cohesión y Separación de Responsabilidades
- Principios SOLID Aplicados a la Arquitectura
- DRY, KISS, YAGNI y Otros Principios de Diseño
- Tácticas Arquitectónicas para los Atributos de Calidad
- Gestión de la Deuda Técnica
Módulo 3: Estilos y Patrones Arquitectónicos
- Arquitectura Monolítica
- Arquitectura en Capas (N-Tier)
- Arquitectura Cliente-Servidor
- Arquitectura Hexagonal (Puertos y Adaptadores)
- Arquitectura Limpia y Cebolla (Clean & Onion)
Módulo 4: Arquitecturas Distribuidas y Microservicios
- Introducción a los Sistemas Distribuidos
- Arquitectura de Microservicios
- Descomposición de Servicios y Bounded Contexts
- API Gateway, Service Discovery y Comunicación entre Servicios
- Patrones de Resiliencia: Circuit Breaker, Retry y Bulkhead
- El Teorema CAP y la Consistencia de Datos
Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería
- Fundamentos de la Arquitectura Orientada a Eventos
- Mensajería Asíncrona: Colas y Brokers
- Patrones de Eventos: Event Sourcing y CQRS
- Gestión de Transacciones Distribuidas: Patrón Saga
- Streaming de Datos en Tiempo Real
Módulo 6: Diseño Dirigido por el Dominio (DDD)
- Conceptos Fundamentales del DDD
- Diseño Estratégico: Bounded Contexts y Lenguaje Ubicuo
- Diseño Táctico: Entidades, Agregados y Repositorios
- Mapeo de Contextos (Context Mapping)
Módulo 7: Datos y Persistencia
- Estrategias de Persistencia: SQL vs NoSQL
- Patrones de Acceso a Datos: Repository, Unit of Work y DAO
- Base de Datos por Servicio y Gestión de Datos Distribuidos
- Caché y Estrategias de Invalidación
Módulo 8: Arquitectura en la Nube y Despliegue
- Fundamentos del Cloud Computing (IaaS, PaaS, SaaS)
- Contenedores y Orquestación con Docker y Kubernetes
- Arquitectura Serverless
- Patrones de Diseño Cloud-Native
- Infraestructura como Código (IaC)
Módulo 9: Calidad, Seguridad y Observabilidad
- Escalabilidad: Horizontal vs Vertical y Balanceo de Carga
- Alta Disponibilidad y Tolerancia a Fallos
- Seguridad por Diseño y Autenticación/Autorización
- Observabilidad: Logging, Métricas y Trazabilidad
- Rendimiento y Pruebas de Carga
