Goal: design the v1 data model the way a senior would: understand the business, identify the domain invariants, and only then write models. Status: Taught — implementation pending in the exercises Prerequisite: Lesson 00 — Environment and your first endpoint
Objectives
- Apply a modeling process (business → entities → relationships → invariants → schema).
- Understand why the seat is at the center of TicketFlow's problem.
- Write the Django v1 models with justified decisions.
- Identify the trade-offs of each decision (this is what they ask in interviews!).
1. The senior modeling process (before the code)
A beginner opens models.py and starts writing classes. A senior asks three questions first:
Question A — What does the business do? (use cases)
TicketFlow, initial version:
- An organizer publishes an event with a date and a capacity.
- A buyer sees the seat availability of an event.
- A buyer reserves one or more seats (they're "held" for X minutes).
- The buyer pays → the reservation is confirmed → they receive their tickets.
- If they don't pay in time, the reservation expires and the seats become free again.
Question B — Which invariants must NEVER break?
Invariants are the rules that define whether your system is correct. In TicketFlow:
- I1: Two active reservations cannot include the same seat. (The most important one: it's the business's money and reputation.)
- I2: A reservation has an expiration deadline; past that moment, it cannot be confirmed.
- I3: A reservation can only be paid once (payment idempotency).
- I4: Seats of a past or cancelled event cannot be reserved.
Why this comes first: the schema is a consequence of the invariants, not the other way around. If the model cannot express that a seat can't have two active reservations, no amount of view-layer code will fix it. This idea returns in Lesson 08 (modeling) and Lesson 10 (transactions).
Question C — What scales and what doesn't? (what to keep simple for now)
A real stadium has a seat map with rows/zones. v1 scope decisions (and why):
- Include seats as real rows (needed for invariant I1).
- Visual map/stands: we leave it for a v2; it adds complexity without helping learning.
- One ticket type per seat. Types (VIP, general…) arrive when we build caching and search.
- Simulated payment gateway: the value lies in the webhooks and idempotency, not the real integration.
2. Entities and relationships (mental diagram)
User 1───N Reservation N───N Seat N───1 Event
│
└──── N ───1 Payment- User (Django's): buyer or organizer, with a
role. - Event: belongs to an organizer; has a venue, date, capacity and state.
- Seat: belongs to an event; has a sector, row and number → its identity within the event is
(event, sector, row, number). - Reservation: belongs to a buyer; groups seats from a single event; has a state and an expiration.
- Payment: belongs to a reservation; records payment attempts with an idempotency key.
Deliberate decision: a reservation only touches seats from one event. Why? Because expiring/cancelling/confirming a reservation must be one atomic, local transaction. Allowing multi-event complicates locks and the shopping cart. Trade-off: a customer who wants seats for two concerts makes two reservations. Simple > general.
3. States: the domain's state machines
States are the other half of the model. Before they exist, they must be drawn.
Event
draft ──publish()──▶ published ──cancel()──▶ cancelled
│
└──start_date passes──▶ completedReservation (the important one)
┌───────── timeout / expire ─────────┐
▼ │
PENDING_PAYMENT ──pay()──▶ CONFIRMED ──refund/cancel──▶ CANCELLED
│ ▲
└────── cancel() by the user ────────┘PENDING_PAYMENT: the seats are retained (temporarily taken).CONFIRMED: payment received; tickets issued.CANCELLED: terminal. The seats become available again.- The
PENDING_PAYMENT → CANCELLEDtransition on timeout will be done by Celery (Lesson 29), not a humancron.
Payment
PENDING ──webhook ok──▶ SUCCEEDED
│──── webhook fail──▶ FAILED
└──── timeout ──────▶ EXPIREDSenior rule: in every state machine, legal transitions are declared in a single place in the code (here, model methods). If anyone can do
reservation.status = "CONFIRMED"by hand from any view, you don't have a state machine — you have a database with decorative states.
4. The v1 models in Django (with justified decisions)
# events/models.py
import uuid
from django.conf import settings
from django.db import models
class TimeStampedModel(models.Model):
"""Mixin: created_at/updated_at. Auditing "when" is cheap now and invaluable later."""
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: later we evaluate Django's User (native auth) vs this one.
v1 decision: our own model to avoid coupling the domain to the auth mechanism yet."""
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) # place
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 = [
# Dominant query: "future published events, ordered by date"
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 = [
# Physical identity of the seat within the event
models.UniqueConstraint(fields=["event", "sector", "row", "number"], name="uniq_seat_per_event"),
]
indexes = [
# "Which seats of event X are free?" → filtering by event is 95% of the queries
models.Index(fields=["event", "sector", "row", "number"]),
]
def __str__(self):
return f"{self.sector}·R{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 expires here (invariant I2)
class Meta:
indexes = [
models.Index(fields=["status", "expires_at"]),
]
class ReservationItem(models.Model):
"""Each seat within a reservation. N:M bridge table with data of its own
(price_at_purchase: the price is frozen at reservation time, not at payment)."""
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"),
]Before you copy this: the two conditional
UniqueConstraints onReservationItemare the heart of the system (invariant I1): the database makes it impossible for a seat to be in two active or confirmed reservations at once, even if the code has a bug. That's the difference between "let's hope it doesn't happen" and "it cannot happen". But careful: MySQL doesn't support partial unique indexes; PostgreSQL does (ADR-0003). And forstatuswe use TextChoices instead of IntEnum for readability in the DB and logs. A defensible alternative: thedjango-fsmlibrary for transitions — we'll evaluate it in Lesson 25.
And the payment:
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: one payment, one attempt
gateway_reference = models.CharField(max_length=100, blank=True) # what the gateway returns5. Trade-offs of the v1 model (know this for the interview)
| Decision | Alternative | Why ours (v1) |
|---|---|---|
| Single-event reservation | Multi-event cart | Local atomicity and locks; fewer impossible states. Simple > general. |
| Seat as a physical row | Capacity counter (capacity) | Invariant I1 can be expressed in the DB. With counters, "who has which seat" is lost and the timeout becomes arbitrary. |
Partial UniqueConstraint on ReservationItem | Check in the view (if seat.is_taken) | The view has a race condition between check and save; the DB doesn't. (Lesson 10 proves it with code.) |
price_at_purchase frozen on the item | Price only on Event | The price can change between reserving and paying; you bill what was agreed. |
on_delete=PROTECT on organizer/reservation/event | CASCADE | You don't want deleting a user to wipe the payment history (audit). |
States as TextChoices | Integer choices, fsm library | Readability in DB/logs; transitions will be controlled in model methods. |
expires_at on Reservation | Job that "frees seats" by deleting | Expiration is data, not an action: queryable, auditable, and timeout cancellation is idempotent. |
Metric of a good model: invariants are violated less with well-designed data than with well-written code. Always prefer the DB not allowing the wrong state over code fixing it.
6. What this model does NOT solve yet (and which lesson will)
| Gap | Lesson that solves it |
|---|---|
| Two concurrent reads can create two reservations for the same seat if the DB doesn't enforce the constraint (MySQL, or if we remove it) | L10 — transactions and locks |
| Querying availability does N seat queries → the N+1 problem | L09 — indexes and EXPLAIN |
| No real auth yet (our User is provisional) | L18–L21 — auth and authorization |
| Timeout expiration needs background jobs | L29 — Celery |
| The simulated payment gateway needs webhooks | L17 — webhooks |
| Availability cache under load | L38 — caching |
Self-assessment (answer me in the chat)
- Why a partial
UniqueConstraintand not a check in the view? Explain the race condition. - What breaks in the business if
expires_atis stored in the user's session instead of the table? - An organizer deletes their account. What happens to their events and reservations with
PROTECT? Is that desirable? - Why is the seat a row and not a simple capacity counter? What future functionality is lost with a counter?
- Why is the price frozen on
ReservationIteminstead of being looked up at payment time?
Continue with the exercises to implement the model. The solutions only after trying it yourself.