DDDDomain-Driven DesignArchitectureSoftware Design

Revisando Domain-Driven Design (DDD)

Sloth255
Sloth255
·21 min read·4,630 words

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 note

El 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 Order del 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 domain

Las 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.

  1. Cargar aggregates llamando a Repositories
  2. Delegar en el dominio dejando que Entities o Domain Services ejecuten las reglas
  3. Gestionar los limites de transaccion
  4. Ordenar la persistencia guardando a traves de Repositories
  5. 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 Created

Como 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.

  1. Escribe el caso de uso en una sola frase: "Un usuario compra un producto"
  2. Extrae los terminos que aparecen: usuario / producto / pedido / inventario
  3. Decide los aggregates y sus limites: que pertenece dentro del aggregate Order
  4. Empieza por la capa de Dominio: Entity, Value Object, Domain Service
  5. Escribe pruebas de la capa de Dominio (deberian ejecutarse sin base de datos)
  6. Escribe el Application Service: sigue usando Repositories como interfaces
  7. Implementa la capa de Infraestructura: implementaciones reales de Repository, configuracion del ORM
  8. Implementa la capa de Presentacion: controller, tipos de peticion y tipos de respuesta
  9. 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.

  1. Los limites del contexto se vuelven visibles fisicamente. Surge de manera natural un diseno autocontenido dentro de ordering/.
  2. La extraccion a microservicios se vuelve mas facil. Puedes sacar contexts/ordering/ a otro repositorio casi tal cual.
  3. 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 domain

Las 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 output

Un 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.

  1. Dibuja bien los limites de contexto. Incluso en zonas que no son DDD, conserva la idea de los limites.
  2. No escatimes en el core domain. Si recortas ahi, te arrepentiras despues.
  3. Escribe las zonas CRUD de forma simple. No fuerces DDD donde no hace falta.
  4. Permite una ruta separada para las lecturas. Usa read models o CQRS ligero.
  5. 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.

  1. Recoge el lenguaje del negocio creando un glosario junto con expertos del dominio
  2. Se consciente de los limites y dibuja lineas alrededor de lo que pertenece al mismo contexto
  3. Empieza con Value Objects y reemplaza tipos primitivos por tipos con significado
  4. Identifica con cuidado los aggregates y piensa en terminos de limites de transaccion
  5. 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"