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
- Aplicar un proceso de modelado (negocio → entidades → relaciones → invariantes → esquema).
- Entender por qué el asiento es el centro del problema de TicketFlow.
- Escribir los modelos Django v1 con decisiones justificadas.
- 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:
- Un organizador publica un evento con fecha y aforo.
- Un comprador ve la disponibilidad de asientos de un evento.
- Un comprador reserva uno o varios asientos (se los "quedo" X minutos).
- El comprador paga → la reserva se confirma → recibe sus entradas.
- 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──▶ completedReservation (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 → CANCELLEDpor timeout la hará Celery (Lección 29), no uncronhumano.
Payment
PENDING ──webhook ok──▶ SUCCEEDED
│──── webhook fail──▶ FAILED
└──── timeout ──────▶ EXPIREDRegla 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)
# 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
UniqueConstraintcondicionales deReservationItemson 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 parastatususamos StringChoices en vez de IntEnum por legibilidad en BD y logs. Alternativa defendible: libreríadjango-fsmpara las transiciones — la evaluaremos en la Lección 25.
Y el pago:
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 pasarela5. Trade-offs del modelo v1 (sé esto para la entrevista)
| Decisión | Alternativa | Por qué la nuestra (v1) |
|---|---|---|
| Reserva de un solo evento | Carro multi-evento | Atomicidad y bloqueos locales; menos estados imposibles. Simple > general. |
| Asiento como fila física | Contador 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 ReservationItem | Chequeo 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 ítem | Precio solo en Event | El precio puede cambiar entre reserva y pago; facturar lo pactado. |
on_delete=PROTECT en organizador/reserva/evento de reserva | CASCADE | No quieres que borrar un usuario elimine el historial de pagos (auditoría). |
Estados como TextChoices | Integer choices, librería fsm | Legibilidad en BD/logs; transiciones se controlarán en métodos del modelo. |
expires_at en Reservation | Job que "libera asientos" borrando | La 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á)
| Carencia | Lecció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+1 | L09 — í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 plano | L29 — Celery |
| La pasarela de pago simulada necesita webhooks | L17 — webhooks |
| Caché de disponibilidad bajo carga | L38 — caché |
Autoevaluación (respóndeme en el chat)
- ¿Por qué
UniqueConstraintparcial y no un check en la vista? Explica la race condition. - ¿Qué rompe en el negocio si
expires_atse guarda en la sesión del usuario en vez de en la tabla? - Un organizador borra su cuenta. ¿Qué pasa con sus eventos y reservas con
PROTECT? ¿Es deseable? - ¿Por qué el asiento es fila y no un simple contador de aforo? ¿Qué funcionalidad futura se pierde con contador?
- ¿Por qué el precio se congela en
ReservationItemy no se consulta al pagar?
Continúa con los ejercicios para implementar el modelo. Las soluciones solo tras intentarlo.