Stack: Django/DRF · Project: TicketFlow Status: Published — opening the enterprise-level module Prerequisite: Lesson 51 — Understanding the business
Objectives
- Recognize what the course ALREADY built: 24/25's layers are hexagonal by another name — and name the DDD concepts still missing.
- Extract TicketFlow's domain into pure domain objects (entities, value objects, aggregates) and see the real cost of doing it.
- 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:
# 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?" noThe 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:
# 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_atAnd the mapper in the adapter (the only one that knows Django):
# 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
- Which parts of 24/25 were already hexagonal, and which gap remained (the Model inside the service)?
- What does a value object guarantee, and which two invariants does the course's
Moneyenforce? Where does the invariant live? - What is an aggregate, and what hidden cost does the big aggregate have (lazy-loading)? What does it demand from the repository?
- What does "partial DDD" decide for TicketFlow, and what would full DDD's trigger be (the ADR)?
- 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.