Module 12 · Enterprise level (capstone)

Lesson 52 — Clean/Hexagonal and DDD applied

The TicketFlow domain at the center and infrastructure at the edge.

Published
In this lesson
  1. Objectives
  2. 1. Hexagonal: what you already have (and the name)
  3. 2. Value objects: the money and the references you already wanted
  4. 3. The aggregate and the mapping: the honest extraction
  5. 4. The decision: full DDD for TicketFlow?
  6. 5. The ubiquitous language: the skill that is not code
  7. Self-assessment

Stack: Django/DRF · Project: TicketFlow Status: Published — opening the enterprise-level module Prerequisite: Lesson 51 — Understanding the business


Objectives

  1. Recognize what the course ALREADY built: 24/25's layers are hexagonal by another name — and name the DDD concepts still missing.
  2. Extract TicketFlow's domain into pure domain objects (entities, value objects, aggregates) and see the real cost of doing it.
  3. Decide when full DDD pays and when it is the over-engineering 51 identifies.

1. Hexagonal: what you already have (and the name)

The hexagonal architecture (ports and adapters): the domain at the center, the details (HTTP, DB, Redis, gateway) at the edge, the dependency arrows ONLY inward. The course's audit: TicketFlow's domain already lives in services/ (24) with the Protocols (Clock, PaymentGateway, ReservationRepository — the PORTS), the implementations at the edge (ORMReservationRepository, SimulatedGateway — the ADAPTERS), the DTOs outward (25), the outbox as the events port (25). It is already hexagonal: what was missing was the name and one thing: the domain still manipulates Django Models (Reservation.objects inside the service). Today's lesson closes that last gap — WITH the decision of whether it's worth it (§4).

        ┌──────────────────────────────────────┐
        │  Django (HTTP) · Celery · CLI        │  ← input adapters
        │  ┌────────────────────────────────┐  │
        │  │  APPLICATION: use cases        │  │
        │  │  ┌──────────────────────────┐  │  │
        │  │  │ DOMAIN: Reservation,     │  │  │
        │  │  │ Money, Checkout — pure   │  │  │
        │  │  └──────────────────────────┘  │  │
        │  │  Ports: Repos, Clock, GW       │  │
        │  └────────────────────────────────┘  │
        │  Postgres · Redis · Gateway          │  ← output adapters
        └──────────────────────────────────────┘

2. Value objects: the money and the references you already wanted

The value object (VO): no identity, immutable, defined by its value, with the invariants in the constructor — 08's Money already was one (that is why 37 caught it in profiling: it was the hottest object). The missing ones:

python
# domain/values.py — pure, zero 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")     # the living invariant
        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 yes, "a-12?" no

The VO's value: the invariant RUNS where the data is born (not in 12 places with if), the type documents the domain (def total(self) -> Money vs -> Decimal), and 33's test exercises them without Django. The cost: the conversion at the edge (the Model stores Decimal, the domain receives Money — §3's mappers).

3. The aggregate and the mapping: the honest extraction

The DDD aggregate: the cluster of objects persisted as a unit with their invariants — Reservation (+ its Seats, its Money) is the aggregate 25 already isolated with a repository. The full extraction to pure domain:

python
# domain/reservation.py — WITHOUT 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")   # the state machine (00b)
        self.status = "CONFIRMED"

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

And the mapper in the adapter (the only one that knows 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, ...}

The REAL measured cost: 25's reservar() service goes from manipulating the Model (the lock, the select_for_update, the direct save) to: fetch the state (mapper), operate the domain, write (mapper) — 2 mappers + the state machine's refactor. The benefit: the domain tests in milliseconds WITHOUT a DB (33 begged for it), the invariants live in ONE class, and the transport (Postgres→another) is a new mapper. The hidden cost: lazy-loading disappears (the aggregate loads COMPLETE: 25's repo design must load the seats with prefetch — the big aggregate is slow by design).

4. The decision: full DDD for TicketFlow?

51's honest matrix (value/effort/risk): full DDD (pure domain + mappers + repositories for EVERY model): effort ~3 weeks, benefit: pure testable domain and explicit boundaries. TicketFlow's domain: 6-8 entities, 3 aggregates with real logic, 1 stable business language — 51's calculation: partial: VOs (Money, SeatRef) + the extracted Reservation aggregate + 24's ports; trivial CRUD (Venue, 27's config) stays with the direct ORM. The decision rule (48's ADR): "Partial DDD: the VOs and the aggregates with invariants live in pure domain; CRUD and reads live in Django. Reviewed when the second service (53) or the rules' complexity demands it". Full DDD for ONE Django CRUD app is the over-engineering 51's map flags.

5. The ubiquitous language: the skill that is not code

DDD's most valuable concept is not technical: the ubiquitous language — the business's words are the code's: the business says "reserva", "expira", "se confirma", "tarjeta regalo" — and the code is named reservar(), expirar(), confirmar(), GiftCard (not AppointmentModel, not GiftCardData). The course's audit: 00b's language (the state machine: PENDING/CONFIRMED/EXPIRED) IS the business's language; the Anti-corruption layer (ACL) is the edge's translator: the gateway's payload (charge_status: "settled") is translated to PaymentSucceeded in ONE place (17's adapter) — the foreign vocabulary does not contaminate the domain. The measurable benefit: the conversation with the business (51) uses the SAME words as the code: the bug gets reported in the domain's language and the traceback reads in the same language.


Self-assessment

  1. Which parts of 24/25 were already hexagonal, and which gap remained (the Model inside the service)?
  2. What does a value object guarantee, and which two invariants does the course's Money enforce? Where does the invariant live?
  3. What is an aggregate, and what hidden cost does the big aggregate have (lazy-loading)? What does it demand from the repository?
  4. What does "partial DDD" decide for TicketFlow, and what would full DDD's trigger be (the ADR)?
  5. What is the ubiquitous language, and where did the course's Anti-corruption layer show up (the gateway's payload)?

Continue with the exercises. The solutions only after trying it yourself.