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. Exercise 1 — Implementation
  2. Exercise 2 — Proving invariant I1
  3. Exercise 3 — Availability
  4. Exercise 4 — State machine
  5. Exercise 5 — Designing what's missing (reasoned solution)
  6. Exercise 6 — Scale reflection
  7. Professor's summary

Don't read this without having tried the exercises. Feedback is where learning sticks.


Exercise 1 — Implementation

Checkpoints:

  • AUTH_USER_MODEL = "events.User" declared before the first migration. If you did it later with migrations already applied, you saw Django's warning about auth.User vs the new model — hence the warning.
  • The constraints are created in the migration. Verify them: python manage.py sqlmigrate events 0001 | grep -i unique should show the partial unique indexes.
  • PROTECT on organizer, user, event (from Reservation) and seat (from ReservationItem). CASCADE only where the child is worthless without the parent (Seat dies with its event; ReservationItem dies with its reservation).

Typical mistakes: forgetting settings.AUTH_USER_MODEL and hardcoding "events.User" in the ForeignKey (works, but less flexible), or on_delete=CASCADE on Payment→Reservation (deleting a reservation must not delete payments! — audit).

Exercise 2 — Proving invariant I1

  1. The last line raises an IntegrityError similar to:
django.db.utils.IntegrityError: duplicate key value violates unique constraint "uniq_active_reservation_per_seat"
DETAIL: Key (seat_id)=(1) already exists.
  1. Without the constraints, the second reservation is created: two users "own" the same seat. Once both are confirmed, the business has sold the same chair twice: refunds, broken data, compensation and, in a real case, sanctions.
  2. What you demonstrated: business integrity is guaranteed by the database, not by the good will of the code. An if in the view between "check if it's free" and "create the reservation" has a millisecond window where another request does the same (race condition). The constraint is atomic.

Fine note: catching the IntegrityError in a real view would answer 409 Conflict — exactly the code you designed in Lesson 01, Exercise 2. HTTP theory and the data model embrace.

Exercise 3 — Availability

Typical correct solution (a single query):

python
from django.db.models import Q

def available_seats(event_id):
    return (
        Seat.objects
        .filter(event_id=event_id)
        .filter(
            Q(reservation_items__isnull=True)
            | ~Q(reservation_items__reservation__status__in=[
                ReservationState.PENDING_PAYMENT, ReservationState.CONFIRMED
            ])
        )
        .distinct()
    )

What matters:

  • It must be 1 query. If you used a per-seat loop (for seat in seats: seat.reservation_items...), you ran N+1 queries: the classic API killer (Lesson 09).
  • The Index(fields=["event", "sector", "row", "number"]) on Seat makes filter(event_id=...) an index scan instead of a full scan.
  • With connection.queries you should see a single SELECT with a JOIN/subquery.

Exercise 4 — State machine

python
class Reservation(TimeStampedModel):
    # ... fields ...

    def confirm(self, when):
        """PENDING_PAYMENT -> CONFIRMED with a successful payment and not expired."""
        if self.status != ReservationState.PENDING_PAYMENT:
            raise InvalidTransition(f"Only PENDING_PAYMENT can be confirmed (current state: {self.status})")
        if when >= self.expires_at:
            raise InvalidTransition("Reservation expired; it cannot be confirmed")
        has_payment = self.payments.filter(status=PaymentState.SUCCEEDED).exists()
        if not has_payment:
            raise InvalidTransition("A SUCCEEDED payment is required to confirm")
        self.status = ReservationState.CONFIRMED
        self.save(update_fields=["status", "updated_at"])

    def cancel(self, when):
        """PENDING_PAYMENT/CONFIRMED -> CANCELLED. Idempotent: cancelling twice doesn't fail."""
        if self.status == ReservationState.CANCELLED:
            return False  # idempotent no-op: the caller's intent is already satisfied
        if self.status not in (ReservationState.PENDING_PAYMENT, ReservationState.CONFIRMED):
            raise InvalidTransition(f"State cannot be cancelled: {self.status}")
        self.status = ReservationState.CANCELLED
        self.save(update_fields=["status", "updated_at"])
        return True
  1. With expires_at in the past, confirm() raises InvalidTransition("Reservation expired..."). Invariant I2 lives in the model, not in the view.
  2. Double cancel(): the second one is a no-op (returns False). Justification: idempotency in state operations protects against client retries and duplicated queue messages. If you raised an exception, a legitimate retry would fail. This decision returns in Lesson 14 (API idempotency) and Lesson 30 (queue consumers).

Exercise 5 — Designing what's missing (reasoned solution)

  1. VIP row as a product: add TicketType (name, price, applicable sector/rows) and have ReservationItem reference ticket_type besides the seat. It's a reasonable change and the price stops being on the item, coming from the type instead. For v1 I reject it because it adds an entity and queries before validating the base model, but it's the first natural extension. Criterion: extend when the use case exists, not "just in case" (YAGNI).
  2. Max 4 seats per user/event: it's a business rule (it may change tomorrow), not a physical law of the schema. It lives in the model/service (e.g. validation when creating the reservation, at the same point where the transaction happens). A DB constraint (COUNT <= 4 per active user+event) is defensible as defense in depth but rigid for business changes; a serializer only validates HTTP input, not invocations from other services/cron. Rule: structural invariants → DB; business rules → model/service.
  3. Tickets without a seat: mixing both in Seat with number=NULL pollutes the seat's physical identity and breaks the partial constraints (two "seats without a number" would be indistinguishable at the unique level). Better: a TicketType with seated=True/False, and for unseated events use capacity (a counter) instead of seats, with a capacity constraint. Real trade-off: two capacity-control mechanisms that should be unified after Lesson 10 (transactions) — the honest discussion is worth more than a "closed" answer.

Exercise 6 — Scale reflection

  1. The availability query: everyone makes it when opening the event page. It's read thousands of times per second during sales peaks.
  2. ReservationItem (it grows with every historical active reservation) and Seat's composite index. With 2M seats and accumulated old reservations, filters by reservation__status scan more than needed; partitioning by state/date will come (Lesson 55).
  3. Cheap-today, very-expensive-tomorrow:
  4. Right indexes now (done: state+starts_at, event+sector+row+number).
  5. expires_at indexed with state (done: status+expires_at) because the expiry job queries it every minute.
  6. Never delete: states and dates instead of DELETE (auditable history).
  7. Integer IDs + public UUID separated (the API doesn't expose sequential IDs; we'll see it in Lesson 13).

Professor's summary

  • Senior modeling is: business → invariants → schema. Never the other way around.
  • The DB constraint is not a technical detail: it's the contractual guarantee that the system won't sell the same seat twice.
  • State machines live in model methods, and illegal transitions raise domain exceptions.
  • Every modeling decision has a nameable trade-off. If you can't say what you're sacrificing, you haven't decided: you've copied.

When you submit the exercises, we'll grade them and continue with the syllabus (Lesson 01 if it's not done, then Lesson 02 — Networking).