Introduccion
"Mas o menos se que es DDD, pero que tengo que hacer realmente para que algo cuente como DDD?"
"Cual es la diferencia real entre una Entity y un Value Object?"
"Si creo un Repository, eso ya lo convierte en DDD?"
Probablemente muchas personas tienen preguntas como estas. En este articulo, apoyandome en Domain-Driven Design de Eric Evans y Implementing Domain-Driven Design de Vaughn Vernon, voy a reorganizar la esencia de DDD en una forma que realmente puedas usar en la practica.
Las explicaciones de los terminos principales estan alineadas lo mas estrechamente posible con DDD Reference de Eric Evans y con la vision general oficial de Vaughn Vernon.
Que es DDD?
Domain-Driven Design (DDD) es un enfoque de diseno basado en la idea de que, al desarrollar software con logica de negocio compleja,
el dominio debe ocupar el centro del software, y el codigo debe escribirse en el lenguaje de ese dominio
La idea clave es que DDD no es ni un framework ni una arquitectura. Es una coleccion de ideas y tecnicas. Por eso, agregar una libreria de DDD no hace que tu sistema sea automaticamente "DDD".
DDD se divide, a grandes rasgos, en dos capas
DDD puede dividirse en las dos partes siguientes.
| Categoria | Tambien llamada | Que hace |
|---|---|---|
| Diseno estrategico | Strategic Design | Divide el dominio de forma apropiada y dibuja limites claros |
| Diseno tactico | Tactical Design | Expresa el dominio al nivel del codigo |
Es facil pensar que "DDD significa crear Entities y Value Objects", pero eso solo cubre el lado tactico. Lo que importa aun mas es el diseno estrategico.
Diseno estrategico
Ubiquitous Language
Este es el punto de partida de DDD y su concepto mas importante.
Los expertos del dominio, los desarrolladores y los stakeholders usan las mismas palabras
Por ejemplo, cuando la gente de un sitio de comercio electronico dice "order":
- Marketing: "El momento en que un articulo entra al carrito ya es un pedido"
- Contabilidad: "El momento en que se completa el pago es un pedido"
- Logistica: "El momento en que se emite la orden de envio es un pedido"
Es muy comun que el significado varie de una persona a otra. Si llevas esa ambiguedad directamente al codigo, nadie sabra que se supone que representa la clase Order.
Haz que el modelo sea la columna vertebral del lenguaje y sigue usando ese lenguaje tanto en la conversacion como en el codigo. Esa es la base de DDD.
Bounded Context
Un lenguaje ubicuo no necesita unificarse en toda la organizacion y, de hecho, normalmente no puede hacerlo.
Por eso se dibuja un limite que dice: "Dentro de este alcance, esta palabra significa esto". Eso es un Bounded Context. El limite no es solo un acuerdo conversacional. Tambien aparece en la estructura del equipo, en el alcance donde el modelo se aplica dentro de la aplicacion y en la separacion de bases de codigo y almacenes de datos.
flowchart LR
subgraph Sales["Contexto de ventas"]
S["Order =<br/>Intencion de compra tras el checkout"]
end
subgraph Logistics["Contexto logistico"]
L["Order =<br/>Paquete listo para ser enviado"]
end
N["El mismo Order puede significar cosas distintas"]
S -.-> N
L -.-> N
classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
class S,L context
class N noteEl mapa de estos limites es el context map. Tambien es una guia muy poderosa al decidir los limites de los microservicios.
Como extraer un dominio a partir de los requisitos
En algun momento todo el mundo pregunta: "Entonces, como encuentro realmente el dominio?" Aqui van algunos enfoques que de verdad sirven en proyectos reales.
1. Trata las entrevistas como una oportunidad para recopilar verbos y sustantivos
Cuando hables con clientes o con responsables del negocio, separa conscientemente los sustantivos = modelos candidatos de los verbos = comportamientos candidatos, y anotales de esa manera.
"Un representante de ventas crea una cotizacion, la envia al cliente y, cuando queda aprobada, la convierte en un pedido."
Solo de esa frase puedes extraer:
- Sustantivos: representante de ventas, cotizacion, cliente, pedido -> candidatos a Entities / Value Objects
- Verbos: crear, enviar, aprobar, convertir en pedido -> candidatos a comportamientos / domain events
Las palabras que la gente del negocio usa de forma inconsciente son el mineral bruto del lenguaje ubicuo.
2. Usa EventStorming
Para dominios complejos, EventStorming, donde dispones eventos en notas adhesivas sobre una pizarra o en herramientas como Miro o FigJam, es extremadamente eficaz.
flowchart LR
E1["🟧 Cotizacion solicitada"] --> E2["🟧 Cotizacion creada"] --> E3["🟧 Cotizacion enviada"]
E3 --> C2["🟦 Comando<br/>Aprobar cotizacion"] --> E4["🟧 Cotizacion aprobada"] --> E5["🟧 Pedido aceptado"]
E3 --> C1["🟦 Comando<br/>Cancelar cotizacion"] --> E6["🟧 Cotizacion cancelada"]
A1["🟪 Aggregate<br/>Cotizacion"] -.dispara los eventos.-> E2
R1["🟨 Regla<br/>Solo las cotizaciones aprobadas pueden convertirse en pedidos"] -.restriccion.-> E5
classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
class E1,E2,E3,E4,E5,E6 event
class C1,C2 command
class A1 aggregate
class R1 rule- 🟧 Notas naranjas: domain events, escritos en pasado
- 🟦 Notas azules: commands, es decir, la accion de alguien
- 🟪 Notas moradas: aggregates, las cosas que disparan los eventos. Ejemplo:
Cotizacion - 🟨 Notas amarillas: reglas de negocio / restricciones. Ejemplo: "Solo las cotizaciones aprobadas pueden convertirse en pedidos"
Por ejemplo, "Aprobar cotizacion" es un comando porque es una instruccion que alguien ejecuta. En cambio, "Cotizacion aprobada" es un domain event porque es un hecho que ya ocurrio.
Hacer esto junto con stakeholders del negocio hace que el conocimiento tacito se vuelva visible de golpe. Su mayor valor es que las conversaciones surgen naturalmente: "Ah, en este punto se toma una decision?" "En realidad, en este caso hay una excepcion..."
3. Pregunta siempre por los casos excepcionales
La fuente mas rica de informacion en las entrevistas de requisitos es el flujo de excepcion.
| Pregunta | Lo que revela |
|---|---|
| "En que situaciones esto no se puede hacer?" | Invariantes y reglas de negocio |
| "Que tipos de casos generaron disputas en el pasado?" | Restricciones ocultas |
| "Que cosas se estan resolviendo manualmente?" | Reglas de negocio que nunca se implementaron |
| "Que ha cambiado en esta operacion durante los ultimos cinco anos?" | Partes volatiles frente a partes estables |
Si solo preguntas por el happy path, la forma del dominio nunca termina de aparecer. La esencia del dominio se esconde en las partes dolorosas.
4. Clasificalos en core, supporting y generic
No todos los dominios extraidos necesitan implementarse con la misma intensidad.
| Categoria | Descripcion | Estrategia |
|---|---|---|
| Core Domain | La fuente de la ventaja competitiva del negocio | Construirlo cuidadosamente en casa con DDD |
| Supporting Subdomain | Capacidades de negocio que sostienen el nucleo | Implementarlo internamente de forma ligera o externalizarlo |
| Generic Subdomain | Comunes a cualquier empresa, como autenticacion o pagos | Usar SaaS / OSS |
Tomando como ejemplo un sitio de comercio electronico:
- Core: recomendaciones de producto, estrategia de precios, asignacion de inventario
- Supporting: gestion del catalogo de productos, coordinacion de entregas
- Generic: autenticacion, pagos, envio de correos
Si intentas ir a fondo con todo, el proyecto acabara colapsando. Dejar explicito donde merece la pena invertir esfuerzo es el primer paso del diseno de limites.
5. Criterios para dibujar limites de contexto
Decidir donde dibujar limites requiere experiencia, pero las siguientes senales ayudan mucho.
- 🚩 La misma palabra cambia de significado (el ejemplo de
Orderdel inicio) - 🚩 Cambia la organizacion o departamento responsable (la ley de Conway)
- 🚩 El ciclo de vida es distinto (los pedidos y los clientes no viven en la misma linea temporal)
- 🚩 La velocidad de cambio es distinta (la estrategia de precios cambia a menudo; la gestion de direcciones, no tanto)
- 🚩 El nivel de consistencia requerido es distinto (el inventario necesita consistencia fuerte; las recomendaciones pueden tolerar consistencia eventual)
Cuando detectes uno de estos desajustes, probablemente habras encontrado un limite candidato.
Diseno tactico
Desde aqui pasamos al codigo.
Value Object
Un Value Object representa "el valor en si mismo" y tiene las siguientes caracteristicas:
- Inmutable
- Se compara por igualdad de valor y no por identidad
- No tiene efectos colaterales
// ❌ Solo una cadena
const email: string = "user@example.com";
// ✅ Value Object
class Email {
constructor(private readonly value: string) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error("Direccion de correo no valida");
}
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
La idea es encapsular la restriccion en el tipo que dice "esto es una direccion de correo valida". La validacion deja de estar dispersa por todo el codigo.
Entity
Una Entity es un objeto cuya identidad esta determinada por un identificador (ID).
class User {
constructor(
public readonly id: UserId,
private name: UserName,
private email: Email,
) {}
changeEmail(newEmail: Email): void {
this.email = newEmail;
}
equals(other: User): boolean {
return this.id.equals(other.id);
}
}
Aunque cambien el nombre o el email, si el id es el mismo, sigue siendo el mismo User. Esa es la esencia de una Entity.
Aggregate
Un Aggregate es un limite de consistencia que agrupa varias Entities y Value Objects en una sola unidad.
La entidad que sirve como punto de entrada se llama Aggregate Root, y el codigo externo debe acceder siempre al aggregate a traves de esa raiz.
// Aggregate root
class Order {
private items: OrderItem[] = [];
constructor(
public readonly id: OrderId,
private readonly userId: UserId,
) {}
// La aggregate root protege la consistencia dentro del aggregate.
addItem(product: Product, quantity: number): void {
if (this.items.length >= 100) {
throw new Error("Se ha superado el limite de articulos por pedido");
}
this.items.push(new OrderItem(product.id, quantity, product.price));
}
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
}
Una regla basica es: mantener los aggregates lo mas pequenos posible. Si crecen demasiado, sufriras conflictos de actualizacion y costes de carga elevados.
Repository
Un Repository es una abstraccion responsable de persistir aggregates. Existe para que la capa de dominio no dependa de la infraestructura.
// Capa de dominio: define solo la interfaz.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// Capa de infraestructura: coloca la implementacion fuera.
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> { /* ... */ }
async save(order: Order): Promise<void> { /* ... */ }
}
Lo importante aqui es que un Repository es una abstraccion que proporciona acceso global a aggregate roots y permite a quien lo llama tratarlos como una coleccion. No lo pienses como "algo que guarda en la base de datos", sino como "la puerta de entrada para almacenar y recuperar aggregates".
Domain Service
Un Domain Service es el lugar donde colocas procesos o transformaciones importantes del dominio que no encajan de forma natural como responsabilidades de una Entity o de un Value Object.
// Una comprobacion de email duplicado no puede decidirla User por si solo.
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
Un flujo de decision para cuando dudas: Entity o Service?
Durante la implementacion, una de las preguntas mas comunes es: "Esta logica deberia vivir en la Entity o en un Service?" El siguiente flujo cubre la mayoria de los casos.
flowchart TB
Start["La logica..."] --> Q1{"solo cambia el estado de un unico objeto?"}
Q1 -- SI --> E["Metodo de la Entity"]
Q1 -- NO --> Q2{"toma una decision que cruza varios aggregates?"}
Q2 -- SI --> D["Domain Service"]
Q2 -- NO --> Q3{"requiere un repository o un servicio externo?"}
Q3 -- SI --> D
Q3 -- NO --> Q4{"suena mas natural como verbo que como sustantivo?"}
Q4 -- SI --> D
Q4 -- NO --> V["Entity / Value Object"]
classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
class Q1,Q2,Q3,Q4 question
class E,D,V answer✅ Que deberia vivir en una Entity
La logica que protege su propio estado y sus invariantes pertenece a la Entity.
class Order {
private status: OrderStatus;
private items: OrderItem[];
// ✅ Protege sus propias transiciones de estado.
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error("No se puede cancelar un pedido ya enviado");
}
this.status = OrderStatus.CANCELLED;
}
// ✅ Logica que solo calcula a partir de sus propios datos.
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
// ✅ Comprueba su propio invariante.
addItem(item: OrderItem): void {
if (this.items.length >= 100) throw new Error("Se ha superado el limite");
this.items.push(item);
}
}
Un criterio util es este: si suena natural personificarlo y preguntar "Order, puedes cancelarte?", entonces probablemente sea responsabilidad de la Entity.
✅ Que deberia vivir en un Domain Service
La logica que no puede decidir una sola Entity por si sola, o que requiere interaccion con el exterior, pertenece a un Domain Service.
// ✅ Logica de transferencia que cruza dos aggregates.
class TransferService {
transfer(from: Account, to: Account, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
// El concepto de transferencia no pertenece solo a una cuenta.
}
}
// ✅ Una comprobacion de duplicados que requiere un repository.
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
Otro criterio util es este: si preguntas "User, sabes si esta direccion de correo ya esta registrada por otra persona?" y la respuesta natural es "Como voy a saberlo?", entonces pertenece a un Service.
❌ Antipatron: ponerlo todo en un Service
// ❌ No hagas esto.
class OrderService {
cancel(order: Order): void {
if (order.status === "SHIPPED") throw new Error(/* ... */);
order.status = "CANCELLED"; // reescritura mediante setter
}
totalPrice(order: Order): Money { /* recorrer items y sumar */ }
addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
status: string;
items: OrderItem[];
// solo getters / setters ← un anemic domain model
}
Esto es un transaction script tipico. La Entity se ha convertido en poco mas que una estructura de datos. El principio es: si el comportamiento puede escribirse con Order como sujeto, escribelo dentro de Order.
En resumen
| Perspectiva | Ponlo en Entity | Ponlo en Domain Service |
|---|---|---|
| Sujeto | El propio objeto | Un verbo o accion de negocio |
| Estado | Cambia su propio estado | Stateless |
| Dependencias | No depende de un Repository | Puede depender de Repositories / sistemas externos |
| Alcance | Dentro de un solo aggregate | A traves de varios aggregates |
| Ejemplo | order.cancel() |
transferService.transfer(a, b, money) |
Domain Event
Un Domain Event es un objeto que representa "algo importante que ocurrio en el dominio". Encaja especialmente bien con microservicios y procesamiento asincrono, por eso ha recibido mucha atencion en los ultimos anos.
class OrderPlaced {
constructor(
public readonly orderId: OrderId,
public readonly userId: UserId,
public readonly occurredAt: Date,
) {}
}
Permite separar efectos secundarios de forma desacoplada, por ejemplo: "pedido realizado -> enviar email" o "pedido realizado -> otorgar puntos".
Arquitectura por capas
DDD suele implementarse con una estructura de capas como la siguiente.
flowchart TB
P["Capa de Presentacion<br/>UI / API"]
A["Capa de Aplicacion<br/>Casos de uso"]
D["Capa de Dominio<br/>Modelo / Reglas<br/>Actor principal"]
I["Capa de Infraestructura<br/>DB / API"]
P --> A --> D
I --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class P,A,I layer
class D domainLas dependencias siempre apuntan hacia dentro. La capa de Dominio no depende de ninguna de las demas. Para imponer esto con rigor, se suele recurrir a Onion Architecture o a Hexagonal Architecture (Ports and Adapters).
Haz explicita la responsabilidad de cada capa
En el desarrollo real, una de las formas mas sencillas de crear caos es el problema de que la logica se filtre por todas las capas. Conviene recordar el rol de cada una en una frase.
| Capa | Responsabilidad | Lo que no debe hacer |
|---|---|---|
| Presentacion | Aceptar peticiones y presentar resultados | Escribir reglas de negocio |
| Aplicacion | Coordinar casos de uso | Escribir reglas de negocio |
| Dominio | Contener las reglas de negocio en si mismas | Depender de frameworks o bases de datos |
| Infraestructura | Implementar persistencia y comunicacion externa | Tomar decisiones de negocio |
La restriccion clave es fuerte y simple: las reglas de negocio pertenecen solo a la capa de Dominio. Si mantienes esa regla, la logica de negocio sobrevivira incluso cuando cambie el framework.
El papel de la capa de Presentacion
No deberia hacer mas que aceptar la entrada de usuarios u otros sistemas, pasarla a la capa de Aplicacion y devolver el resultado. La regla es: mantenla lo mas fina posible.
// ✅ Buen controller: simplemente hace de pasarela.
@Controller("orders")
class OrderController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
@Post()
async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
// 1. Da forma a la peticion (conviertela a un DTO).
const command = new PlaceOrderCommand(
body.userId,
body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
);
// 2. Delegar en el caso de uso.
const result = await this.placeOrder.execute(command);
// 3. Dar forma a la respuesta y devolverla.
return OrderResponse.from(result);
}
}
Lo que debe hacer la capa de Presentacion:
- Analizar y validar el formato de la peticion, como campos obligatorios, tipos y longitud de cadenas
- Ejecutar autenticacion y autorizacion en el punto de entrada
- Convertir los resultados a la forma de la respuesta
- Elegir los codigos de estado HTTP
Lo que no debe hacer es juicio de negocio, como "devolver 400 si no hay inventario". Ese es trabajo de la capa de Dominio. Presentacion simplemente traduce al lenguaje HTTP el resultado producido por el Dominio.
El papel de la capa de Aplicacion
Esta es la capa que mas malentendidos provoca en DDD. Un Application Service es un coordinador de casos de uso. No contiene logica de negocio.
// ✅ Buen Application Service: solo coordina el flujo.
class PlaceOrderUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly productRepository: ProductRepository,
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService, // Domain Service
private readonly transactionManager: TransactionManager,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderResult> {
return this.transactionManager.run(async () => {
// 1. Cargar los aggregates requeridos.
const user = await this.userRepository.findById(command.userId);
if (!user) throw new UserNotFoundError(command.userId);
const products = await this.productRepository.findByIds(
command.items.map(i => i.productId),
);
// 2. Delegar el procesamiento en el dominio (las reglas viven en Domain).
const order = Order.place(user, products, command.items);
// 3. Llamar al domain service (decision que cruza varios aggregates).
await this.inventoryService.reserve(order);
// 4. Persistir.
await this.orderRepository.save(order);
// 5. Publicar domain events.
await this.eventPublisher.publishAll(order.pullEvents());
return OrderResult.from(order);
});
}
}
Estas son las cinco responsabilidades de un Application Service.
- Cargar aggregates llamando a Repositories
- Delegar en el dominio dejando que Entities o Domain Services ejecuten las reglas
- Gestionar los limites de transaccion
- Ordenar la persistencia guardando a traves de Repositories
- Publicar domain events
Por contraste, este es un ejemplo tipico de codigo que no debe vivir en un Application Service:
// ❌ Antipatron: las reglas de negocio se fugan a la capa de Aplicacion.
class PlaceOrderUseCase {
async execute(command: PlaceOrderCommand) {
// ❌ Comprobar el stock aqui fuga conocimiento de dominio.
if (product.stock < command.quantity) {
throw new Error("Sin stock");
}
// ❌ Calcular el total aqui deberia ser trabajo de Order.
const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
// ❌ Decidir sobre el estado aqui deberia ser trabajo de Order.
if (user.status === "BLOCKED") throw new Error("Cuenta suspendida");
await this.orderRepository.save({ /* ... */ });
}
}
Una buena prueba es: "Si enseno este codigo a una persona del negocio, puede ver las reglas de negocio ahi?" Si las decisiones de negocio se estan tomando en if dentro de esta capa, es una senal de que esa logica debe moverse al Dominio.
El flujo de una peticion a traves de las capas
Sigamos el flujo de cuando un usuario pulsa el boton de realizar pedido durante el desarrollo real.
sequenceDiagram
participant Browser
participant Presentation as Presentacion / OrderController
participant Application as Aplicacion / PlaceOrderUseCase
participant Domain as Dominio / Order.place(...)
participant Infrastructure as Infraestructura / Implementacion del repository
Browser->>Presentation: POST /orders
Presentation->>Application: Pasar PlaceOrderCommand
Note over Application: Iniciar transaccion
Application->>Infrastructure: userRepository.findById()
Application->>Infrastructure: productRepository.findByIds()
Infrastructure-->>Application: User / Product aggregates
Application->>Domain: Order.place(user, products, items)
activate Domain
Domain->>Domain: Validar reglas de negocio
Domain->>Domain: Comprobar invariantes
Domain->>Domain: Crear el evento OrderPlaced
Domain-->>Application: Order aggregate y OrderPlaced
deactivate Domain
Application->>Domain: inventoryService.reserve(order)
Domain-->>Application: Resultado de la reserva de inventario
Application->>Infrastructure: orderRepository.save(order)
Application->>Infrastructure: eventPublisher.publishAll(...)
Infrastructure-->>Application: Persistencia completada
Note over Application: Confirmar transaccion
Application-->>Presentation: OrderResult
Presentation-->>Browser: 201 CreatedComo muestra este flujo, las decisiones de negocio ocurren solo dentro de la capa de Dominio. Las demas capas solo hacen la preparacion y la limpieza necesarias para que el Dominio haga bien su trabajo.
El limite entre DTOs y objetos de dominio
Al cruzar capas, la practica estandar es no exponer objetos de dominio directamente al exterior.
// Capa de dominio
class Order { /* Aggregate con logica de negocio */ }
// Capa de aplicacion: DTOs de entrada / salida
class PlaceOrderCommand { /* DTO de peticion */ }
class OrderResult { /* DTO de respuesta */ }
// Capa de presentacion: tipos orientados a HTTP
class PlaceOrderRequest { /* esquema JSON */ }
class OrderResponse { /* JSON de respuesta */ }
Puede parecer tedioso, pero esto aporta mucho:
- El Dominio permanece intacto aunque cambie la forma del API
- Las preocupaciones de serializacion ya no contaminan el dominio
- Cambiar a GraphQL o gRPC se convierte en un cambio localizado
En particular, nunca anadas decoradores ORM como @Column o anotaciones de serializacion como @Expose directamente a un Domain Object. En el momento en que lo haces, el Dominio empieza a depender del framework.
Orden recomendado de implementacion
Por ultimo, este es un orden de trabajo recomendado al implementar una funcionalidad nueva con DDD.
- Escribe el caso de uso en una sola frase: "Un usuario compra un producto"
- Extrae los terminos que aparecen: usuario / producto / pedido / inventario
- Decide los aggregates y sus limites: que pertenece dentro del aggregate
Order - Empieza por la capa de Dominio: Entity, Value Object, Domain Service
- Escribe pruebas de la capa de Dominio (deberian ejecutarse sin base de datos)
- Escribe el Application Service: sigue usando Repositories como interfaces
- Implementa la capa de Infraestructura: implementaciones reales de Repository, configuracion del ORM
- Implementa la capa de Presentacion: controller, tipos de peticion y tipos de respuesta
- Pruebas E2E
La clave es escribir de dentro hacia fuera. Empezar por el diseno de tablas de base de datos va contra el espiritu de DDD. El modelo de dominio va primero; las tablas vienen despues. Ese principio no es negociable.
Buenas practicas para la estructura de carpetas
Esta es la primera pregunta con la que mucha gente se topa al organizar un proyecto DDD.
Conviene dividir por capa, o por dominio / funcionalidad?
La respuesta corta es: el enfoque estandar es primero el dominio, y luego las capas dentro de el.
❌ Primero por capas (cercano a un antipatron)
src/
├── controllers/
│ ├── user.controller.ts
│ ├── order.controller.ts
│ └── product.controller.ts
├── services/
│ ├── user.service.ts
│ ├── order.service.ts
│ └── product.service.ts
├── repositories/
│ ├── user.repository.ts
│ ├── order.repository.ts
│ └── product.repository.ts
└── entities/
├── user.entity.ts
├── order.entity.ts
└── product.entity.ts
Al principio es facil de entender, pero a medida que el sistema crece:
- Cambiar un unico caso de uso implica moverse por cuatro carpetas
- Los limites del dominio se vuelven invisibles porque todo parece plano
- Se vuelve dificil extraer servicios mas adelante si evolucionas hacia microservicios
El dolor crece muy rapido.
✅ Primero por dominio (recomendado)
src/
├── contexts/ ← Organizado por Bounded Context
│ ├── ordering/ ← Contexto de pedidos
│ │ ├── domain/
│ │ │ ├── order.ts (Entity / Aggregate Root)
│ │ │ ├── order-item.ts
│ │ │ ├── order-id.ts (Value Object)
│ │ │ ├── order-status.ts
│ │ │ ├── order.repository.ts(interface only)
│ │ │ └── events/
│ │ │ └── order-placed.ts
│ │ ├── application/
│ │ │ ├── place-order.usecase.ts
│ │ │ └── cancel-order.usecase.ts
│ │ ├── infrastructure/
│ │ │ ├── postgres-order.repository.ts
│ │ │ └── stripe-payment.gateway.ts
│ │ └── presentation/
│ │ ├── order.controller.ts
│ │ └── dto/
│ │ ├── place-order.request.ts
│ │ └── order.response.ts
│ ├── catalog/ ← Contexto del catalogo
│ │ └── (misma estructura)
│ └── shipping/ ← Contexto de envios
│ └── (misma estructura)
├── shared-kernel/ ← Value Objects compartidos entre todos los contextos
│ ├── money.ts
│ ├── email.ts
│ └── user-id.ts
└── shared/ ← Base tecnica transversal
├── logger.ts
├── transaction-manager.ts
└── event-bus.ts
Esta distribucion tiene tres ventajas importantes.
- Los limites del contexto se vuelven visibles fisicamente. Surge de manera natural un diseno autocontenido dentro de
ordering/. - La extraccion a microservicios se vuelve mas facil. Puedes sacar
contexts/ordering/a otro repositorio casi tal cual. - La revision de codigo se vuelve mas enfocada. Puedes ver de un vistazo si un cambio permanece dentro de
ordering.
Organizacion dentro de la capa de Dominio
Cuando domain/ empieza a crecer demasiado, un buen enfoque es dividirlo por aggregate.
ordering/domain/
├── order/
│ ├── order.ts (aggregate root)
│ ├── order-item.ts
│ ├── order-id.ts
│ ├── order-status.ts
│ ├── order.repository.ts
│ └── events/
│ ├── order-placed.ts
│ └── order-cancelled.ts
├── customer/
│ ├── customer.ts
│ └── customer.repository.ts
└── services/
└── pricing.service.ts (Domain Service)
Cuando un aggregate esta organizado como si poseyera su pequeno mundo, sus dependencias tambien se vuelven mucho mas faciles de seguir.
Estructura en monorepo
Cuando varios servicios se gestionan dentro de un mismo repositorio, la estructura se parece mas a esta.
monorepo/
├── apps/
│ ├── web/ (frontend en Next.js)
│ ├── api/ (API backend)
│ └── admin/ (interfaz de administracion)
├── packages/
│ ├── ordering-context/ ← Bounded Context empaquetado como unidad
│ ├── catalog-context/
│ ├── shipping-context/
│ └── shared-kernel/
└── package.json
La clave es que cada Bounded Context se convierte en un paquete npm independiente. apps/api solo los importa y utiliza, lo que te permite hacer cumplir las dependencias entre contextos al nivel de paquete.
Pistas sobre convenciones de nombres
Conviene mantener consistencia en nombres de archivos y clases. Estas reglas suelen funcionar bien en la practica.
| Objetivo | Ejemplo |
|---|---|
| Entity / Aggregate Root | Order (sustantivo, singular) |
| Value Object | Email, Money, OrderId |
| Repository (interface) | OrderRepository (termina en Repository) |
| Repository (implementation) | PostgresOrderRepository (tecnologia + Repository) |
| Domain Service | PricingService, TransferService |
| Application Service | PlaceOrderUseCase, CancelOrderUseCase |
| Domain Event | OrderPlaced, OrderCancelled (pasado) |
| Command (DTO) | PlaceOrderCommand |
En particular, poner los Domain Events en pasado es importante. Deja claro que "esto es algo que ya ocurrio", no "algo que estamos a punto de hacer".
Capas en sistemas con UI
Hasta ahora la discusion se ha inclinado hacia el backend. Pero que cambia cuando entra en juego una UI (frontend web)?
Si llevas las cuatro capas del backend tal cual al frontend...
La conclusion es simple: normalmente es mejor no forzar esa estructura de manera directa. El frontend tiene preocupaciones propias, y obligarlo a adoptar el mismo modelo por capas del servidor vuelve el diseno demasiado rigido.
[Preocupaciones propias del frontend]
- Routing
- Estado de pantalla (loading, error, elementos seleccionados, etc.)
- Validacion de formularios (para UX)
- Animacion
- Re-renderizado reactivo
Si metes todo eso dentro de la capa de Dominio, el Dominio empieza a depender del framework, como React o Vue, y el objetivo original se pierde.
Un modelo de capas para frontend
Una separacion practica se parece a esta.
flowchart TB
R["Capa Pages / Routes<br/>Next.js app/, pages/, etc."]
C["Capa Components<br/>UI y preocupaciones visuales"]
V["Capa ViewModel / Hooks<br/>estado y llamadas a casos de uso"]
A["Capa Application<br/>casos de uso / clientes API"]
D["Capa Domain<br/>modelo minimo de dominio frontend"]
R --> C --> V --> A --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class R,C,V,A layer
class D domainLas responsabilidades de cada capa serian las siguientes.
| Capa | Responsabilidad | Ejemplo |
|---|---|---|
| Pages / Routes | Mapear URLs a paginas | Pagina /orders/new |
| Components | Visuales e interacciones | <OrderForm />, <Button /> |
| ViewModel / Hooks | Gestionar el estado de pantalla y disparar casos de uso | useOrderForm() |
| Application | Llamar a APIs y dar forma a los datos | placeOrderUseCase() |
| Domain | Reglas minimas de dominio necesarias en el frontend | Order.canCancel() |
Ejemplo de implementacion en React + TypeScript
// Capa de Dominio: conserva una version delgada de las reglas del backend.
class Order {
constructor(
public readonly id: string,
public readonly status: OrderStatus,
public readonly items: OrderItem[],
) {}
// Coloca aqui la decision de si puede pulsarse el boton Cancelar.
canCancel(): boolean {
return this.status === "PENDING" || this.status === "CONFIRMED";
}
totalPrice(): number {
return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
}
}
// Capa de Aplicacion: un caso de uso que llama al API.
class PlaceOrderUseCase {
constructor(private readonly api: OrderApiClient) {}
async execute(items: CartItem[]): Promise<Order> {
const response = await this.api.placeOrder({ items });
return Order.fromResponse(response);
}
}
// Capa ViewModel / Hooks: gestiona el estado de la pantalla.
function useOrderForm() {
const [items, setItems] = useState<CartItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);
const submit = async () => {
setIsSubmitting(true);
try {
await placeOrder.execute(items);
} catch (e) {
setError(formatError(e));
} finally {
setIsSubmitting(false);
}
};
return { items, setItems, isSubmitting, error, submit };
}
// Capa Components: centrada solo en la presentacion.
function OrderForm() {
const { items, isSubmitting, error, submit } = useOrderForm();
return (
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
{/* JSX */}
</form>
);
}
La idea clave es que el Component puede centrarse solo en la presentacion. El estado vive en el Hook, las reglas de negocio como canCancel() viven en el Dominio, y las llamadas al API viven en la capa de Aplicacion.
Cuanto dominio deberia tener el frontend?
Eso depende de la naturaleza del proyecto.
| Naturaleza del proyecto | Capa Domain en frontend |
|---|---|
| Principalmente pantallas CRUD simples | Casi innecesaria. Se pueden usar respuestas del API directamente |
| Se necesitan calculos o decisiones complejas en la UI | Ten una. Por ejemplo, recalcular precios o decidir que puede seleccionarse |
| Soporte offline / movil | Ten una solida (ver mas adelante) |
Otro punto importante es que no hace falta copiar exactamente el modelo de dominio del servidor. En el frontend, conserva solo las reglas relacionadas con la interaccion del usuario.
BFF como opcion
Si "la forma de los datos del frontend no encaja con la del backend" o "una pantalla necesita combinar multiples APIs", entonces colocar un BFF en medio suele ser eficaz.
flowchart TB
Browser["Navegador"]
BFF["BFF<br/>Next.js API Routes / Hono, etc.<br/>Endpoint optimizado para la pantalla"]
Ordering["Servicio de pedidos"]
Catalog["Servicio de catalogo"]
Inventory["Servicio de inventario"]
Response["Respuesta adaptada a la pantalla"]
Browser --> BFF
BFF --> Ordering
BFF --> Catalog
BFF --> Inventory
Ordering --> BFF
Catalog --> BFF
Inventory --> BFF
BFF --> Response --> Browser
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
class Browser,BFF layer
class Ordering,Catalog,Inventory service
class Response outputUn BFF es una capa fina pensada solo para la pantalla, sin dominio propio. Puede leer a traves de aggregates en una sola llamada o combinar resultados de multiples contextos en un unico JSON adaptado a la UI. Tambien es natural hacerlo funcionar como el lado de consulta de CQRS.
Puede aplicarse DDD a aplicaciones moviles?
"DDD no es mas bien algo del lado servidor?"
Esa es una impresion comun, pero DDD puede aplicarse perfectamente a apps moviles. De hecho, cuando entran en juego el soporte offline y la sincronizacion, los beneficios de un modelo de dominio pueden ser incluso mayores.
Realidades especificas del movil
| Realidad | Impacto |
|---|---|
| Soporte offline | Hace falta una base de datos local -> Repository se vuelve valioso |
| Push notifications / trabajo en segundo plano | Encaja bien con Domain Events |
| Sensores del dispositivo / camara | Tratalos como Infrastructure |
| Ciclo de vida de la app | Hace mas compleja la persistencia del estado |
| Compatibilidad de versiones | Se requiere compatibilidad hacia atras con dispositivos antiguos |
Si todo esto se escribe directamente dentro de Activity, Fragment o ViewController, puedes terminar con un sistema que se derrumba en cuanto cambia el SO.
Ejemplo de estructura por capas en implementaciones nativas (Swift / Kotlin)
App/
├── Domain/
│ ├── Entities/
│ │ ├── Recipe.swift
│ │ └── Ingredient.swift
│ ├── ValueObjects/
│ │ └── CookingTime.swift
│ ├── Repositories/
│ │ └── RecipeRepository.swift (protocol / interface)
├── Application/
│ └── UseCases/
│ └── SearchRecipeUseCase.swift
├── Infrastructure/
│ ├── Repositories/
│ │ ├── CoreDataRecipeRepository.swift (base de datos local)
│ │ └── ApiRecipeRepository.swift (API remota)
│ ├── Storage/
│ │ └── KeyValueStore.swift
│ └── Sync/
│ └── RecipeSyncService.swift
└── Presentation/
├── ViewModels/
│ └── RecipeListViewModel.swift
└── Views/
└── RecipeListView.swift (SwiftUI)
En linea con las secciones anteriores, el UseCase se coloca en la capa de Aplicacion, mientras que la capa de Dominio se mantiene centrada en el modelo y sus reglas.
Un ejemplo donde Repository brilla para el soporte offline
// Capa de Dominio: abstrae multiples destinos de persistencia.
protocol RecipeRepository {
func find(id: RecipeId) async throws -> Recipe?
func save(_ recipe: Recipe) async throws
}
// Capa de Infraestructura: compone local + remoto.
class HybridRecipeRepository: RecipeRepository {
let local: CoreDataRecipeRepository
let remote: ApiRecipeRepository
func find(id: RecipeId) async throws -> Recipe? {
// Puede leer del almacenamiento local incluso sin conexion.
if let cached = try await local.find(id: id) {
return cached
}
let remote = try await remote.find(id: id)
if let recipe = remote {
try await local.save(recipe) // actualizar la cache
}
return remote
}
}
Como la logica offline queda encerrada dentro de la implementacion del Repository, el ViewModel y el Dominio solo tienen que decir "obtenlo". Ese es el poder de DDD.
Aplicarlo en React Native / Flutter
La misma forma de pensar tambien funciona en desarrollo cross-platform. De hecho, una gran ventaja es que puedes compartir una capa de Dominio comun entre la app web y la app movil.
shared/
└── domain/ ← Compartido por web y movil
├── recipe.ts
└── recipe.repository.ts
apps/
├── web/
│ └── infrastructure/
│ └── api-recipe.repository.ts
└── mobile/
└── infrastructure/
└── sqlite-recipe.repository.ts (offline first)
Esta estructura es posible precisamente porque el Dominio no depende de nada mas, lo que representa una de las expresiones mas fuertes de lo que DDD hace bien.
Pero no olvides mantenerlo ligero
Llevar todo el arsenal de DDD del servidor a una app movil es demasiado. En apps moviles:
- Los limites de aggregate no tienen que ser tan estrictos como en el servidor, porque al final la consistencia la garantiza el servidor
- Los Domain Events normalmente solo necesitan ser notificaciones locales de eventos
- En muchos casos, llegar hasta CQRS completo es innecesario
Un equilibrio realista es mantener una version delgada del modelo de dominio del servidor en el cliente.
CRUD, pantallas de administracion y convivencia con DDD
Despues de leer hasta aqui, mucha gente probablemente este pensando: "La mayor parte de nuestra app son pantallas de configuracion y de administracion. Eso sigue siendo DDD?" Esa incomodidad es correcta.
DDD es para dominios complejos. Si lo llevas a lugares que no son complejos, solo consigues codigo mas largo sin beneficio real.
Evalua el "peso" del dominio
Incluso dentro de un mismo proyecto, la complejidad varia segun la funcionalidad.
| Funcionalidad | En que consiste la complejidad | Enfoque de implementacion |
|---|---|---|
| Procesamiento de pedidos | asignacion, calculo de precios, transiciones de estado complejas | Invertir con DDD |
| Gestion de inventario | reserva, liberacion, mantenimiento de consistencia | Invertir con DDD |
| Maestro de productos | mayormente CRUD | Un enfoque ligero basta |
| Configuracion de usuario | almacenamiento simple de valores | CRUD es suficiente |
| Visor de logs de admin | solo consultar y mostrar | CRUD es suficiente |
Lo importante aqui es que la clasificacion anterior entre core, supporting y generic se corresponde directamente con el peso de la implementacion.
| Categoria de dominio | Estilo de implementacion | Ejemplos |
|---|---|---|
| Core Domain | DDD completo | pedidos, inventario, estrategia de precios |
| Supporting Subdomain | DDD ligero o transaction script | maestro de productos, configuraciones de entrega |
| Generic Subdomain | CRUD o SaaS | autenticacion, envio de email, pantallas de configuracion |
Como escribir las partes CRUD
Para las zonas ligeras, esta perfectamente bien escribir transaction scripts sencillos.
// ✅ Esto es suficiente para actualizar una pantalla de configuracion.
class UpdateSiteSettingsHandler {
constructor(private readonly db: Database) {}
async execute(input: UpdateSiteSettingsInput): Promise<void> {
await this.db.siteSettings.update({
where: { id: 1 },
data: input,
});
}
}
Si fuerzas Entities, Repositories y Domain Services aqui, solo aumentas la cantidad de codigo sin obtener nada a cambio.
Mezclar ambos en el mismo proyecto
En la practica, la respuesta realista es cambiar el estilo de implementacion segun el contexto.
src/contexts/
├── ordering/ ← DDD completo
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── presentation/
├── inventory/ ← DDD completo
│ └── (igual que arriba)
├── catalog/ ← DDD ligero (mantener Entities, minimizar Services)
│ └── (version simplificada)
└── admin/ ← Basado en CRUD
├── handlers/ (solo handlers finos)
└── controllers/
Mientras cada contexto sea internamente consistente, eso basta. No hace falta imponer un unico estilo a todo el proyecto.
CQRS resulta util para APIs de solo lectura en administracion
Un caso tipico de pantallas admin es: "Quiero unir datos de muchos aggregates y mostrar una lista". Eso suele significar leer a traves de varios aggregates, lo cual encaja mal con las reglas estrictas de aggregate de DDD.
Aqui es donde resulta util un uso ligero de CQRS: ser estricto con las escrituras a traves de aggregates, y permitir SQL puro para las lecturas.
// Las escrituras pasan por aggregates (estricto).
class CancelOrderUseCase {
async execute(orderId: OrderId): Promise<void> {
const order = await this.orderRepository.findById(orderId);
order.cancel(); // regla de dominio
await this.orderRepository.save(order);
}
}
// Las consultas de solo lectura pueden usar SQL puro (optimizado para la pantalla).
class OrderListQuery {
async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
return this.db.raw(`
SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
FROM orders o
JOIN users u ON ...
JOIN order_items oi ON ...
WHERE ...
`);
}
}
No recuperes aggregates uno a uno para luego unirlos en memoria. Para las lecturas, usa herramientas de lectura. Ese es el enfoque pragmatico.
Principios para mezclar CRUD y DDD
Por ultimo, aqui estan los principios para mezclar ambos.
- Dibuja bien los limites de contexto. Incluso en zonas que no son DDD, conserva la idea de los limites.
- No escatimes en el core domain. Si recortas ahi, te arrepentiras despues.
- Escribe las zonas CRUD de forma simple. No fuerces DDD donde no hace falta.
- Permite una ruta separada para las lecturas. Usa read models o CQRS ligero.
- Promociona despues si la complejidad crece. Si algo que empezo como CRUD se vuelve complejo, muevelo a DDD en ese momento.
DDD no es una religion; es una herramienta. Invierte en proporcion a la complejidad del dominio.
Errores comunes
1. Un anemic domain model
Es el estado en el que una Entity se convierte en poco mas que una bolsa de getters y setters, y la logica se fuga a Services. Recuerda esto: un objeto sin comportamiento no es realmente un objeto.
2. Aplicar DDD a todo
Pantallas de configuracion, pantallas de administracion, CRUD sencillo. Si llevas DDD a todo eso, la velocidad de desarrollo caera sin duda. Usalo solo donde haya un dominio complejo. La seccion anterior sobre CRUD, pantallas admin y convivencia con DDD tambien cubre este punto.
3. Quedarse solo con los patrones tacticos
Crear Entities y Repositories no hace que algo sea DDD. Sin la discusion sobre lenguaje ubicuo y contextos delimitados, los patrones tacticos apenas aportaran valor.
Pasos para empezar con DDD
Si quieres introducir DDD en la practica, recomiendo hacerlo gradualmente en lugar de saltar de golpe al paquete completo.
- Recoge el lenguaje del negocio creando un glosario junto con expertos del dominio
- Se consciente de los limites y dibuja lineas alrededor de lo que pertenece al mismo contexto
- Empieza con Value Objects y reemplaza tipos primitivos por tipos con significado
- Identifica con cuidado los aggregates y piensa en terminos de limites de transaccion
- Usa eventos para desacoplar cuando haga falta introduciendo Domain Events
Cierre
Personalmente, creo que la esencia de DDD es afrontar de frente en el codigo la complejidad del dominio.
Si memorizar patrones se convierte en el objetivo, el codigo suele volverse mas complejo en lugar de mas simple. Hacer crecer el lenguaje ubicuo como equipo es a la vez el punto de partida y, en cierto sentido, el destino de DDD.
Por que no empezar la revision de codigo de manana con una pregunta sencilla: "Tendria sentido este nombre de clase tambien para la gente del negocio?"
Referencias
- Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
- Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
- Vaughn Vernon "Implementing Domain-Driven Design"
- Masanobu Naruse "Introduccion a Domain-Driven Design"
- Toru Masuda "Principios de diseno de sistemas utiles en el trabajo real"
- Martin Fowler "Patterns of Enterprise Application Architecture"
- Robert C. Martin "Clean Architecture"
- Alberto Brandolini "Introducing EventStorming"
