Domain-Driven Design aplicado: una introducción práctica sin jerga innecesaria
DDD tiene fama de jerga densa e infraestructura pesada. En realidad, su parte más valiosa -el lenguaje ubicuo y los bounded contexts- es casi gratis. Explicado con un ejemplo de dominio real, sin ceremonia de más.
Domain-Driven Design tiene una reputación que le juega en contra: la de ser una disciplina densa, llena de términos en inglés con mayúscula (Aggregate, Bounded Context, Value Object) que solo tiene sentido en sistemas empresariales enormes con equipos de arquitectos dedicados. Esa reputación es en parte responsabilidad del propio libro fundacional de Eric Evans, publicado en 2003, que es denso y exige una segunda lectura para asentar. Pero la idea central de DDD es mucho más simple de lo que su vocabulario sugiere, y buena parte de su valor es casi gratis: no exige infraestructura nueva, exige conversaciones distintas.
Este artículo explica DDD con un dominio real y deliberadamente evita infravalorar ni sobrevalorar la disciplina: qué parte aporta valor a prácticamente cualquier proyecto con lógica de negocio no trivial, y qué parte es ceremonia que solo se justifica en dominios genuinamente complejos.
El problema que DDD resuelve: el lenguaje se rompe entre personas y entre partes del sistema
Imagina una plataforma de alquiler de coches. Un cliente dice: “quiero reservar un coche”. El equipo de facturación dice: “cuando se confirma la reserva, se genera el cargo”. El equipo de flota dice: “un coche reservado pasa a estado bloqueado hasta la recogida”. Todos hablan del mismo concepto de negocio, pero cada equipo usa una palabra distinta, y peor aún: cada uno tiene en la cabeza un modelo ligeramente distinto de qué significa “reservar”.
Este desajuste no es un problema de comunicación menor. Es la fuente número uno de bugs de negocio: código que técnicamente funciona pero implementa una regla que no es la que el negocio quería, porque quien escribió el código y quien conocía la regla real nunca hablaron con el mismo vocabulario exacto.
Lenguaje ubicuo: la práctica más barata y más rentable de DDD
El lenguaje ubicuo (ubiquitous language) es un vocabulario compartido, acordado explícitamente entre expertos de negocio y desarrolladores, que se usa igual en las conversaciones, en la documentación y en el propio código. Si el negocio dice “reserva” y el código tiene una clase Booking, ya hay una grieta. Si el negocio distingue entre “reserva” y “solicitud de reserva” como dos cosas distintas y el código las trata como una sola, esa grieta ya es un bug esperando a manifestarse.
// Sin lenguaje ubicuo: nombres técnicos que no reflejan el vocabulario del negocio
class RentalRecord {
status: string; // "pending" | "active" | "closed" -- ¿qué significa cada uno para el negocio?
}
// Con lenguaje ubicuo: los mismos términos que usa el equipo de operaciones de flota
class Reserva {
estado: 'solicitada' | 'confirmada' | 'enCurso' | 'finalizada' | 'cancelada';
confirmar(): void { /* aplica las reglas de negocio de "confirmar" */ }
iniciarRecogida(): void { /* transición que el negocio llama "recogida" */ }
}
La diferencia no es cosmética. Cuando un desarrollador nuevo lee Reserva.confirmar() y puede preguntarle a cualquier persona de operaciones “¿qué significa confirmar una reserva?” y obtener la misma respuesta que si preguntara qué hace el método, el código deja de necesitar traducción mental constante. Eric Evans lo plantea como el cimiento de todo lo demás en DDD: sin un lenguaje compartido, ningún modelo posterior —por sofisticado que sea— refleja de verdad el dominio.
Bounded context: la misma palabra puede significar cosas distintas, y está bien
Aquí es donde DDD da su segunda idea central, y la más frecuentemente malentendida: no hace falta (ni conviene) que toda la organización comparta un único modelo unificado de cada concepto. Un bounded context es una frontera explícita —normalmente, aunque no siempre, un subsistema o servicio— dentro de la cual un modelo y su lenguaje ubicuo son válidos y consistentes. Fuera de esa frontera, el mismo término puede significar algo distinto sin que eso sea un error de diseño.
Volviendo al ejemplo del alquiler de coches:
graph TB subgraph BC1[Bounded Context: Reservas] R1[Reserva: fechas, cliente, coche solicitado] end subgraph BC2[Bounded Context: Flota] R2[Coche: estado físico, ubicación, mantenimiento] end subgraph BC3[Bounded Context: Facturación] R3[Cargo: importe, método de pago, factura] end BC1 -->|evento: ReservaConfirmada| BC2 BC1 -->|evento: ReservaConfirmada| BC3
En el contexto de Reservas, un “Coche” es apenas un identificador y una categoría (compacto, familiar, eléctrico). En el contexto de Flota, ese mismo coche es una entidad rica con kilometraje, historial de mantenimiento, ubicación GPS y estado físico. En el contexto de Facturación, el coche casi no importa: lo que importa es la tarifa asociada a su categoría. Intentar forzar un único modelo de “Coche” que sirva igual de bien a los tres contextos produce, en la práctica, una clase enorme con campos que solo tienen sentido para uno de los tres equipos y que el resto ignora o rellena con valores por defecto sin sentido.
Martin Fowler resume la intención de esta idea con precisión: el propósito de un bounded context es dividir un modelo grande en partes separadas, cada una con su propio significado interno, precisamente porque intentar unificar el modelo entero de una organización compleja en un solo esquema es, en la práctica, imposible de mantener consistente.
Agregados: la unidad que garantiza que las reglas de negocio no se rompan
Dentro de un bounded context, un agregado es un grupo de objetos relacionados que se tratan como una unidad a efectos de consistencia: cualquier cambio a cualquier parte del agregado pasa por su punto de entrada único (la raíz del agregado), que es responsable de que las reglas de negocio del conjunto se cumplan siempre.
En el contexto de Reservas, el agregado natural podría ser la propia Reserva, con sus líneas de extras (GPS, seguro adicional, silla infantil) como parte del mismo agregado:
class Reserva {
private extras: Extra[] = [];
private estado: EstadoReserva = 'solicitada';
agregarExtra(extra: Extra) {
if (this.estado !== 'solicitada') {
throw new Error('No se pueden añadir extras a una reserva ya confirmada');
}
this.extras.push(extra);
}
confirmar() {
if (this.extras.length === 0 && this.requiereAlMenosUnExtra()) {
throw new Error('Esta categoría de vehículo exige seleccionar un extra obligatorio');
}
this.estado = 'confirmada';
}
}
Nadie puede añadir un extra directamente a la lista extras desde fuera: toda modificación pasa por agregarExtra() o confirmar(), que son los únicos lugares donde viven las reglas de negocio de la Reserva. Esto evita el error clásico de sistemas sin agregados bien definidos: código disperso por toda la base que modifica el mismo dato desde media docena de sitios distintos, cada uno con una idea ligeramente distinta de qué validaciones aplican.
La regla práctica para diseñar bien un agregado es mantenerlo pequeño: solo lo que de verdad necesita cambiar de forma atómica y consistente pertenece al mismo agregado. Meter demasiado dentro (por ejemplo, todo el historial de pagos dentro del agregado Reserva) produce bloqueos de concurrencia innecesarios y agregados difíciles de cargar y persistir.
Cómo descubrir los bounded contexts de un dominio real: Event Storming
La pregunta obvia después de todo esto es: ¿cómo se descubren las fronteras correctas en la práctica, sin pasarse meses diseñando en abstracto? La técnica más extendida es Event Storming, ideada por Alberto Brandolini: una sesión colaborativa donde desarrolladores y expertos de negocio pegan notas adhesivas de color naranja en una pared, cada una representando un evento de negocio en pasado (“Reserva Confirmada”, “Coche Recogido”, “Pago Rechazado”), ordenados cronológicamente. Los grupos naturales de eventos, comandos y actores que emergen en esa pared suelen delatar las fronteras reales del dominio mucho más rápido —y con mucho más consenso del equipo— que un ejercicio de diseño hecho por una sola persona frente a una pizarra.
Cuándo DDD aporta y cuándo es ceremonia
DDD se divide, en la práctica, en dos mitades con un valor muy distinto:
- DDD estratégico —lenguaje ubicuo, bounded contexts, mapas de contexto entre equipos— aporta valor en casi cualquier sistema con lógica de negocio no trivial y más de una persona escribiendo código sobre él. Cuesta poco (conversaciones, no infraestructura) y previene errores de negocio caros.
- DDD táctico —agregados, value objects, repositorios, factories, arquitectura hexagonal completa— tiene un coste de diseño y de código real, y solo se amortiza cuando el dominio tiene reglas de negocio genuinamente complejas que necesitan protegerse de forma explícita.
DDD es ceremonia innecesaria cuando:
- El sistema es esencialmente CRUD: crear, leer, actualizar, borrar registros con validaciones simples y poca lógica de negocio propia. Envolver eso en agregados, value objects y repositorios abstractos añade capas sin que ninguna regla de negocio compleja las necesite.
- Un desarrollador trabaja solo o un equipo muy pequeño construye un producto que cambia de dirección cada semana. El lenguaje ubicuo formal y los bounded contexts explícitos rinden más cuando hay suficientes personas (y suficiente rotación de personas) como para que el conocimiento tácito compartido deje de bastar.
- El dominio es simple y estable, sin zonas de ambigüedad real entre equipos.
DDD aporta de verdad cuando:
- Distintos equipos o distintas partes del negocio usan la misma palabra para cosas distintas, y esa ambigüedad ya ha causado bugs o malentendidos reales.
- El dominio tiene reglas de negocio no triviales que deben cumplirse siempre, en cualquier punto donde se modifique el estado (agregados bien diseñados existen exactamente para esto).
- El sistema es lo bastante grande como para dividirse en varios servicios o módulos, y hace falta un criterio de división que no sea “lo que resulte técnicamente cómodo hoy”.
Recomendación práctica
No adoptes DDD como un paquete completo de una vez. El orden que funciona en la mayoría de los proyectos reales es: primero, alinea el lenguaje con quien conoce el negocio. Después, cuando el sistema crezca lo suficiente como para dividirse, usa Event Storming (o simplemente conversación estructurada) para encontrar las fronteras naturales entre contextos, en lugar de dividir el código por capas técnicas. Solo cuando aparezca un dominio con reglas de negocio genuinamente intrincadas —normalmente uno o dos bounded contexts del sistema, no todos— vale la pena invertir en agregados y el resto del vocabulario táctico. El resto del sistema puede seguir siendo, con toda tranquilidad, CRUD bien organizado.
Artículos relacionados
Arquitectura full stack moderna: del monolito a los microservicios (sin dogmatismo)
El péndulo ha vuelto al monolito modular. Repasamos por qué, qué señales reales justifican extraer un microservicio y qué factura oculta pagan los equipos que separan servicios antes de necesitarlo.
Patrones de resiliencia: circuit breakers, retries y timeouts explicados
Un servicio lento en el punto equivocado puede arrastrar a todo el sistema si nadie corta la llamada a tiempo. Timeouts, retries con backoff y jitter, y circuit breakers explicados con criterio de producción, no de diapositiva.
Escalabilidad de sistemas: de 100 a 1 millón de usuarios
Cada orden de magnitud de usuarios rompe algo distinto: primero la CPU de un servidor, luego la base de datos, luego las escrituras. Guía honesta de qué añadir en cada etapa y por qué diseñar para 1M con 100 usuarios sale caro.