* notes / ddd
Domain-Driven Design: lo que de verdad cambia cuando lo aplicas
— Un balance honesto tras varios años aplicando DDD: el proyecto donde fue puro sobre-diseño, el que salvó al equipo, y los conceptos (agregados, lenguaje ubicuo, capas) que de verdad mueven la aguja.
Conocí Domain-Driven Design tarde, mal y con prejuicios. Mal, porque mi primera exposición fue un proyecto donde lo aplicamos como si fuese un decálogo religioso: agregados que no protegían ningún invariante, repositorios para entidades que sólo se consultaban, una anti-corruption layer defendiendo de un servicio externo que cambiaba dos veces al año. El resultado fue un código tres veces más largo de lo necesario y un equipo agotado de discutir nombres. Tarde, porque la segunda exposición (dos años después, con un dominio realmente complejo) me reveló que aquel primer fiasco no había desacreditado al DDD; había desacreditado nuestra forma de adoptarlo. Este artículo es el balance honesto que he sacado entre uno y otro.
Contexto: qué es DDD y qué no es
Domain-Driven Design, propuesto por Eric Evans en su libro de 2003, es un conjunto de prácticas para construir software en torno al dominio del negocio, no a la tecnología. Su tesis central es que la complejidad esencial de un sistema vive en sus reglas de negocio, y que conviene modelarlas de forma explícita, con un lenguaje compartido entre desarrolladores y expertos del dominio.
DDD se suele dividir en dos planos:
- Estratégico: trata de cómo se descompone un sistema grande en partes coherentes. Aquí viven los bounded contexts (límites donde un término del lenguaje tiene un significado preciso), el context mapping (cómo se relacionan esos contextos: shared kernel, conformist, anti-corruption layer, customer-supplier…), y técnicas de descubrimiento como el event storming.
- Táctico: trata de cómo se modela cada contexto por dentro. Aparecen los entities (con identidad estable y ciclo de vida), los value objects (inmutables y comparados por valor), los aggregates (grupos de entidades con un root que protege invariantes), los repositories (abstracciones tipo colección sobre la persistencia), los domain services (operaciones de dominio que no encajan en una entidad), los application services (orquestadores de casos de uso) y los domain events (hechos del pasado que el dominio publica).
Lo que DDD no es: un patrón de carpetas; una excusa para abstraer prematuramente; una garantía de buen software. DDD ayuda cuando hay complejidad de dominio que justifica la inversión de modelado; en proyectos pequeños o donde el negocio se reduce a CRUD contra una tabla, suele ser sobre-diseño.
Capas: ¿por qué la dependencia va hacia dentro?
La regla cardinal de la arquitectura en capas (también llamada hexagonal o de clean architecture, dependiendo de a quién pregunte) es que el dominio no conoce a nadie más. No importa Express, no importa Postgres, no importa el formato JSON. El dominio define puertos (interfaces) que las capas exteriores implementan (adaptadores). Esa inversión de dependencia es lo que permite probar el dominio con dobles en memoria, sustituir Postgres por DynamoDB sin abrir un solo archivo de dominio, o exponer la misma lógica por HTTP, CLI y cola de mensajes.
Implementación: un value object y un agregado en TypeScript
Empecemos por algo modesto y formativo: un value object Money. Un value object es inmutable, no tiene identidad propia y se compara por valor; dos Money(100, 'EUR') distintos son iguales a todos los efectos.
// domain/shared/Money.ts
export type Currency = 'EUR' | 'USD' | 'MXN';
export class Money {{
private constructor(
public readonly amountInCents: number,
public readonly currency: Currency,
) {{
if (!Number.isInteger(amountInCents)) {{
throw new Error('Money debe representarse en céntimos enteros');
}}
if (amountInCents < 0) {{
throw new Error('Money no admite cantidades negativas');
}}
}}
static of(amount: number, currency: Currency): Money {{
return new Money(Math.round(amount * 100), currency);
}}
add(other: Money): Money {{
this.assertSameCurrency(other);
return new Money(this.amountInCents + other.amountInCents, this.currency);
}}
multiply(factor: number): Money {{
return new Money(Math.round(this.amountInCents * factor), this.currency);
}}
equals(other: Money): boolean {{
return this.amountInCents === other.amountInCents
&& this.currency === other.currency;
}}
private assertSameCurrency(other: Money): void {{
if (this.currency !== other.currency) {{
throw new Error(`Mismatch: ${{this.currency}} vs ${{other.currency}}`);
}}
}}
}}Tres observaciones. Primero, la representación en céntimos enteros es la única que protege de la aritmética de coma flotante en dinero. Segundo, los constructores privados con fábricas estáticas dan margen para futura validación o caché sin romper la API. Tercero, todas las operaciones devuelven nuevas instancias: la inmutabilidad no es un decorado, es lo que hace que Money se comporte como un número.
Subamos un peldaño: un agregado Order cuyo root protege la invariante de que el total no puede exceder un límite y de que un pedido confirmado no puede modificarse.
// domain/orders/Order.ts
import {{ Money }} from '../shared/Money';
type OrderStatus = 'DRAFT' | 'CONFIRMED' | 'CANCELLED';
interface LineItem {{
readonly productId: string;
readonly quantity: number;
readonly unitPrice: Money;
}}
export class Order {{
private status: OrderStatus = 'DRAFT';
private items: LineItem[] = [];
private constructor(
public readonly id: string,
public readonly customerId: string,
private readonly maxTotal: Money,
) {{}}
static create(id: string, customerId: string, maxTotal: Money): Order {{
if (!id || !customerId) throw new Error('id y customerId son requeridos');
return new Order(id, customerId, maxTotal);
}}
addItem(item: LineItem): void {{
this.assertModifiable();
if (item.quantity <= 0) throw new Error('cantidad debe ser positiva');
const newTotal = this.total().add(item.unitPrice.multiply(item.quantity));
if (this.exceedsLimit(newTotal)) {{
throw new Error('El pedido excede el total máximo permitido');
}}
this.items.push(item);
}}
confirm(): void {{
this.assertModifiable();
if (this.items.length === 0) throw new Error('pedido vacío');
this.status = 'CONFIRMED';
}}
cancel(): void {{
if (this.status === 'CANCELLED') return;
this.status = 'CANCELLED';
}}
total(): Money {{
return this.items.reduce(
(acc, it) => acc.add(it.unitPrice.multiply(it.quantity)),
Money.of(0, this.maxTotal.currency),
);
}}
private assertModifiable(): void {{
if (this.status !== 'DRAFT') {{
throw new Error(`No se puede modificar un pedido en estado ${{this.status}}`);
}}
}}
private exceedsLimit(total: Money): boolean {{
return total.amountInCents > this.maxTotal.amountInCents;
}}
}}Lo que este código no hace es tan instructivo como lo que sí hace. No tiene anotaciones de ORM, no sabe de bases de datos, no emite JSON. Sólo guarda invariantes y expresa, en términos del negocio, qué se puede y qué no se puede hacer con un pedido. El día que la persistencia cambie, el Order no necesita un solo retoque.
Repositorios como colecciones, no como DAOs
Un repositorio en DDD se presenta como una colección en memoria de agregados: orderRepository.findById('123'), orderRepository.save(order). La interfaz vive en el dominio; la implementación en la infraestructura.
// domain/orders/OrderRepository.ts
export interface OrderRepository {{
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}}
// infrastructure/persistence/PrismaOrderRepository.ts
import {{ OrderRepository }} from '../../domain/orders/OrderRepository';
import {{ Order }} from '../../domain/orders/Order';
import {{ PrismaClient }} from '@prisma/client';
export class PrismaOrderRepository implements OrderRepository {{
constructor(private readonly prisma: PrismaClient) {{}}
async findById(id: string): Promise<Order | null> {{
const row = await this.prisma.order.findUnique({{ where: {{ id }}, include: {{ items: true }} }});
if (!row) return null;
return rehydrate(row);
}}
async save(order: Order): Promise<void> {{
await this.prisma.order.upsert({{ /* ...mapeo... */ }} as any);
}}
}}rehydrate es la pieza que reconstruye un agregado a partir de filas crudas, restaurando estado privado. Eso a veces requiere un constructor o método estático adicional en Order (Order.rehydrate(...)) marcado como uso interno; un compromiso aceptable.
Application services: el caso de uso
El application service orquesta. No tiene lógica de negocio propia (eso vive en el dominio), pero compone llamadas: cargar el agregado, invocar su método, persistir, emitir eventos, publicar respuesta.
// application/orders/AddItemToOrder.ts
export class AddItemToOrder {{
constructor(private readonly orders: OrderRepository) {{}}
async execute(cmd: AddItemCommand): Promise<void> {{
const order = await this.orders.findById(cmd.orderId);
if (!order) throw new NotFoundError('Order', cmd.orderId);
order.addItem({{
productId: cmd.productId,
quantity: cmd.quantity,
unitPrice: Money.of(cmd.unitPriceAmount, cmd.currency),
}});
await this.orders.save(order);
}}
}}Esa clase es el sitio adecuado para una transacción, un publicador de eventos de dominio, o un retry idempotente. La regla práctica: si el código describe el qué del negocio, va en el dominio; si describe el cómo operativo, va en la aplicación.
El proyecto donde DDD fue sobre-diseño
Aquel primer fiasco era un backoffice de unas cien pantallas que servían, en su mayoría, formularios sobre tablas. No había agregados con invariantes complejas; no había varios contextos; no había un experto de dominio con quien compartir lenguaje. Aplicamos el manual entero (agregados, repositorios, eventos, anti-corruption layer contra el sistema externo) y triplicamos el código sin ganar claridad. La lección: DDD no se aplica «por defecto»; se aplica cuando el dominio justifica la inversión.
El proyecto donde DDD salvó al equipo
El segundo fue un sistema de reservas con tarificación dinámica, ventanas de cancelación, suplementos por evento y políticas que cambiaban por jurisdicción. Sin un modelo explícito de dominio, cada nueva regla obligaba a buscar y modificar el mismo cálculo en cuatro sitios distintos. La introducción de un agregado Reservation con invariantes claras, value objects para PricingPolicy y CancellationWindow, y domain events (ReservationConfirmed, ReservationCancelled) cambió el ritmo del equipo: cada nueva regla aterrizaba en un único método, con tests unitarios que no necesitaban ni base de datos ni HTTP.
Hallazgos
- El lenguaje ubicuo es el activo más subestimado. Cuando el código, los tests, los documentos y las conversaciones usan los mismos términos con el mismo significado, la cantidad de malentendidos se desploma.
- Los value objects devuelven más por euro invertido que los agregados. Sustituir tipos primitivos (strings y numbers anónimos) por value objects tipados resuelve clases enteras de bugs por construcción.
- La capa de dominio es la que más vive. Frameworks van y vienen; bases de datos se cambian; lenguajes envejecen; las reglas de negocio, en cambio, suelen ser las más estables del sistema. Proteger ese código de las dependencias externas es protección a futuro, no purismo.
- No aplique DDD si su dominio es trivial. Lo que en un sistema complejo es claridad, en uno simple es ceremonia.
Closure
DDD no es una metodología que se compre y se aplique; es un repertorio de herramientas mentales que conviene tener disponible. Aplicado a destiempo, sobra; aplicado a tiempo, transforma. Mi consejo, después de los dos extremos: empiece por lo barato (el lenguaje ubicuo, los value objects, una capa de dominio sin dependencias), y suba el listón sólo cuando el dominio se lo pida. Si su dominio nunca se lo pide, enhorabuena; tiene un problema más sencillo de lo que su libro de arquitectura preferido le había hecho creer.