Módulo 12 · Nivel empresarial (integración)

Lección 52 — Clean/Hexagonal y DDD aplicados

El dominio de TicketFlow en el centro y la infraestructura en el borde.

Publicada
En esta lección
  1. Objetivos
  2. 1. Hexagonal: lo que ya tienes (y el nombre)
  3. 2. Value objects: el dinero y las referencias que ya querías
  4. 3. El agregado y el mapeo: la extracción honesta
  5. 4. La decisión: ¿DDD completo para TicketFlow?
  6. 5. El lenguaje ubicuo: la habilidad que no es código
  7. Autoevaluación

Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — apertura del módulo de nivel empresarial Prerrequisito: Lección 51 — Entender el negocio


Objetivos

  1. Reconocer lo que el curso YA construyó: las capas de la 24/25 son hexagonal con otro nombre — y nombrar los conceptos DDD que faltan.
  2. Extraer el dominio de TicketFlow a objetos de dominio puros (entidades, value objects, agregados) y ver el costo real de hacerlo.
  3. Decidir cuándo DDD completo paga y cuándo es el sobre-diseño que el 51 identifica.

1. Hexagonal: lo que ya tienes (y el nombre)

La arquitectura hexagonal (puertos y adaptadores): el dominio en el centro, los detalles (HTTP, BD, Redis, pasarela) en el borde, las flechas de dependencia SOLO hacia adentro. La auditoría del curso: el dominio de TicketFlow ya está en services/ (24) con los Protocols (Clock, PaymentGateway, ReservationRepository — los PUERTOS), las implementaciones en el borde (ORMReservationRepository, SimulatedGateway — los ADAPTADORES), los DTOs hacia fuera (25), el outbox como puerto de eventos (25). Ya es hexagonal: lo que faltaba era el nombre y una cosa: el dominio aún manipula Models de Django (Reservation.objects dentro del servicio). La lección de hoy cierra ese último hueco — CON la decisión de si vale (§4).

        ┌──────────────────────────────────────┐
        │  Django (HTTP) · Celery · CLI        │  ← adaptadores de entrada
        │  ┌────────────────────────────────┐  │
        │  │  APLICACIÓN: casos de uso      │  │
        │  │  ┌──────────────────────────┐  │  │
        │  │  │ DOMINIO: Reservation,    │  │  │
        │  │  │ Money, Checkout — puro   │  │  │
        │  │  └──────────────────────────┘  │  │
        │  │  Puertos: Repos, Clock, GW     │  │
        │  └────────────────────────────────┘  │
        │  Postgres · Redis · Pasarela         │  ← adaptadores de salida
        └──────────────────────────────────────┘

2. Value objects: el dinero y las referencias que ya querías

El value object (VO): sin identidad, inmutable, definido por su valor, con las invariantes en el constructor — el Money de la 08 ya era uno (por eso el 37 lo cazó en el perfilado: era el objeto más caliente). Los que faltan:

python
# domain/values.py — puro, cero Django
@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str = "EUR"

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Money no admite negativos")     # la invariante viva
        if len(self.currency) != 3:
            raise ValueError("moneda ISO-4217")

    def add(self, other: "Money") -> "Money":
        if other.currency != self.currency:
            raise ValueError("monedas distintas")
        return Money(self.amount + other.amount, self.currency)

@dataclass(frozen=True)
class SeatRef:
    value: str
    def __post_init__(self):
        if not re.fullmatch(r"[A-Z]\d{1,3}", self.value):
            raise ValueError(f"asiento inválido: {self.value}")   # A12 sí, "a-12?" no

El valor del VO: la invariante SE EJECUTA donde nace el dato (no en 12 sitios con if), el tipo documenta el dominio (def total(self) -> Money vs -> Decimal), y el test de la 33 los prueba sin Django. El costo: la conversión en el borde (el Model guarda Decimal, el dominio recibe Money — los mappers del §3).

3. El agregado y el mapeo: la extracción honesta

El agregado DDD: el clúster de objetos que se persisten como unidad con sus invariantes — Reservation (+ sus Seats, su Money) es el agregado que la 25 ya aisló con repositorio. La extracción completa al dominio puro:

python
# domain/reservation.py — SIN Django
class Reservation:
    def __init__(self, ref: str, event_id: str, seats: list[SeatRef], money: Money, status: str):
        ...
    def confirmar(self) -> None:
        if self.status != "PENDING":
            raise DomainError(f"transición inválida {self.status} → CONFIRMED")   # la máquina de estados (00b)
        self.status = "CONFIRMED"

    def expira_en(self, clock: Clock, ttl: timedelta) -> bool:
        return clock.now() > self.expires_at

Y el mapper en el adaptador (el único que sabe Django):

python
# adapters/mappers.py
def to_domain(row: ReservationModel) -> Reservation:
    return Reservation(row.public_ref, row.event_id, [SeatRef(s) for s in row.seat_refs],
                       Money(row.total_amount), row.status)

def to_persistence(r: Reservation) -> dict:
    return {"public_ref": r.ref, "seat_refs": [s.value for s in r.seats], "status": r.status, ...}

El costo REAL medido: el servicio reservar() del 25 pasa de manipular el Model (el lock, el select_for_update, el save directo) a: obtener el estado (mapper), operar el dominio, escribir (mapper) — 2 mappers + el refactoreo de la máquina de estados. El beneficio: el dominio se testea en milisegundos SIN BD (la 33 lo pedía a gritos), las invariantes viven en UNA clase, y el transporte (Postgres→otro) es un mapper nuevo. El costo oculto: el lazy-loading desaparece (el agregado se carga COMPLETO: el diseño del repo del 25 debe cargar los seats con prefetch — el agregado grande es lento por diseño).

4. La decisión: ¿DDD completo para TicketFlow?

La matriz honesta del 51 (valor/esfuerzo/riesgo): DDD completo (dominio puro + mappers + repositorios para TODOS los modelos): esfuerzo ~3 semanas, beneficio: dominio testeable puro y fronteras explícitas. El dominio de TicketFlow: 6-8 entidades, 3 agregados con lógica real, 1 lenguaje de negocio estable — el cálculo del 51: partial: VOs (Money, SeatRef) + el agregado Reservation extraído + los puertos de la 24; el CRUD trivial (Venue, la config del 27) sigue con el ORM directo. La regla de decisión (el ADR del 48): "DDD parcial: los VOs y los agregados con invariantes viven en dominio puro; el CRUD y las lecturas viven en Django. Se revisa cuando el segundo servicio (53) o la complejidad de las reglas lo exijan". El DDD completo para UNA app Django CRUD es el sobre-diseño que el mapa del 51 marca.

5. El lenguaje ubicuo: la habilidad que no es código

El concepto DDD más valioso no es técnico: el lenguaje ubicuo — las palabras del negocio son las del código: el negocio dice "reserva", "expira", "se confirma", "tarjeta regalo" — y el código se llama reservar(), expirar(), confirmar(), GiftCard (no AppointmentModel, no GiftCardData). La auditoría del curso: el lenguaje del 00b (la máquina de estados: PENDING/CONFIRMED/EXPIRED) ES el lenguaje del negocio; el Anti-corruption layer (ACL) es el traductor del borde: el payload de la pasarela (charge_status: "settled") se traduce a PaymentSucceeded en UN lugar (el adaptador del 17) — el vocabulario ajeno no contamina el dominio. El beneficio medible: la conversación con el negocio (51) usa las MISMAS palabras que el código: el bug se reporta con el lenguaje del dominio y el traceback se lee en el mismo idioma.


Autoevaluación

  1. ¿Qué partes de la 24/25 ya eran hexagonal y cuál era el hueco que quedaba (el Model dentro del servicio)?
  2. ¿Qué garantiza un value object y qué dos invariantes ejecuta el Money del curso? ¿Dónde vive la invariante?
  3. ¿Qué es un agregado y qué costo oculto tiene el agregado grande (el lazy-loading)? ¿Qué exige del repositorio?
  4. ¿Qué decide "DDD parcial" para TicketFlow y cuál sería el trigger del DDD completo (el ADR)?
  5. ¿Qué es el lenguaje ubicuo y dónde se vio el Anti-corruption layer del curso (el payload de la pasarela)?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.