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. Ejercicio 1 — La auditoría hexagonal
  2. Ejercicio 2 — Los value objects
  3. Ejercicio 3 — El agregado
  4. Ejercicio 4 — La decisión
  5. Ejercicio 5 — El lenguaje ubicuo
  6. Resumen del profesor

Ejercicio 1 — La auditoría hexagonal

  1. El anillo con la flecha que mordía: el servicio reservar() manipulaba ReservationModel (el Model DENTRO del dominio: la flecha hacia fuera) y settings.FEATURE_WEBHOOKS importado en services (la config del 27 filtrando al dominio). Tras la extracción: el dominio recibe flags por parámetro (la 24: inyectado).
  2. Los puertos faltantes: el email era send_mail directo en la tarea (infra en el dominio del worker) → el puerto Mailer + SMTPMailer (adaptador) + FakeMailer (33). El rate limiter del 23 ya vivía detrás de cache (puerto implícito): formalizado como Limiter.
  3. El grep: from django en domain/ → vacío tras la extracción. import models en services → quedó SOLO en el mapper/adapters (la decisión documentada: el active record de la 00b persiste para el CRUD, el dominio puro para el agregado).

Ejercicio 2 — Los value objects

  1. Los tests puros:
python
def test_money_rechaza_monedas_distintas():
    with pytest.raises(ValueError):
        Money("10", "EUR").add(Money("5", "USD"))

def test_seatref_valida_formato():
    assert SeatRef("A12").value == "A12"
    with pytest.raises(ValueError):
        SeatRef("a-12?")
  1. El Percentage: Percentage(5) (la comisión del 08) con invariante 0-30 y of(money) -> Money: la comisión del org dejó de ser un Decimal(0.05) sin reglas en 3 lugares — ahora la regla vive en el constructor y el of() la aplica.
  2. La conversión: vive en el MAPPER (adapters), jamás en el dominio (el dominio no sabe qué es un serializer): el DTO de la 25 acepta Money y su __str__/asdict lo serializa — el borde convierte, el centro no.

Ejercicio 3 — El agregado

  1. La consecuencia del lazy-loading: get() del repositorio ahora exige el agregado COMPLETO (los seats con prefetch_related o el JOIN): el agregado parcial rompería las invariantes (¿total() sin seats? error). La regla documentada: el repositorio carga el agregado entero o nada — el agregado grande (20k seats) NO se carga entero: la disponibilidad masiva NO es el agregado (es una lectura del 25: AvailabilityReader), solo la reserva del comprador (2-6 seats) es agregado.
  2. La migración de tests: 14 tests del agregado pasaron a unitario puro (<0.4 s la suite de dominio); SE QUEDAN en integración: el lock (34: dos transacciones), el outbox-tras-commit (34), la saga reanudable (34) — las costuras del motor no se migran al dominio puro.

Ejercicio 4 — La decisión

  1. El ADR (extracto): "DDD parcial: VOs (Money, SeatRef, Percentage) + agregado Reservation en dominio puro con mappers; CRUD (Venue, config) en ORM activo. Costo medido: 4 días + 2 mappers. Trigger de revisión: el segundo servicio (53) o que las reglas de precios superen las 3 entidades interconectadas — el DDD completo sería 3 semanas por 1 aplicación CRUD: el mapa del 51 lo marca como sobre-ingeniería HOY".
  2. El contrafáctico: DDD completo ≈ 3 semanas (todos los modelos + repos + bounded contexts): la ganancia real sería el dominio testeable sin BD para Venue/Seat (CRUD: no aporta: sin lógica que probar) y la frontera con el futuro servicio de pagos (la saga ya está desacoplada del 32). La factura la pagarían las 3 líneas de negocio del 51: facturación (el ledger: tocarlo = riesgo), conversión (el checkout: el refactor lo congela), fiabilidad. NO vale hoy; vale si el 53 trocea.
  3. Los bounded contexts: tickets (reserva, asientos, disponibilidad), payments (intent, pasarela, refunds), events (catálogo, venues) — el mapa con sus lenguajes: "reserva" en tickets es el AGREGADO con expiración; en payments es solo un ID de contexto (el payload del outbox): la misma palabra, dos contextos — la regla del ubicuo POR contexto (no global).

Ejercicio 5 — El lenguaje ubicuo

  1. Los renombres: process_stuff() → liquidar_comisiones() (la 31); XDataModel → Reservation; handle_webhook_generic → aplicar_webhook_pasarela. El test del dueño: con el glosario del 3, lee los nombres del módulo de tickets y entiende 12 de 15 — antes entendía 4.
  2. La ACL: el if charge_status == "settled" estaba en services (el vocabulario ajeno contaminó) → movido al adaptador PasarelaAdapter que emite PaymentSucceeded/PaymentFailed (el dominio solo conoce SU vocabulario) — el borde traduce, el centro respira su idioma.
  3. El glosario (extracto):
markdown
# Lenguaje del dominio (15 palabras)
- Reserva: la retención temporal de asientos de un comprador; expira a los 10 min (10).
- Confirmar: la transición PENDING→CONFIRMED tras el pago (32). irreversible.
- Expira: la transición automática PENDING→EXPIRED al vencer el TTL (29/31).
- Tarjeta regalo: saldo prepagado con ledger propio (08); paga parcial.
- Comisión: el Percentage del org cobrado por venta (08): la facturación.
- Disponibilidad: la lectura de asientos libres (25): informa, nunca decide (38).
...

Resumen del profesor

  • La 24/25 ya era hexagonal; el hueco era el Model en el servicio: el dominio puro (VOs + agregado) lo cierra, con mappers en el borde.
  • El VO ejecuta la invariante donde nace el dato; el agregado se carga completo (la disponibilidad masiva NO es agregado); el CRUD queda en ORM: DDD parcial, con trigger en el ADR.
  • El lenguaje ubicuo y la ACL valen más que la arquitectura: el negocio y el código hablan el mismo idioma, y el ajeno (la pasarela) se traduce en el borde.