Stack: Django/DRF · Project: TicketFlow Status: Published — patterns on top of lesson 24's layers Prerequisite: Lesson 24 — SOLID and dependency injection
Objectives
- Implement the Repository pattern to isolate data access from the domain, and decide with judgment when it does NOT pay off in Django.
- Model domain events and publish them with the Outbox pattern so notifications are never lost (lesson 17's weak link).
- Distinguish domain service, application service and DTO, and place each piece in its layer.
1. Repository: the boundary with the database
A repository encapsulates data access behind a domain interface: the service asks repos.reservas.por_evento_disponibles(event_id), it doesn't build Ticket.objects.filter(...) in three different views. Benefits: the complicated query lives in ONE place; the domain doesn't import the ORM (24's inversion); test fakes are in-memory lists.
class ReservationRepository(Protocol):
def get(self, ref: str) -> Reservation: ...
def save(self, r: Reservation) -> None: ...
class ORMCampaignRepository:
def get(self, ref: str) -> Reservation:
return Reservation.objects.select_for_update().get(public_ref=ref)
def save(self, r: Reservation) -> None:
r.save()The honest rule: not for every model. Django already IS a repository per table (the manager). A full repository in pure Django is usually a layer duplicating the ORM with fewer features. What it does isolate: aggregates with complex queries (reservations per event with seats), the checkout's multi-table access, and everything a test without a DB wants to fake. A repository for Reservation (an aggregate with invariants), direct manager for trivial Venue CRUD.
2. Domain service vs. application service
Two things are called "service" and they are not the same:
- Domain service: business logic that doesn't fit in an entity —
reservar(),asignar_asientos(),liquidar_comisiones(). It speaks the business's language, orchestrates entities and repositories, knows neither HTTP nor serializers. It is the heart of 24's services layer. - Application service: thin use cases over the domain — "receive a reservation request from this user" (implicit authentication, transaction, event publication, gateway call). In Django it usually merges with the domain; separate them when the same flow must fire from API, management command and worker with different add-ons (14, 29).
Lesson 24's view calls the service; the service calls repositories and models. The dependency arrow never goes back up.
3. DTO: the data that crosses the boundary
A DTO (Data Transfer Object) is a plain data object that travels between layers without behavior. In Django you already have two and sometimes didn't know it: the input serializer validates and transports (parsed_data), and the output serializer models the response. Why your own DTO? When the service must NOT return a Model: the view receives ReservationSummary(event, seats, total, expires_at) — an immutable dataclass — and decides how to serialize it. Advantages: the service can be called from CLI/worker without surprise lazy-loading; the output contract is explicit; you don't leak internal fields by accident.
@dataclass(frozen=True)
class ReservationSummary:
public_ref: str
event: str
seats: tuple[str, ...]
total: Money
expires_at: datetime
def resumen(r: Reservation) -> ReservationSummary:
return ReservationSummary(r.public_ref, r.event.title, r.seat_refs, r.total(), r.expires_at)Rule: DTO outwards (response, queue message, event), Model inside. If a DRF serializer already covers the case, don't add a DTO for fashion — add it when the consumer is not the HTTP view (worker, queue, report) or when the output shape differs from the model.
4. Domain events and the Outbox pattern
A domain event is a fait accompli in the past: ReservationConfirmed, PaymentFailed. It is published AFTER the transaction; 17's problem was: if you confirm the reservation (commit) and the broker is down, the webhook to the buyer is lost. The outbox pattern: in the SAME transaction you write the event into an outbox_event table; a poller/worker reads the table and publishes to the broker. The event goes to the network only if the transaction was real.
def reservar(...) -> Reservation:
with transaction.atomic():
r = Reservation.objects.select_for_update().get(...)
r.confirm()
OutboxEvent.objects.create(
aggregate_type="reservation",
aggregate_id=r.public_ref,
event_type="ReservationConfirmed",
payload={"ref": r.public_ref, "seats": r.seat_refs, "total": r.total_amount},
)
# commit: the poller will publish (at-least-once, the consumer dedupes by aggregate_id+type)Properties: the poller retries (the event is not lost), it may duplicate (at-least-once) — the consumer dedupes with the unique event_id key (the outbox's UUID) — and it preserves order by created_at/id per aggregate. It is Django's answer to the double-commit problem (17): there is no distributed transaction, there is a table and a worker.
5. What TicketFlow's checkout looks like
View (HTTP) → serializer + status codes
ApplicationService → reservar(): transaction + outbox + clock + gateway injected
DomainService/Model → rules: state machine, invariants, timeouts
Repository → aggregates with complex queries, test fakes
DTO → ReservationSummary outwards; Model inside
Outbox + poller → reliable events towards webhooks/emails (17)The signal you applied it well: the reservation service is invoked the same way from the view, from manage.py reservar_cli and from 29's worker — and all three hang from the same transaction and the same outbox.
Self-assessment
- Why can a repository over a pure Django manager be over-engineering, and which signals justify one?
- Difference between domain service and application service? What does the project gain by separating them (or by not doing so)?
- When a DTO and not a Model at the service's output? Give two concrete criteria.
- Explain the outbox pattern: which double-commit problem does it solve and what guarantee does it deliver (at-least-once, order)?
- If the outbox poller goes down for 10 minutes, what happens to the events and how does the consumer dedupe?
Continue with the exercises. The solutions only after trying it yourself.