Module 5 · Architecture and maintainable code

Lesson 25 — Service patterns

Repository, service, DTO, domain events and the outbox pattern.

Published
In this lesson
  1. Objectives
  2. 1. Repository: the boundary with the database
  3. 2. Domain service vs. application service
  4. 3. DTO: the data that crosses the boundary
  5. 4. Domain events and the Outbox pattern
  6. 5. What TicketFlow's checkout looks like
  7. Self-assessment

Stack: Django/DRF · Project: TicketFlow Status: Published — patterns on top of lesson 24's layers Prerequisite: Lesson 24 — SOLID and dependency injection


Objectives

  1. Implement the Repository pattern to isolate data access from the domain, and decide with judgment when it does NOT pay off in Django.
  2. Model domain events and publish them with the Outbox pattern so notifications are never lost (lesson 17's weak link).
  3. 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.

python
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.

python
@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.

python
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

  1. Why can a repository over a pure Django manager be over-engineering, and which signals justify one?
  2. Difference between domain service and application service? What does the project gain by separating them (or by not doing so)?
  3. When a DTO and not a Model at the service's output? Give two concrete criteria.
  4. Explain the outbox pattern: which double-commit problem does it solve and what guarantee does it deliver (at-least-once, order)?
  5. 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.