Module 0 · Getting set up

Lesson 00b — TicketFlow initial modeling

From the business to the invariants and from there to the schema: constraints that make selling a seat twice impossible.

Published
In this lesson
  1. Objectives
  2. 1. The senior modeling process (before the code)
  3. 2. Entities and relationships (mental diagram)
  4. 3. States: the domain's state machines
  5. 4. The v1 models in Django (with justified decisions)
  6. 5. Trade-offs of the v1 model (know this for the interview)
  7. 6. What this model does NOT solve yet (and which lesson will)
  8. Self-assessment (answer me in the chat)

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

  1. Apply a modeling process (business → entities → relationships → invariants → schema).
  2. Understand why the seat is at the center of TicketFlow's problem.
  3. Write the Django v1 models with justified decisions.
  4. 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:

  1. An organizer publishes an event with a date and a capacity.
  2. A buyer sees the seat availability of an event.
  3. A buyer reserves one or more seats (they're "held" for X minutes).
  4. The buyer pays → the reservation is confirmed → they receive their tickets.
  5. 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──▶ completed

Reservation (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 → CANCELLED transition on timeout will be done by Celery (Lesson 29), not a human cron.

Payment

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

Senior 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)

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. 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 on ReservationItem are 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 for status we use TextChoices instead of IntEnum for readability in the DB and logs. A defensible alternative: the django-fsm library for transitions — we'll evaluate it in Lesson 25.

And the payment:

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: one payment, one attempt
    gateway_reference = models.CharField(max_length=100, blank=True)  # what the gateway returns

5. Trade-offs of the v1 model (know this for the interview)

DecisionAlternativeWhy ours (v1)
Single-event reservationMulti-event cartLocal atomicity and locks; fewer impossible states. Simple > general.
Seat as a physical rowCapacity 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 ReservationItemCheck 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 itemPrice only on EventThe price can change between reserving and paying; you bill what was agreed.
on_delete=PROTECT on organizer/reservation/eventCASCADEYou don't want deleting a user to wipe the payment history (audit).
States as TextChoicesInteger choices, fsm libraryReadability in DB/logs; transitions will be controlled in model methods.
expires_at on ReservationJob that "frees seats" by deletingExpiration 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)

GapLesson 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 problemL09 — indexes and EXPLAIN
No real auth yet (our User is provisional)L18–L21 — auth and authorization
Timeout expiration needs background jobsL29 — Celery
The simulated payment gateway needs webhooksL17 — webhooks
Availability cache under loadL38 — caching

Self-assessment (answer me in the chat)

  1. Why a partial UniqueConstraint and not a check in the view? Explain the race condition.
  2. What breaks in the business if expires_at is stored in the user's session instead of the table?
  3. An organizer deletes their account. What happens to their events and reservations with PROTECT? Is that desirable?
  4. Why is the seat a row and not a simple capacity counter? What future functionality is lost with a counter?
  5. Why is the price frozen on ReservationItem instead of being looked up at payment time?

Continue with the exercises to implement the model. The solutions only after trying it yourself.