Módulo 5 · Arquitectura y código mantenible

Lección 25 — Patrones de servicio

Repositorio, servicio, DTO, eventos de dominio y el patrón outbox.

Publicada
En esta lección
  1. Ejercicio 1 — Repositorio
  2. Ejercicio 2 — Outbox
  3. Ejercicio 3 — DTO
  4. Ejercicio 4 — Eventos
  5. Ejercicio 5 — Deduplicación
  6. Resumen del profesor

Ejercicio 1 — Repositorio

python
class ReservationRepository(Protocol):
    def get(self, ref: str) -> Reservation: ...
    def save(self, r: Reservation) -> None: ...
    def existe_reserva_activa(self, event_id: int, user_id: int) -> bool: ...

class InMemoryReservationRepository:
    def __init__(self): self._items: dict[str, Reservation] = {}
    def get(self, ref): return self._items[ref]          # KeyError si no existe: mismo contrato que .get() del ORM
    def save(self, r): self._items[r.public_ref] = r
    def existe_reserva_activa(self, event_id, user_id):
        return any(r.event_id == event_id and r.user_id == user_id and r.status == "ACTIVE"
                   for r in self._items.values())
  1. Para Venue/Seat: manager directo. Repositorio ahí es reescribir el ORM sin ganar nada; el agregado Reservation justifica el suyo porque concentra la consulta con lock, las invariantes y el fake de test.

Ejercicio 2 — Outbox

Migración (extracto):

python
class OutboxEvent(models.Model):
    aggregate_type = models.CharField(max_length=50)
    aggregate_id = models.CharField(max_length=64)
    event_type = models.CharField(max_length=100)
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)
    publicado_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        indexes = [models.Index(
            fields=["created_at"],
            condition=Q(publicado_at__isnull=True),   # índice parcial: solo pendientes
            name="outbox_pending_idx",
        )]

Test de atomicidad: fuerza el fallo (asiento ya reservado) y verifica OutboxEvent.objects.count() == 0 después del DomainError — el rollback se llevó el evento: eso es exactamente el punto del outbox.

Poller idempotente:

python
class Command(BaseCommand):
    def handle(self, *args, **opts):
        while True:
            with transaction.atomic():
                batch = list(OutboxEvent.objects.filter(publicado_at__isnull=True).order_by("id")[:50])
                if not batch:
                    return
                for ev in batch:
                    self.publicar(ev)          # broker/log; at-least-once
                    ev.publicado_at = timezone.now()
                    ev.save(update_fields=["publicado_at"])

Ejercicio 3 — DTO

  1. La vista:
python
@api_view(["GET"])
def reservation_detail(request, ref):
    r = repos.reservas.get(ref)                    # o servicio.leer(ref)
    return Response(ReservationSummarySerializer(resumen(r)).data)
  1. Serializar desde Model: renombrar seat_refs → seats toca el serializer y potencialmente cada consumidor del model_to_dict. Con DTO: toca resumen() (un lugar) y el serializer que lo pinta. Un punto de traducción.
  2. expires_in_seconds es regla de negocio (el TTL de la reserva, la 10) — si vive en el serializer, cada cliente lo recalcula y el CLI no lo tiene. En el DTO con Clock inyectado, el test lo fija sin freezegun.

Ejercicio 4 — Eventos

Tabla mínima de eventos (payload justo, el consumidor no hace queries extra):

EventoEmisorConsumidorPayload mínimo
ReservationConfirmedservicio reservaremail, analyticsref, user_id, event_id, seats, total, expires_at
ReservationExpiredjob expiración (29)email, liberar asientosref, user_id
PaymentSucceededservicio pagosconfirmar reserva, recibointent_id, ref, amount
RefundIssuedservicio refundsemail, contabilidadintent_id, ref, amount, reason
EventPublishedadmin/organizadornotificaciones pushevent_id, venue
PaymentFailedservicio pagosemail "inténtalo de nuevo", métricasintent_id, ref, reason

El error clásico del payload mínimo: el consumidor "solo mira el event_id y luego consulta" — si la fila cambió (reserva expirada y borrada), el email sale con datos incorrectos. Payload autosuficiente (idempotencia + inmutabilidad del hecho).

Ejercicio 5 — Deduplicación

python
def consumir(ev: OutboxEvent):
    key = f"event:{ev.id}"
    if not cache.add(key, "1", 60 * 60 * 24):   # SET NX: atómico
        return                                   # ya procesado: dedup
    self.publicar(ev)

Test: el fake de "publicar" es una lista; procesa dos veces el mismo evento y len(published) == 1. La deduplicación vive en el CONSUMIDOR porque el poller solo garantiza at-least-once desde el outbox a la cola; el efecto final (email enviado, webhook firmado de la 17) es quien necesita exactly-once efectivo, y solo el dueño del efecto conoce su estado. El poller puede deduplicar como optimización, no como garantía.


Resumen del profesor

  • Repositorio para agregados con lógica; manager directo para CRUD trivial. La capa que duplica el ORM sin dominio es ruido.
  • Outbox = eventos fiables con una tabla y un poller; at-least-once + dedup en el consumidor. La alternativa (transacciones distribuidas) no existe en tu stack.
  • DTO hacia fuera cuando el consumidor no es la vista HTTP o el shape difiere del modelo; Model dentro.