Módulo 0 · Puesta a punto

Lección 00b — Modelado inicial de TicketFlow

Del negocio a las invariantes y de ahí al esquema: constraints que impiden vender un asiento dos veces.

Publicada
En esta lección
  1. Objetivos
  2. 1. El proceso senior de modelado (antes del código)
  3. 2. Entidades y relaciones (diagrama mental)
  4. 3. Estados: las máquinas de estado del dominio
  5. 4. Los modelos v1 en Django (con decisiones justificadas)
  6. 5. Trade-offs del modelo v1 (sé esto para la entrevista)
  7. 6. Qué NO resuelve este modelo todavía (y qué lección lo resolverá)
  8. Autoevaluación (respóndeme en el chat)

Objetivo: diseñar el modelo de datos v1 como lo diseñaría un senior: entender el negocio, identificar las invariantes del dominio, y solo entonces escribir modelos. Estado: Impartida — implementación pendiente en los ejercicios Prerrequisito: Lección 00 — Entorno y primer endpoint


Objetivos

  1. Aplicar un proceso de modelado (negocio → entidades → relaciones → invariantes → esquema).
  2. Entender por qué el asiento es el centro del problema de TicketFlow.
  3. Escribir los modelos Django v1 con decisiones justificadas.
  4. Identificar los trade-offs de cada decisión (¡esto es lo que cuentan en las entrevistas!).

1. El proceso senior de modelado (antes del código)

Un principiante abre models.py y empieza a escribir clases. Un senior hace primero tres preguntas:

Pregunta A — ¿Qué hace el negocio? (casos de uso)

TicketFlow, versión inicial:

  1. Un organizador publica un evento con fecha y aforo.
  2. Un comprador ve la disponibilidad de asientos de un evento.
  3. Un comprador reserva uno o varios asientos (se los "quedo" X minutos).
  4. El comprador paga → la reserva se confirma → recibe sus entradas.
  5. Si no paga a tiempo, la reserva expira y los asientos vuelven a estar libres.

Pregunta B — ¿Qué invariantes NO pueden romperse jamás?

Las invariantes son las reglas que definen si tu sistema es correcto. En TicketFlow:

  • I1: Dos reservas activas no pueden incluir el mismo asiento. (La más importante: es el dinero y la reputación del negocio.)
  • I2: Una reserva tiene un plazo de expiración; pasada la hora, no es confirmable.
  • I3: Solo se puede pagar una vez una reserva (idempotencia de pago).
  • I4: No se pueden reservar asientos de un evento pasado o cancelado.

Por qué esto va primero: el esquema es la consecuencia de las invariantes, no al revés. Si el modelo no puede expresar que un asiento no puede tener dos reservas activas, ninguna cantidad de código en las vistas lo arreglará. Esta idea reaparece en la Lección 08 (modelado) y la Lección 10 (transacciones).

Pregunta C — ¿Qué escala y qué no? (qué sea simple por ahora)

Un estadio real tiene mapa de asientos con filas/zonas. Decisiones de alcance para v1 (y su porqué):

  • Incluir asientos como filas reales (necesarios para la invariante I1).
  • Mapa visual/gradas: lo dejamos para una v2; añade complejidad sin aportar al aprendizaje.
  • Un único tipo de entrada por asiento. Los tipos (VIP, general…) llegan cuando hagamos caché y búsqueda.
  • Pasarela de pago simulada: el valor está en los webhooks y la idempotencia, no en la integración real.

2. Entidades y relaciones (diagrama mental)

User 1───N Reservation N───N Seat N───1 Event
                         │
                         └──── N ───1 Payment
  • User (el de Django): comprador u organizador, con role.
  • Event: pertenece a un organizador; tiene lugar, fecha, aforo y estado.
  • Seat: pertenece a un evento; tiene sector, fila y número → su identidad dentro del evento es (event, sector, row, number).
  • Reservation: pertenece a un comprador; agrupa asientos de un solo evento; tiene estado y expiración.
  • Payment: pertenece a una reserva; registra intentos de pago con clave de idempotencia.

Decisión deliberada: una reserva solo toca asientos de un evento. ¿Por qué? Porque expirar/cancelar/confirmar una reserva debe ser una transacción atómica y local. Permitir multi-evento complica bloqueos y el carro de la compra. Trade-off: el cliente que quiere asientos de dos conciertos hace dos reservas. Simple > general.


3. Estados: las máquinas de estado del dominio

Los estados son la otra mitad del modelo. Antes de que existan, hay que dibujarlos.

Event

draft ──publish()──▶ published ──cancel()──▶ cancelled
                        │
                        └──start_date pasa──▶ completed

Reservation (la importante)

                 ┌───────── timeout / expire ─────────┐
                 ▼                                    │
PENDING_PAYMENT ──pay()──▶ CONFIRMED ──refund/cancel──▶ CANCELLED
                 │                                    ▲
                 └────── cancel() por el usuario ─────┘
  • PENDING_PAYMENT: los asientos están retained (ocupados temporalmente).
  • CONFIRMED: pago recibido; entradas emitidas.
  • CANCELLED: terminal. Los asientos vuelven a estar disponibles.
  • La transición PENDING_PAYMENT → CANCELLED por timeout la hará Celery (Lección 29), no un cron humano.

Payment

PENDING ──webhook ok──▶ SUCCEEDED
   │──── webhook fail──▶ FAILED
   └──── timeout ──────▶ EXPIRED

Regla senior: en cada máquina de estado, las transiciones legales se declaran en un único lugar del código (aquí, métodos del modelo). Si alguien puede hacer reservation.status = "CONFIRMED" a mano desde cualquier vista, no tienes máquina de estado, tienes una base de datos con estados decorativos.


4. Los modelos v1 en Django (con decisiones justificadas)

python
# events/models.py
import uuid
from django.conf import settings
from django.db import models


class TimeStampedModel(models.Model):
    """Mixin: created_at/updated_at. Auditar "cuándo" es barato ahora e invaluable después."""
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class UserRole(models.TextChoices):
    BUYER = "BUYER", "Buyer"
    ORGANIZER = "ORGANIZER", "Organizer"
    ADMIN = "ADMIN", "Admin"


class User(TimeStampedModel):
    """Provisional: más adelante evaluamos usar el User de Django (auth nativa) vs este.
    Decisión v1: modelo propio para no acoplar el dominio al mecanismo de auth aún."""
    email = models.EmailField(unique=True)
    role = models.CharField(max_length=16, choices=UserRole.choices, default=UserRole.BUYER)


class EventState(models.TextChoices):
    DRAFT = "DRAFT", "Draft"
    PUBLISHED = "PUBLISHED", "Published"
    CANCELLED = "CANCELLED", "Cancelled"
    COMPLETED = "COMPLETED", "Completed"


class Event(TimeStampedModel):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    venue = models.CharField(max_length=200)              # lugar
    starts_at = models.DateTimeField()
    state = models.CharField(max_length=16, choices=EventState.choices, default=EventState.DRAFT)
    organizer = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="organized_events",
    )

    class Meta:
        indexes = [
            # Consulta dominante: "eventos publicados futuros, ordenados por fecha"
            models.Index(fields=["state", "starts_at"]),
        ]

    def __str__(self):
        return f"{self.title} @ {self.venue}"


class Seat(TimeStampedModel):
    event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="seats")
    sector = models.CharField(max_length=50, default="GENERAL")
    row = models.CharField(max_length=10)
    number = models.PositiveIntegerField()

    class Meta:
        constraints = [
            # Identidad física del asiento dentro del evento
            models.UniqueConstraint(fields=["event", "sector", "row", "number"], name="uniq_seat_per_event"),
        ]
        indexes = [
            # "¿Qué asientos del evento X están libres?" → filtrar por evento es el 95% de las queries
            models.Index(fields=["event", "sector", "row", "number"]),
        ]

    def __str__(self):
        return f"{self.sector}·F{self.row}·{self.number} ({self.event_id})"


class ReservationState(models.TextChoices):
    PENDING_PAYMENT = "PENDING_PAYMENT", "Pending payment"
    CONFIRMED = "CONFIRMED", "Confirmed"
    CANCELLED = "CANCELLED", "Cancelled"


class Reservation(TimeStampedModel):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="reservations")
    event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="reservations")
    status = models.CharField(max_length=20, choices=ReservationState.choices)
    expires_at = models.DateTimeField()  # PENDING_PAYMENT caduca aquí (invariante I2)

    class Meta:
        indexes = [
            models.Index(fields=["status", "expires_at"]),
        ]


class ReservationItem(models.Model):
    """Cada asiento dentro de una reserva. Tabla puente N:M con información propia
    (price_at_purchase: el precio se congela al reservar, no al pagar)."""
    reservation = models.ForeignKey(Reservation, on_delete=models.CASCADE, related_name="items")
    seat = models.ForeignKey(Seat, on_delete=models.PROTECT, related_name="reservation_items")
    price_at_purchase = models.DecimalField(max_digits=10, decimal_places=2)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["seat"], condition=models.Q(reservation__status="PENDING_PAYMENT"),
                                    name="uniq_active_reservation_per_seat"),
            models.UniqueConstraint(fields=["seat"], condition=models.Q(reservation__status="CONFIRMED"),
                                    name="uniq_confirmed_reservation_per_seat"),
        ]

Antes de que copies esto: las dos UniqueConstraint condicionales de ReservationItem son el corazón del sistema (invariante I1): la base de datos hace imposible que un asiento esté en dos reservas activas o confirmadas a la vez, aunque el código tenga un bug. Es la diferencia entre "esperemos que no pase" y "no puede pasar". Pero ojo: MySQL no soporta índices únicos parciales; en PostgreSQL sí (ADR-0003). Y para status usamos StringChoices en vez de IntEnum por legibilidad en BD y logs. Alternativa defendible: librería django-fsm para las transiciones — la evaluaremos en la Lección 25.

Y el pago:

python
class PaymentState(models.TextChoices):
    PENDING = "PENDING", "Pending"
    SUCCEEDED = "SUCCEEDED", "Succeeded"
    FAILED = "FAILED", "Failed"


class Payment(TimeStampedModel):
    reservation = models.ForeignKey(Reservation, on_delete=models.PROTECT, related_name="payments")
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=16, choices=PaymentState.choices, default=PaymentState.PENDING)
    idempotency_key = models.UUIDField(unique=True)   # I3: un pago, un intento
    gateway_reference = models.CharField(max_length=100, blank=True)  # lo que devuelve la pasarela

5. Trade-offs del modelo v1 (sé esto para la entrevista)

DecisiónAlternativaPor qué la nuestra (v1)
Reserva de un solo eventoCarro multi-eventoAtomicidad y bloqueos locales; menos estados imposibles. Simple > general.
Asiento como fila físicaContador de aforo (capacity)La invariante I1 se puede expresar en la BD. Con contadores, "quién tiene qué asiento" se pierde y el timeout se vuelve arbitrario.
UniqueConstraint parcial en ReservationItemChequeo en la vista (if seat.is_taken)La vista tiene race condition entre check y save; la BD no. (Lección 10 lo demuestra con código.)
price_at_purchase congelado en el ítemPrecio solo en EventEl precio puede cambiar entre reserva y pago; facturar lo pactado.
on_delete=PROTECT en organizador/reserva/evento de reservaCASCADENo quieres que borrar un usuario elimine el historial de pagos (auditoría).
Estados como TextChoicesInteger choices, librería fsmLegibilidad en BD/logs; transiciones se controlarán en métodos del modelo.
expires_at en ReservationJob que "libera asientos" borrandoLa expiración es dato, no acción: consultable, auditable, y la cancelación por timeout es idempotente.

Métrica de un buen modelo: las invariantes se violan menos con datos bien diseñados que con código bien escrito. Prefiere siempre que la BD no permita el estado incorrecto a que el código lo corrija.

6. Qué NO resuelve este modelo todavía (y qué lección lo resolverá)

CarenciaLección que lo resuelve
Dos lecturas simultáneas pueden crear dos reservas del mismo asiento si la BD no aplica la constraint (MySQL, o si la quitamos)L10 — transacciones y bloqueos
Consultar disponibilidad hace N queries de asientos → problema N+1L09 — índices y EXPLAIN
No hay auth real (nuestro User es provisional)L18-L21 — auth y autorización
Expiración por timeout necesita tareas en segundo planoL29 — Celery
La pasarela de pago simulada necesita webhooksL17 — webhooks
Caché de disponibilidad bajo cargaL38 — caché

Autoevaluación (respóndeme en el chat)

  1. ¿Por qué UniqueConstraint parcial y no un check en la vista? Explica la race condition.
  2. ¿Qué rompe en el negocio si expires_at se guarda en la sesión del usuario en vez de en la tabla?
  3. Un organizador borra su cuenta. ¿Qué pasa con sus eventos y reservas con PROTECT? ¿Es deseable?
  4. ¿Por qué el asiento es fila y no un simple contador de aforo? ¿Qué funcionalidad futura se pierde con contador?
  5. ¿Por qué el precio se congela en ReservationItem y no se consulta al pagar?

Continúa con los ejercicios para implementar el modelo. Las soluciones solo tras intentarlo.