Domain-Driven Design (DDD)¶
De un vistazo
Materia: Capa 2 · Dar forma al sistema · Tiempo de lectura: ~35 min · Requisitos previos: Capa 1 (patrones), Bases de datos y APIs.
En una frase: vas a entender cómo dejar de escribir software que "guarda filas en tablas" y empezar a escribir software que habla el idioma del negocio, de modo que el experto que conoce el dominio y el código que lo implementa dejen de ser dos mundos que un traductor roto conecta a mano.
Por qué esto importa¶
Casi todo el software de empresa muere de lo mismo, y no es de un fallo técnico: muere porque nadie entiende ya qué hace ni por qué. El experto de negocio dice "un pedido se puede cancelar mientras no esté embarcado"; seis meses después, en el código hay un if ($order->status_id != 4 && $order->flag_b != 1) que nadie se atreve a tocar, y ni el que lo escribió sabe si sigue significando eso.
Ese es el fenómeno que ya has sufrido:
- Reuniones donde el experto y tú decís las mismas palabras —"reserva", "cuenta", "envío"— y descubrís tarde que cada uno pensaba en algo distinto.
- Reglas de negocio importantísimas repartidas en trozos por controladores, servicios, validaciones de formulario y triggers de base de datos, sin un sitio único donde vivan.
- Un modelo de datos que empezó siendo un espejo de las tablas y que, cuando el negocio cambia, obliga a "adaptar" a base de parches cada vez más ilegibles.
- Un mismo término —"cliente"— que significa una cosa para el equipo de ventas y otra para el de facturación, metidos a la fuerza en la misma tabla
clientescon 40 columnas medio vacías.
Domain-Driven Design es la respuesta de Eric Evans (2003) a este problema. No es un framework ni una librería: es una forma de decidir dónde vive la complejidad de tu negocio y cómo hacer que el código sea un reflejo fiel —y sobre todo modificable— de cómo piensa el negocio. Su tesis central es incómoda pero cierta: en el software complejo, el cuello de botella no es la tecnología, es el modelo. Y un modelo malo no lo salva ningún lenguaje ni ninguna arquitectura elegante.
Intuición primero¶
Imagina que contratas a un arquitecto para construir una casa, pero no habláis el mismo idioma. Tú dices "quiero un sitio tranquilo para leer con luz de tarde"; él, que solo entiende de planos, apunta "habitación 3,2 × 2,8 m, ventana orientación oeste". En cada traducción se pierde algo. Cuando cambias de idea —"mejor que dé al jardín"— él tiene que retraducir, y a veces traduce mal, y no os dais cuenta hasta que la pared está levantada.
Ahora imagina que el arquitecto aprende tu idioma: habláis los dos de "el rincón de lectura", esa expresión aparece en los planos, en las conversaciones y en la obra. Cuando dices "muevo el rincón de lectura al jardín", no hay traducción que se rompa: la palabra es la misma en todas partes.
DDD es exactamente eso aplicado al software. El experto del dominio (el que sabe cómo funciona el negocio de verdad) y el desarrollador acuerdan un único vocabulario —el lenguaje ubicuo— y ese vocabulario aparece literalmente en las conversaciones, en los diagramas y en los nombres de clases y métodos del código. No hay traducción intermedia que se corrompa.
graph TB
subgraph SIN["Sin lenguaje ubicuo"]
E1["Experto de negocio<br/>habla de 'cancelar reserva'"] -->|"traducción<br/>que se rompe"| C1["Código:<br/>UPDATE bookings<br/>SET st=3"]
end
subgraph CON["Con lenguaje ubicuo"]
E2["Experto de negocio<br/>habla de 'cancelar reserva'"] -->|"mismo término"| C2["Código:<br/>reserva.cancelar()"]
end
Guarda esta imagen: el modelo del dominio es un idioma compartido, y el código es una de las formas en que ese idioma se escribe. Todo lo demás cuelga de aquí.
El detalle¶
DDD tiene dos mitades que se suelen confundir y conviene separar desde el principio:
- El diseño táctico: los bloques de construcción dentro de un modelo (entidad, objeto de valor, agregado, repositorio, servicio de dominio, evento). Es lo "pequeño", lo que tocas en el código a diario.
- El diseño estratégico: cómo partes un sistema grande en modelos distintos (bounded contexts) y cómo se relacionan entre sí. Es lo "grande", lo que decide la forma general del sistema.
La trampa habitual es aprender solo lo táctico —cuatro clases con nombres bonitos— y creer que ya "haces DDD". Lo táctico sin lo estratégico es decoración. Vamos por partes, pero no pierdas de vista que lo estratégico es lo que de verdad reparte la complejidad.
1. El lenguaje ubicuo: el pilar del que cuelga todo¶
El lenguaje ubicuo (ubiquitous language) es un vocabulario construido en equipo entre desarrolladores y expertos del dominio, y usado sin excepción y sin traducción en las conversaciones, la documentación y el código.
Suena obvio y casi nadie lo hace. La prueba del algodón: coge un método de tu código actual y léeselo en voz alta a la persona de negocio. Si dice "¿qué es un flag_status? ¿por qué type = 2 es un envío urgente?", no tienes lenguaje ubicuo: tienes una capa de jerga técnica que solo tú sabes traducir, y esa traducción es el punto exacto donde el sistema se pudre.
El lenguaje ubicuo se descubre, no se inventa
No te sientes a diseñar el vocabulario. Escucha cómo habla el experto y roba sus palabras. Si dice "la póliza caduca", tu método es caducar(), no deactivatePolicy(). Si distingue entre "prospecto" y "cliente", tu código también los distingue. Cuando el negocio usa una palabra distinta para dos cosas, es porque son dos cosas distintas; homogeneizarlas en el código es destruir información valiosa.
Un detalle que mucha gente no capta: el lenguaje ubicuo es local a un contexto (lo veremos en la sección 8). "Cliente" en ventas no es "cliente" en soporte técnico. Forzar un único diccionario global para toda la empresa es tan malo como no tener ninguno.
2. Modelar el dominio, no la base de datos¶
Este es el giro mental más difícil de interiorizar para quien viene de "primero diseño las tablas".
El enfoque tradicional —database-first— empieza por las tablas: clientes, pedidos, líneas_pedido, con sus claves foráneas, y luego crea clases que son un espejo anémico de esas tablas (una propiedad por columna, getters y setters, y toda la lógica fuera, en "servicios"). El resultado es lo que Martin Fowler llamó modelo de dominio anémico: objetos que son sacos de datos sin comportamiento, y reglas de negocio dispersas por controladores y servicios.
DDD invierte el orden: primero modelas el comportamiento del dominio —qué cosas existen, qué pueden hacer, qué reglas las gobiernan— y la base de datos pasa a ser un detalle de persistencia, importante pero subordinado. La pregunta deja de ser "¿qué columnas tiene un pedido?" y pasa a ser "¿qué puede hacer un pedido y qué reglas nunca puede violar?".
// ❌ Anémico: espejo de la tabla, la lógica vive fuera
class Pedido {
public int $id;
public int $estado; // 1=nuevo, 2=pagado, 3=enviado... ¿quién sabe?
public array $lineas;
}
// ... y en algún servicio, lejos:
if ($pedido->estado == 2 && count($pedido->lineas) > 0) {
$pedido->estado = 3;
}
// ✅ Rico: el objeto protege sus propias reglas y habla el idioma del negocio
final class Pedido {
private EstadoPedido $estado;
/** @var LineaPedido[] */
private array $lineas;
public function enviar(): void {
if (!$this->estado->esPagado()) {
throw new PedidoNoEnviable('No se puede enviar un pedido sin pagar.');
}
if ($this->lineas === []) {
throw new PedidoNoEnviable('No se puede enviar un pedido vacío.');
}
$this->estado = EstadoPedido::Enviado;
}
}
La diferencia no es estética. En el segundo caso, la regla "solo se envía lo pagado y no vacío" vive en un único sitio, imposible de saltarse porque enviar() es la única puerta. En el primero, esa regla está copiada en cada lugar que cambia el estado, y basta olvidarla una vez para tener un bug de datos silencioso.
La persistencia va después, y no manda
"El dominio no depende de la base de datos" no significa ignorarla; significa que las decisiones de modelado las toma el negocio, no el motor SQL. Que un agregado se guarde en una tabla, en tres o en Mongo es un detalle que resuelves en la capa de infraestructura (esto conecta con Arquitectura hexagonal / puertos y adaptadores, de esta misma capa). El dominio ni se entera.
3. Entidades: identidad que persiste en el tiempo¶
Una entidad es un objeto del dominio que tiene una identidad propia que lo distingue, y que se mantiene a lo largo del tiempo aunque sus atributos cambien.
La clave es la identidad. Un cliente que cambia de nombre, de email y de dirección sigue siendo el mismo cliente: lo que lo define no son sus datos, sino su identidad (un id). Dos entidades con exactamente los mismos atributos pero distinto id son distintas. Dos con el mismo id son la misma, aunque difieran en todo lo demás.
final class Cliente {
public function __construct(
private readonly ClienteId $id, // identidad: no cambia jamás
private string $nombre,
private Email $email,
) {}
public function cambiarEmail(Email $nuevo): void {
// sigue siendo el mismo cliente; solo cambia un atributo
$this->email = $nuevo;
}
public function equals(Cliente $otro): bool {
return $this->id->equals($otro->id); // igualdad POR IDENTIDAD
}
}
Pistas para reconocer una entidad: te importa su historia ("este pedido, el que hizo Ana el martes"), la rastreas en el tiempo, y necesitas distinguir dos ejemplares aunque sus datos coincidan.
4. Objetos de valor: definidos por lo que valen, no por quién son¶
Un objeto de valor (value object) es lo contrario: no tiene identidad, se define por sus atributos, y dos objetos de valor con los mismos atributos son el mismo, intercambiables. Un billete de 10 € es intercambiable por cualquier otro billete de 10 €: no te importa "cuál" es.
Ejemplos típicos: dinero, una dirección postal, un rango de fechas, un email, unas coordenadas. Los objetos de valor deberían ser inmutables (para "cambiarlos", creas uno nuevo) y llevar sus propias reglas de validez dentro.
final class Email {
private readonly string $valor;
public function __construct(string $valor) {
if (!filter_var($valor, FILTER_VALIDATE_EMAIL)) {
throw new EmailInvalido("'$valor' no es un email válido.");
}
$this->valor = strtolower($valor);
}
public function equals(Email $otro): bool {
return $this->valor === $otro->valor; // igualdad POR VALOR
}
}
El poder de los objetos de valor es doble. Primero, hacen imposible el dato inválido: si tienes un Email, es válido, porque el constructor no deja crearlo de otra forma. Adiós a validar el mismo email en veinte sitios. Segundo, suben el vocabulario del negocio al sistema de tipos: un método que recibe Dinero $importe en vez de float $importe no puede sumar euros con dólares por accidente, ni confundir un precio con una cantidad.
Regla práctica para decidir entidad vs valor
Pregúntate: "si dos de estos tienen exactamente los mismos datos, ¿son la misma cosa?". Si sí → objeto de valor (dos importes de 10 € son el mismo). Si no, porque te importa cuál es → entidad (dos clientes llamados igual son distintos). Ante la duda, empieza por objeto de valor: son más simples, inmutables y seguros. Muchos "ids" que arrastramos como enteros son en realidad objetos de valor esperando nacer (ClienteId, Dinero, Cantidad).
5. Agregados y raíz de agregado: las fronteras de consistencia¶
Este es el concepto táctico más importante y el que más gente entiende mal.
Un agregado es un grupo de entidades y objetos de valor que se tratan como una sola unidad a efectos de cambios y de consistencia. Uno de sus objetos es la raíz del agregado (aggregate root): la única puerta de entrada al grupo. Nadie de fuera toca las piezas internas directamente; todo pasa por la raíz.
El ejemplo canónico: un Pedido (raíz) y sus LineaPedido (entidades internas). Las líneas no existen sin su pedido y nadie las manipula por su cuenta: si quieres añadir una línea, se lo pides al pedido, y el pedido decide.
final class Pedido { // ← RAÍZ del agregado
/** @var LineaPedido[] */
private array $lineas = [];
private Dinero $total;
public function añadirLinea(ProductoId $producto, Cantidad $cant, Dinero $precio): void {
if ($this->estado->esCerrado()) {
throw new PedidoCerrado('No se pueden añadir líneas a un pedido cerrado.');
}
$this->lineas[] = new LineaPedido($producto, $cant, $precio);
$this->recalcularTotal(); // ← la INVARIANTE se mantiene aquí
}
private function recalcularTotal(): void {
$this->total = array_reduce(
$this->lineas,
fn(Dinero $acc, LineaPedido $l) => $acc->sumar($l->subtotal()),
Dinero::cero(),
);
}
}
¿Por qué esta ceremonia? Por la palabra clave: invariante. Una invariante es una regla que siempre debe cumplirse, por ejemplo "el total del pedido es la suma de sus líneas" o "un pedido no puede tener más de 50 líneas". Si cualquiera pudiera hacer $pedido->lineas[] = ... a mano por fuera, el total quedaría desincronizado y la invariante rota. Al forzar que todo cambio pase por la raíz, garantizas que el agregado nunca queda en un estado inválido.
graph TB
subgraph AGG["Agregado Pedido (frontera de consistencia)"]
R["<b>Pedido</b><br/>(raíz de agregado)<br/>única puerta de entrada"]
L1["LineaPedido"]
L2["LineaPedido"]
D["Dinero total<br/>(objeto de valor)"]
R --> L1
R --> L2
R --> D
end
EXT["Código externo<br/>(servicio, controlador)"] -->|"solo habla<br/>con la raíz"| R
EXT -.->|"❌ prohibido tocar<br/>las piezas internas"| L1
Dos reglas de diseño que separan al que "sabe DDD" del que solo copió nombres:
- Referencia entre agregados: por identidad, no por objeto. Un
Pedidono guarda un objetoClienteentero dentro; guarda unClienteId. Los agregados son islas: se refieren unos a otros por suid, no se cuelgan enteros. Esto mantiene las fronteras claras y evita cargar medio sistema en memoria para tocar una cosa. - Un agregado = una transacción = una frontera de consistencia. Cada operación de escritura debería modificar un solo agregado y guardarlo de forma atómica. Si una acción "necesita" cambiar tres agregados a la vez de forma consistente, casi siempre es señal de que las fronteras están mal trazadas, o de que la consistencia entre ellos debe ser eventual (vía eventos, sección 7), no inmediata.
El error nº 1 con agregados: hacerlos gigantes
La tentación es meter en un agregado todo lo que "está relacionado": Cliente → sus Pedidos → sus Líneas → sus Pagos... Acabas con un mega-agregado que hay que cargar entero para cambiar una coma, con contención de bloqueos y rendimiento pésimo. Los agregados pequeños son casi siempre mejores. La pregunta no es "¿esto está relacionado?" (casi todo lo está), sino "¿esto tiene que ser consistente de forma inmediata y atómica con esto otro?". Si la respuesta es no, son agregados distintos.
6. Repositorios: guardar y recuperar agregados sin ensuciar el dominio¶
Un repositorio es el objeto que se encarga de persistir y recuperar agregados, dando la ilusión de una colección en memoria de agregados, ocultando por completo el SQL, el ORM o lo que sea que haya debajo.
La clave es que hay un repositorio por agregado (por su raíz), no uno por tabla. El repositorio trabaja en el idioma del dominio (guardar(Pedido), porId(PedidoId): Pedido), y el dominio solo conoce una interfaz, no la implementación:
// En la capa de DOMINIO: solo la interfaz, en el idioma del negocio
interface RepositorioDePedidos {
public function porId(PedidoId $id): ?Pedido;
public function guardar(Pedido $pedido): void;
public function pedidosPendientesDe(ClienteId $cliente): array;
}
// En la capa de INFRAESTRUCTURA: la implementación sucia con Doctrine
final class RepositorioDePedidosDoctrine implements RepositorioDePedidos {
public function __construct(private EntityManagerInterface $em) {}
public function porId(PedidoId $id): ?Pedido {
return $this->em->find(Pedido::class, $id->valor());
}
public function guardar(Pedido $pedido): void {
$this->em->persist($pedido);
$this->em->flush();
}
// ...
}
El dominio depende de RepositorioDePedidos (la interfaz); la implementación concreta se inyecta desde fuera. Esto es inversión de dependencias, y es lo que permite que el dominio ni se entere de si detrás hay MySQL, un fichero o un array de test. En Symfony, el service container enlaza la interfaz con su implementación real vía configuración.
Repositorio ≠ el EntityRepository de Doctrine
Doctrine te da unos Repository que devuelven cualquier entidad y exponen findBy, createQueryBuilder, etc. Un repositorio DDD es un concepto más estricto: solo devuelve agregados completos y consistentes, habla el idioma del dominio y vive detrás de una interfaz del dominio. Puedes implementarlo con Doctrine, pero no son lo mismo. Confundirlos es el motivo por el que muchos proyectos "con repositorios" siguen teniendo el dominio acoplado al ORM.
7. Servicios de dominio y eventos de dominio¶
Servicios de dominio. A veces una operación del negocio no encaja de forma natural en ninguna entidad ni objeto de valor porque involucra a varios o representa un proceso, no una cosa. Para eso está el servicio de dominio: un objeto sin estado, con nombre del negocio, que orquesta lógica de dominio pura.
El clásico: una transferencia entre dos cuentas. ¿Vive en la cuenta origen? ¿En la destino? En ninguna con naturalidad; vive en un servicio.
final class ServicioDeTransferencia {
public function transferir(Cuenta $origen, Cuenta $destino, Dinero $importe): void {
$origen->retirar($importe); // cada cuenta protege su propia invariante
$destino->ingresar($importe); // (p. ej. no quedar en negativo)
}
}
Cuidado: el servicio de dominio es un último recurso, no el sitio por defecto donde tirar lógica. Si te descubres poniendo toda la lógica en servicios y dejando las entidades anémicas, has vuelto al punto de partida. La lógica que pertenece a un solo agregado va dentro de ese agregado; al servicio solo lo que no cabe en ninguno.
Eventos de dominio. Un evento de dominio (domain event) es un hecho relevante del negocio que ya ha ocurrido, expresado en pasado: PedidoConfirmado, PagoRechazado, ReservaCancelada. Sirven para que, cuando algo pasa en un agregado, otras partes del sistema reaccionen sin que el agregado las conozca.
final class Pedido {
private array $eventos = [];
public function confirmar(): void {
$this->estado = EstadoPedido::Confirmado;
$this->eventos[] = new PedidoConfirmado($this->id, $this->total);
}
public function tirarEventos(): array { /* devuelve y limpia */ }
}
Cuando el pedido se confirma, se emite PedidoConfirmado. Otros interesados —enviar el email de confirmación, descontar stock, avisar a facturación— se suscriben al evento y actúan, sin que Pedido sepa nada de emails ni de stock. Esto desacopla brutalmente: añadir un nuevo efecto ("también notificar al CRM") no toca el Pedido. Y es la vía natural de mantener consistencia entre agregados distintos sin meterlos a todos en la misma transacción (recuerda la regla de la sección 5). Conecta directamente con arquitecturas dirigidas por eventos y con patrones como event sourcing, materia aparte.
8. Diseño estratégico: bounded contexts¶
Todo lo anterior era táctica dentro de un modelo. Pero un sistema real grande —un ERP, un marketplace— no tiene un solo modelo, ni debe tenerlo. Aquí entra la mitad estratégica, y es donde DDD de verdad paga.
Un bounded context (contexto delimitado) es una frontera explícita dentro de la cual un modelo y su lenguaje ubicuo son consistentes y tienen un único significado. Fuera de esa frontera, las mismas palabras pueden significar —y normalmente significan— cosas distintas.
El ejemplo que lo deja claro: la palabra "Producto".
- En el contexto de Catálogo, un producto es una ficha: nombre, fotos, descripción, categoría, opiniones.
- En el contexto de Inventario, un producto es una unidad de stock: SKU, ubicación en almacén, cantidad disponible.
- En el contexto de Facturación, un producto es una línea con precio, impuestos y código contable.
- En el de Envíos, un producto es peso, volumen y fragilidad.
graph TB
subgraph CAT["Contexto: Catálogo"]
P1["<b>Producto</b><br/>fotos, descripción,<br/>opiniones, categoría"]
end
subgraph INV["Contexto: Inventario"]
P2["<b>Producto</b><br/>SKU, ubicación,<br/>stock disponible"]
end
subgraph FAC["Contexto: Facturación"]
P3["<b>Producto</b><br/>precio, impuestos,<br/>código contable"]
end
P1 -.->|"mismo SKU,<br/>modelos distintos"| P2
P2 -.-> P3
El error que arruina sistemas es intentar construir un único Producto universal que sirva para todos: acabas con una clase monstruosa de 60 propiedades donde cada equipo usa solo un tercio, todos se pisan al modificarla, y un cambio en Envíos rompe Facturación. La solución de DDD es dejar que haya varios Producto, uno por contexto, cada uno con su modelo ajustado a lo que ese contexto necesita, coordinados solo por un identificador compartido (el SKU). Cada contexto tiene su propio lenguaje ubicuo, su propio equipo idealmente, y su propia base de datos.
Bounded context ≈ candidato natural a microservicio
Cuando la gente pregunta "¿por dónde parto mi monolito en microservicios?", la respuesta madura de DDD es: por los bounded contexts, no por capas técnicas. Un contexto es una unidad de negocio coherente con una frontera clara; esa frontera es exactamente por donde conviene cortar (o al menos, por donde poner módulos bien separados dentro del monolito). Partir por criterios técnicos —"un servicio de usuarios, uno de emails"— produce microservicios que se llaman entre sí sin parar. Esto se profundiza en Arquitectura de microservicios.
9. Context mapping: cómo se relacionan los contextos¶
Los contextos no viven aislados: se hablan. El context mapping (mapa de contextos) es dibujar qué contextos hay y qué relación tienen entre sí. Sin entrar al detalle de cada patrón (es materia en sí misma), los que más vas a encontrar:
- Cliente/Proveedor (customer/supplier): un contexto aguas arriba provee datos a otro aguas abajo, que puede influir en lo que el de arriba expone.
- Capa anticorrupción (anti-corruption layer, ACL): cuando consumes un contexto ajeno (un sistema legado, una API externa) y no quieres que su modelo contamine el tuyo, pones una capa traductora en medio que convierte su lenguaje al tuyo. Es una de las ideas más rentables de todo DDD: protege tu modelo limpio del caos de fuera.
- Lenguaje publicado / Servicio abierto: el contexto expone un contrato estable (una API, unos eventos) que otros consumen sin acoplarse a sus tripas.
La utilidad del mapa no es burocrática: te obliga a hacer explícitas las dependencias entre partes del sistema y a decidir conscientemente quién depende de quién y cómo te proteges. La mayoría de los "big ball of mud" son sistemas donde esas relaciones nunca se dibujaron y crecieron a lo salvaje.
Cómo se ve en la práctica¶
Un caso real recurrente: una plataforma de reservas de hotel. Empezó como CRUD —tabla reservas con un campo estado (int)— y el equipo iba metiendo ifs por todas partes. Cada nueva regla de negocio ("no se puede cancelar con menos de 24 h", "una reserva no-reembolsable no admite cambios de fecha") era otro if disperso, y los bugs de estados imposibles se multiplicaban.
La reescritura con DDD, resumida:
- Bounded contexts separados: Reservas (el acto de reservar y sus reglas), Facturación (cobros, reembolsos) y Disponibilidad (habitaciones y calendario). Antes eran una maraña; ahora, tres módulos con fronteras claras.
- En Reservas, el agregado
Reserva(raíz) con objetos de valorRangoDeFechas,Dinero,PoliticaDeCancelacion. Toda regla de cancelación vive dentro deReserva.cancelar():
public function cancelar(\DateTimeImmutable $ahora): void {
if (!$this->politica->permiteCancelar($this->fechas, $ahora)) {
throw new CancelacionNoPermitida(
'La política de esta reserva no permite cancelar a estas alturas.'
);
}
$this->estado = EstadoReserva::Cancelada;
$this->eventos[] = new ReservaCancelada($this->id, $this->fechas);
}
- El evento
ReservaCanceladalo escucha el contexto de Facturación para calcular el reembolso, y el de Disponibilidad para liberar la habitación.Reservano sabe nada de reembolsos ni de calendarios: solo anuncia el hecho.
El resultado medible no fue "código más bonito": fue que cuando negocio pedía una regla nueva, se sabía exactamente dónde iba (dentro del agregado correcto), los estados imposibles desaparecieron (la raíz los prohíbe), y añadir un efecto nuevo a una cancelación era suscribir un listener más, sin tocar el núcleo. Ese es el retorno real de DDD: cambiabilidad en un dominio que no para de cambiar.
Lo que sacrificas / errores comunes¶
DDD no es gratis, y vendértelo como la respuesta universal sería mentirte:
- Es caro por adelantado. Modelar bien, acordar el lenguaje ubicuo con negocio, separar contextos... cuesta tiempo y exige acceso real a los expertos del dominio. Si no tienes con quién construir el lenguaje ubicuo, DDD se queda en cascarón vacío.
- Es sobre-ingeniería flagrante en CRUD simple. Si tu aplicación es "formularios que guardan filas y las listan" —un gestor de contactos, un panel de contenidos—, montar agregados, objetos de valor, repositorios y eventos es complejidad accidental que solo añade fricción. Ahí un CRUD directo con Active Record es la respuesta correcta, y pretender lo contrario es presumir de arquitectura. DDD solo se paga a sí mismo cuando la complejidad está en el dominio, no en la técnica.
- La curva de aprendizaje reparte mal el coste. El equipo tarda en interiorizarlo, y mientras tanto produce "DDD de imitación": clases con nombres bonitos y dominio anémico por dentro. Peor que no hacerlo, porque tiene el coste sin el beneficio.
- Errores concretos recurrentes: agregados gigantes que cargan medio sistema; repositorios que devuelven fragmentos en vez de agregados completos; meter toda la lógica en "servicios" y dejar entidades anémicas; un único modelo universal en vez de bounded contexts; y confundir los tres o cuatro patrones tácticos con "hacer DDD", ignorando por completo la parte estratégica, que es la que de verdad ordena un sistema grande.
La pregunta honesta antes de empezar
Antes de "hacer DDD", responde: ¿la dificultad de este proyecto está en las reglas de negocio o en la técnica (escala, latencia, integraciones)? DDD ataca lo primero. Si tu reto es CRUD con mucho tráfico, tu problema es de infraestructura, no de modelado, y DDD te distrae. Aplica DDD al subconjunto complejo del dominio (el core domain), y deja lo simple simple. Casi ningún sistema es DDD "entero": son DDD en el corazón valioso y CRUD en los bordes.
Resumen¶
- DDD arregla el problema de que el software no habla el idioma del negocio: el lenguaje ubicuo —un vocabulario compartido entre expertos y desarrolladores, presente literalmente en el código— elimina el traductor roto que corrompe todo.
- Modela el dominio, no la base de datos: primero el comportamiento y las reglas (objetos ricos que protegen sus invariantes), y la persistencia después, como detalle subordinado.
- Bloques tácticos: entidades (identidad que persiste), objetos de valor (definidos por sus datos, inmutables, hacen imposible el dato inválido), agregados (fronteras de consistencia con una raíz como única puerta), repositorios (colección de agregados que oculta la persistencia), servicios de dominio (lógica que no cabe en una entidad) y eventos de dominio (hechos del negocio que desacoplan reacciones).
- Diseño estratégico: los bounded contexts dividen un sistema grande en modelos independientes, porque un mismo término ("Producto", "Cliente") significa cosas distintas en cada contexto; el context mapping hace explícito cómo se relacionan y cómo te proteges de los modelos ajenos.
- DDD cuesta caro por adelantado y es sobre-ingeniería en CRUD simple; solo se paga a sí mismo cuando la complejidad vive en el dominio. Aplícalo al corazón valioso y deja simple lo simple.
Ejercicios socráticos¶
No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.
- Tienes un
Clientey suDirección. Argumenta en qué caso la dirección es un objeto de valor y en qué caso podría ser una entidad. ¿Qué pregunta decide la respuesta? - Un compañero mete
Cliente, todos susPedidosy todas lasLíneasde cada pedido dentro de un solo agregado cuya raíz esCliente. Nombra al menos dos problemas concretos que esto va a causar en producción y explica cómo redibujarías las fronteras. - ¿Por qué un agregado se refiere a otro por su identidad (
ClienteId) y no guardando el objetoClienteentero dentro? ¿Qué se rompería si guardaras el objeto? - Tu equipo insiste en tener una única clase
Productopara catálogo, inventario y facturación "para no duplicar código". Explica, con el concepto de bounded context, por qué esa "no duplicación" te va a salir carísima, y qué duplicación sí merece la pena aquí. - Te piden aplicar DDD completo (agregados, VOs, repositorios, eventos) a un panel de administración de un blog: crear, editar y borrar posts y categorías. ¿Lo harías? Justifica tu decisión en términos de dónde está la complejidad y qué coste estarías añadiendo.
Repaso espaciado¶
Pasa estas a Anki (
repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.
- [ ] ¿Qué es el lenguaje ubicuo y por qué es local a un bounded context en vez de global a toda la empresa? Da un ejemplo de un término que signifique cosas distintas en dos contextos.
- [ ] Distingue entidad de objeto de valor: ¿qué pregunta usas para clasificar uno, y por qué los objetos de valor "hacen imposible el dato inválido"?
- [ ] ¿Qué es la raíz de un agregado y por qué obligar a que todo cambio pase por ella protege las invariantes? ¿Qué regla siguen dos agregados para referirse el uno al otro?
- [ ] ¿En qué se diferencia un repositorio DDD del
EntityRepositoryde Doctrine, y por qué el dominio depende de una interfaz y no de la implementación? - [ ] ¿Cuándo DDD merece la pena y cuándo es sobre-ingeniería? Enuncia la pregunta que usas para decidirlo.
Para seguir tirando del hilo¶
- Domain-Driven Design: Tackling Complexity in the Heart of Software (Eric Evans, 2003) — el libro original, "el libro azul". Denso pero es la fuente.
- Implementing Domain-Driven Design (Vaughn Vernon) — "el libro rojo", más práctico y con código; el mejor para aterrizar los agregados y los bounded contexts.
- Domain-Driven Design Distilled (Vaughn Vernon) — versión corta y digerible para una primera pasada antes de meterte en los tochos.
- Patterns, Principles, and Practices of Domain-Driven Design (Millett & Tune) — con ejemplos en C#/.NET muy trasladables a PHP/Symfony.
- Experimento: coge un trozo anémico de tu código actual (un servicio lleno de
ifsobre unestadoentero) y reescríbelo como un agregado rico con un objeto de valor para el estado. Compara cuántos sitios tenías que tocar antes para una regla nueva y cuántos después.