Domain-Driven Design (DDD) explicado paso a paso: de la teoría al código en Python

MasTalentos — 2026-08-31

¿Alguna vez has llegado a un proyecto donde la lógica de negocio está repartida entre controladores, helpers dispersos y consultas embebidas en el ORM? ¿Donde cambiar una regla de "no se puede reservar una habitación ocupada" implica tocar cinco archivos diferentes y rezar para que no se rompa nada?

Si la respuesta es sí, este artículo es para ti.

Hoy vamos a explorar Domain-Driven Design (DDD). No es un framework ni una librería; es una filosofía de diseño que pone las reglas del negocio en el centro del código. Y lo haremos con un enfoque práctico: primero entenderemos los conceptos clave y luego los implementaremos en Python, el lenguaje favorito para la claridad y la rapidez.


1. El problema: ¿Por qué CRUD y tablas no son suficientes?

Empecemos con una analogía. Imagina que construyes una casa. Si tu plano es solo una lista de materiales (ladrillos, cemento, vigas), probablemente la casa se caerá. Necesitas un plano que refleje cómo se usan esas habitaciones (dónde están las puertas, cómo fluye la luz).

En el software tradicional, tendemos a diseñar primero las tablas de la base de datos y luego generamos un CRUD (Crear, Leer, Actualizar, Borrar) sobre ellas. Esto funciona para aplicaciones triviales, pero cuando el negocio es complejo, la lógica se "filtra" por todos lados.

El ejemplo clásico del hotel:
Imagina una tabla Reservas con columnas: id, cliente_id, habitacion_id, fecha_inicio, fecha_fin, precio.

  • ¿Dónde pones la regla: "No se puede reservar una habitación que ya esté ocupada en esas fechas"?

  • ¿Dónde pones: "El precio final depende de la temporada alta, la antigüedad del cliente y el número de noches"?

Si metes estas reglas en el controlador o en un servicio genérico, el día que el negocio cambie (ej. "ahora los clientes VIP tienen un 15% de descuento"), tendrás que buscar por todo el código. DDD dice: diseña tu código alrededor de las reglas del negocio, no alrededor de las tablas.


2. La estrategia global: Contextos delimitados (Bounded Contexts)

Antes de escribir una sola línea de código, DDD nos pide que pongamos límites. En una empresa grande, la palabra "Cliente" significa cosas diferentes para el departamento de Ventas (nombre, teléfono, historial) que para el departamento de Facturación (dirección fiscal, número de identificación tributaria, saldo).

Analogía: Un gran hospital tiene un ala de medicina (donde "paciente" significa síntomas y recetas) y un ala de administración (donde "paciente" significa póliza de seguro y deudas). Si usáramos el mismo modelo en ambas, sería un caos.

La solución: Dividir el sistema en Contextos Delimitados (Bounded Contexts). Cada contexto tiene su propio modelo de datos y su propio equipo.

En nuestro ejemplo del hotel:

  • Contexto de Reservas: Maneja disponibilidad, fechas, cambios de habitación.

  • Contexto de Facturación: Maneja impuestos, métodos de pago, facturas.

  • Contexto de Notificaciones: Maneja correos y mensajes SMS.

¿Cómo se comunican? Mediante Context Mapping. El contexto de Reservas no llama directamente a Facturación. Simplemente publica un evento (ej. ReservaConfirmada). Facturación escucha ese evento y actúa en consecuencia. Así, si el sistema de pagos se cae, las reservas siguen funcionando.


3. Los bloques tácticos: Las piezas de Lego del dominio

Una vez que tenemos los límites claros, pasamos a la parte "táctica": ¿con qué piezas construimos el modelo de negocio dentro de cada contexto?

a) Entidades (Entities) vs. Objetos Valor (Value Objects)

  • Entidad: Tiene una identidad única que la persiste en el tiempo. Aunque cambies su nombre o dirección, sigue siendo la misma.

    • Analogía: Tú y tu cédula de identidad. Aunque cambies de peinado, sigues siendo tú.

    • Ejemplo en código: Reserva(id=12345, cliente=...). El id es su identidad.

  • Objeto Valor: No tiene identidad; se define por sus atributos. Si cambias un atributo, creas uno nuevo. Son inmutables.

    • Analogía: Un billete de $50.000. No importa si es el billete A o el B; valen lo mismo.

    • Ejemplo en código: Dinero(cantidad=100, moneda="USD"). Dos instancias con mismos valores son intercambiables.

b) Agregados (Aggregates) y Repositorios (Repositories)

  • Agregado: Es un clúster de entidades y objetos valor que se tratan como una unidad de consistencia. Siempre se accede a través de una raíz.

    • Analogía: Un coche (la raíz) tiene ruedas y motor. No cambias una rueda directamente; le dices al coche que la cambie, y él se asegura de que todo quede equilibrado.

    • En la práctica: El agregado Reserva contiene al Cliente, el RangoFechas y el Estado. Para confirmar una reserva, llamas a reserva.confirmar(pago), no a reserva.estado = "confirmada".

  • Repositorio: Es la abstracción para guardar y recuperar agregados completos. Oculta si usas MySQL, MongoDB o un archivo.

    • Analogía: El valet parking del coche. Le das la llave y él se encarga de estacionarlo; tú no necesitas saber en qué piso está.

c) Servicios de Dominio (Domain Services)

Cuando una operación de negocio no encaja naturalmente en una sola entidad o valor, se usa un servicio.

  • Ejemplo: CalculadoraPrecioReserva.calcular(habitacion, fechas, cliente). No es propiedad de la habitación ni del cliente; es un cálculo que depende de varios factores.

d) Eventos de Dominio (Domain Events)

Son notificaciones de que algo importante ya sucedió.

  • Ejemplo: ReservaConfirmada. Este evento es publicado por el agregado y escuchado por otros contextos (Facturación para cobrar, Notificaciones para enviar el email). Desacoplan completamente los sistemas.


4. Manos al código: Implementación en Python

Ahora pasemos a la práctica con Python. Usaremos dataclasses (que son perfectas para Objetos Valor y Entidades) y tipado estático para mayor claridad. Crearemos una versión simplificada del sistema de reservas.

Paso 1: Definir los Objetos Valor (Value Objects)

Los objetos valor deben ser inmutables. En Python, usamos @dataclass(frozen=True).

python

from dataclasses import dataclass
from datetime import date
from typing import Optional

@dataclass(frozen=True)
class Dinero:
    cantidad: float
    moneda: str

    def sumar(self, otro: 'Dinero') -> 'Dinero':
        if self.moneda != otro.moneda:
            raise ValueError("No se pueden sumar monedas diferentes")
        return Dinero(self.cantidad + otro.cantidad, self.moneda)

@dataclass(frozen=True)
class RangoFechas:
    inicio: date
    fin: date

    def contiene(self, fecha: date) -> bool:
        return self.inicio <= fecha <= self.fin

    def solapa_con(self, otro: 'RangoFechas') -> bool:
        return self.inicio <= otro.fin and otro.inicio <= self.fin

Paso 2: Definir la Entidad Raíz del Agregado (Aggregate Root)

Aquí está el corazón del negocio. El agregado Reserva contiene toda la lógica de validación. Nadie fuera de esta clase puede modificar el estado de la reserva sin pasar por sus métodos.

python

from enum import Enum
import uuid

class EstadoReserva(Enum):
    BORRADOR = "borrador"
    CONFIRMADA = "confirmada"
    CANCELADA = "cancelada"

@dataclass
class Reserva:
    id: str
    cliente_id: str
    habitacion_id: str
    rango: RangoFechas
    precio: Dinero
    estado: EstadoReserva = EstadoReserva.BORRADOR
    _eventos: list = None  # Lista interna de eventos

    def __post_init__(self):
        if self._eventos is None:
            self._eventos = []

    def confirmar(self, pago: Dinero) -> None:
        # Regla de negocio 1: No se puede confirmar si ya está confirmada o cancelada
        if self.estado == EstadoReserva.CONFIRMADA:
            raise Exception("La reserva ya está confirmada")
        if self.estado == EstadoReserva.CANCELADA:
            raise Exception("No se puede confirmar una reserva cancelada")
        
        # Regla de negocio 2: El pago debe cubrir el precio total
        if pago.cantidad < self.precio.cantidad:
            raise Exception("El pago no cubre el precio total de la reserva")
        
        # Regla de negocio 3: (Simulación) Verificar disponibilidad real con un servicio externo
        # Aquí llamaríamos a un servicio de disponibilidad, pero lo simulamos.
        
        # Si todo está bien, cambiamos el estado
        self.estado = EstadoReserva.CONFIRMADA
        
        # Publicamos un evento de dominio (lo almacenamos en la lista interna)
        self._eventos.append(ReservaConfirmada(
            reserva_id=self.id,
            cliente_id=self.cliente_id,
            habitacion_id=self.habitacion_id,
            rango=self.rango,
            precio_total=self.precio
        ))

    def cancelar(self) -> None:
        if self.estado == EstadoReserva.CONFIRMADA:
            # Si ya está confirmada, debe tener lógica de reembolso, etc.
            self.estado = EstadoReserva.CANCELADA
            self._eventos.append(ReservaCancelada(reserva_id=self.id))
        else:
            self.estado = EstadoReserva.CANCELADA

    def eventos_publicados(self) -> list:
        # Devuelve los eventos y limpia la lista (para que el repositorio los maneje)
        eventos = self._eventos[:]
        self._eventos.clear()
        return eventos

Paso 3: Definir los Eventos de Dominio

Los eventos son simples objetos valor que contienen la información relevante.

python

@dataclass(frozen=True)
class ReservaConfirmada:
    reserva_id: str
    cliente_id: str
    habitacion_id: str
    rango: RangoFechas
    precio_total: Dinero

@dataclass(frozen=True)
class ReservaCancelada:
    reserva_id: str

Paso 4: El Repositorio (Abstracción)

El repositorio es una interfaz (clase abstracta) que define cómo se guardan y recuperan los agregados. La implementación concreta (SQL, Redis, etc.) vive en la capa de infraestructura.

python

from abc import ABC, abstractmethod

class RepositorioReservas(ABC):
    @abstractmethod
    def guardar(self, reserva: Reserva) -> None:
        pass

    @abstractmethod
    def buscar_por_id(self, reserva_id: str) -> Optional[Reserva]:
        pass

    @abstractmethod
    def buscar_por_habitacion_y_fechas(self, habitacion_id: str, rango: RangoFechas) -> list[Reserva]:
        pass

Paso 5: El Servicio de Aplicación (Orquestación)

Aquí está la lógica de aplicación (no de dominio). Esta capa maneja las transacciones, recupera agregados del repositorio, llama a los métodos del dominio y maneja la publicación de eventos (ej. a un bus de mensajes como RabbitMQ o Kafka).

python

class ServicioReservas:
    def __init__(self, repo: RepositorioReservas, bus_eventos):
        self.repo = repo
        self.bus_eventos = bus_eventos  # Inyección de dependencia

    def confirmar_reserva(self, reserva_id: str, pago: Dinero) -> None:
        # 1. Recuperar el agregado
        reserva = self.repo.buscar_por_id(reserva_id)
        if not reserva:
            raise Exception("Reserva no encontrada")
        
        # 2. Ejecutar la lógica del dominio (dentro del agregado)
        reserva.confirmar(pago)
        
        # 3. Persistir el agregado (guardar el cambio)
        self.repo.guardar(reserva)
        
        # 4. Publicar los eventos generados (desacoplamiento)
        for evento in reserva.eventos_publicados():
            self.bus_eventos.publicar(evento)

5. El ciclo de modelado: ¿Cómo nace este código?

DDD no se inventa en una habitación oscura. Nace de la colaboración con los expertos del negocio.

  • Event Storming: Es un taller colaborativo (físico o en Miro) donde se usa post-its para identificar eventos (ej. "Reserva Confirmada"), comandos (ej. "Confirmar Reserva") y agregados (quién toma esa decisión). Todos participan: programadores, analistas, dueños del negocio.

  • El ciclo: Hablar con el experto → identificar reglas → codificarlas en agregados → validar con el experto → repetir.

Regla de oro:

  • Lógica de dominio (reglas del negocio) → Vive en el agregado (ej. reserva.confirmar()).

  • Lógica de aplicación (orquestación técnica) → Vive en el servicio de aplicación (ej. ServicioReservas.confirmar_reserva()).


6. Errores comunes (y cómo evitarlos)

  1. Sobrediseñar todo: DDD es para dominios complejos. No lo uses para un simple catálogo de productos; ahí usa CRUD y ya. DDD tiene un coste de complejidad que solo se justifica en el Core del negocio.

  2. Confundir tablas de BD con agregados: Un agregado no es una tabla. Un agregado es un concepto de negocio que agrupa varias cosas que cambian juntas. Si diseñas tus agregados copiando tus tablas 1 a 1, no estás haciendo DDD, solo estás usando objetos anémicos.

  3. Poner reglas en los servicios: Evita tener un ReservaService.confirmar(reserva) que haga reserva.estado = "confirmada". La lógica debe estar dentro del objeto.

  4. Ignorar el lenguaje ubicuo: Si el negocio dice "Reserva", no llames a tu clase BookingRecord en el código. Usa el mismo idioma en todas partes (código, base de datos, conversaciones).


7. Conclusión y recursos recomendados

Domain-Driven Design no es una bala de plata, pero es una herramienta extraordinaria para domar la complejidad de sistemas grandes y cambiantes. Al poner las reglas del negocio en el centro y usar eventos para desacoplar contextos, logramos un sistema más mantenible, testeable y alineado con lo que realmente importa.

Lecturas esenciales:

  • "Domain-Driven Design: Tackling Complexity in the Heart of Software" de Eric Evans (el "libro azul"): la base teórica.

  • "Implementing Domain-Driven Design" de Vaughn Vernon (el "libro rojo"): más práctico y con ejemplos de código.

En Python, herramientas útiles:

  • dataclasses y pydantic para Value Objects.

  • abc para Repositorios.

  • Para eventos, librerías como pika (RabbitMQ) o kafka-python, o simplemente un bus interno en memoria para empezar.

Tu próximo paso: No intentes comerte el elefante entero. Busca un solo proceso complejo en tu proyecto actual (ej. el cálculo de precios de envío). Organiza una sesión de 1 hora de Event Storming con el experto. Dibuja el flujo en post-its. Luego, codifica un solo agregado en Python con los conceptos que hemos visto hoy. El resto del sistema aprenderá de ese experimento.

← Ver todos los artículos