Coding by Hand
Rust home

Design Airline Management

A small bus depot has a chalkboard out front that lists every bus, where it is going, what time it leaves, and a grid of squares for the seats. The clerk inside takes money, writes a name in a square, erases a name when a rider cancels, and crosses the whole row out if the bus breaks down. The board is the truth. Every other ticket window in the depot looks at the same board, and every change goes through one clerk who guards it. An airline reservation system is a bigger depot with more buses and faster clerks, but the shape of the problem is the same. Pick the right squares on the board and the right pen strokes that can change them, and the rest of the system has almost nothing to invent.

An airline reservation system is a departure board: flights, fares, seats, and bookings all tracked from one place.
An airline reservation system is a departure board: flights, fares, seats, and bookings all tracked from one place.

This shape got pinned down in the late 1950s when American Airlines and IBM built SABRE, the first computerized seat reservation system. Before SABRE, a clerk in Dallas who wanted to sell a seat on a flight out of New York had to telephone New York, wait for a human there to walk to a paper card, read the seat back, and mark the card. Two clerks selling at the same time could sell the same seat. SABRE replaced the paper card with a single shared record and forced every change to flow through one program that knew how to refuse a double booking. The 2 hard parts that fell out of that work were exactly the 2 hard parts of this lesson — the right type for a seat, and the right list of events that are allowed to touch it.

Start with the seats on one bus.

#[derive(Copy, Clone, PartialEq, Eq)]
enum Class {
    Economy,
    First,
}

#[derive(Clone, PartialEq, Eq)]
enum Seat {
    Empty,
    Booked { passenger: String, class: Class },
}

#[derive(Copy, Clone, PartialEq, Eq)]
enum Status {
    OnTime,
    Delayed,
    Cancelled,
}

struct Flight {
    number: &'static str,
    origin: &'static str,
    destination: &'static str,
    fare: u32,
    status: Status,
    seats: [(&'static str, Seat); 6],
}

struct Airline {
    flights: Vec<Flight>,
}

A Seat is either Empty or Booked, and Booked carries the passenger's name and the class of ticket they paid for. An empty seat with no passenger and a booked seat with no name are both illegal under this type, so the compiler will not let them exist. A weaker design would use a String for the passenger and an empty string to mean nobody is in the seat, and then half the code would forget to check for the empty string and crash when it tried to print the name. Pulling the two cases apart into an enum is what a typed language buys you. The Class enum next to it does the same job for the kind of ticket — exactly Economy or First, nothing else.

Status is the chalkboard label across the top of one row of seats. The bus is on time, the bus is late, or the bus is cancelled. Three values, no surprises. A Flight then is a single bus on the board — a number, an origin, a destination, a fare written in dollars, the current status, and the six numbered squares the clerk writes names into. The depot itself, the Airline, is just a list of flights, because that is all a depot is.

The seat labels live alongside the seat data in a tuple so the renderer can walk the row in order and the booking function can find a seat by its label without a separate map. Six seats fit in a fixed-size array because the bus has exactly six and the array makes that promise to the compiler. A Vec would have worked but the array says out loud "this never grows."

Each seat is a tiny state machine: available, held during booking, then ticketed once paid.
Each seat is a tiny state machine: available, held during booking, then ticketed once paid.

The pen strokes the clerk is allowed to make come next, and this is where the design earns its money.

enum Event<'a> {
    BookSeat { flight: &'a str, seat: &'a str, passenger: &'a str, class: Class },
    CancelSeat { flight: &'a str, seat: &'a str },
    ChangeStatus { flight: &'a str, status: Status },
}

impl Airline {
    fn new() -> Self {
        let seats = || [
            ("1A", Seat::Empty), ("1B", Seat::Empty), ("1C", Seat::Empty),
            ("2A", Seat::Empty), ("2B", Seat::Empty), ("2C", Seat::Empty),
        ];
        Self {
            flights: vec![
                Flight { number: "AA101", origin: "JFK", destination: "LAX",
                    fare: 320, status: Status::OnTime, seats: seats() },
                Flight { number: "UA200", origin: "LAX", destination: "JFK",
                    fare: 295, status: Status::OnTime, seats: seats() },
            ],
        }
    }

    fn flight_mut(&mut self, number: &str) -> Result<&mut Flight, &'static str> {
        self.flights.iter_mut().find(|f| f.number == number).ok_or("flight not found")
    }

    fn apply(&mut self, event: Event<'_>) -> Result<String, &'static str> {
        match event {
            Event::BookSeat { flight, seat, passenger, class } => {
                let f = self.flight_mut(flight)?;
                if f.status == Status::Cancelled { return Err("flight is cancelled"); }
                let slot = f.seats.iter_mut().find(|(label, _)| *label == seat)
                    .ok_or("seat not on this aircraft")?;
                if !matches!(slot.1, Seat::Empty) { return Err("seat already booked"); }
                let price = match class { Class::First => f.fare * 3, Class::Economy => f.fare };
                slot.1 = Seat::Booked { passenger: passenger.to_string(), class };
                Ok(format!("booked {seat} on {flight} for {passenger} (${price})"))
            }
            Event::CancelSeat { flight, seat } => {
                let f = self.flight_mut(flight)?;
                let slot = f.seats.iter_mut().find(|(label, _)| *label == seat)
                    .ok_or("seat not on this aircraft")?;
                if matches!(slot.1, Seat::Empty) { return Err("seat is already empty"); }
                slot.1 = Seat::Empty;
                Ok(format!("cancelled {seat} on {flight}"))
            }
            Event::ChangeStatus { flight, status } => {
                let f = self.flight_mut(flight)?;
                f.status = status;
                Ok(format!("{flight} status -> {}", status_label(status)))
            }
        }
    }
}

An Event is a closed list of every change the world can ask of the board — book a seat, cancel a seat, change a flight's status. Nothing else is on the menu. This shape comes straight out of the SABRE design — every terminal at every airport speaks the same handful of commands, and the central program decides whether each one is legal before it lands. The reason to give the events their own type is that the apply function can match on them with no defaults, and the compiler will yell on the day someone adds a fourth event and forgets to wire up the handler. A version that took a string command like "book" and parsed arguments out of more strings would still work, but it would push every check into runtime and every typo into a bug report.

Read apply from the top. BookSeat looks the flight up, refuses if the flight is cancelled, looks the seat up, refuses if the seat is taken, computes the price (First class is 3 times Economy because someone has to pay for the legroom), and writes the booking. CancelSeat looks up the seat and turns it back to Empty, refusing if it was already empty so the caller never thinks they undid a booking that never existed. ChangeStatus flips the chalkboard label. Every branch returns Result<String, &'static str> — a happy message on success, a reason on failure — so no caller can ignore the answer. A version that returned bool would let the rest of the program shrug at the difference between "seat already booked" and "flight cancelled," and the user would get the wrong dialog.

One small thing worth pointing out is that flight_mut is the only place the code searches the flight list. Every event uses it. If the airline grows to a thousand flights and a linear scan becomes the bottleneck, swapping in a HashMap keyed by flight number changes one function and nothing else. This is the same lesson the SABRE team learned the hard way — the lookup is the join point, and you want exactly one of them.

A booking weaves through search, hold, payment, and ticket — any failure rolls the seat back to available.
A booking weaves through search, hold, payment, and ticket — any failure rolls the seat back to available.

The render function and the demo loop sit on top of that core and do not change it.

fn status_label(s: Status) -> &'static str {
    match s {
        Status::OnTime => "on time",
        Status::Delayed => "delayed",
        Status::Cancelled => "cancelled",
    }
}

fn seat_glyph(seat: &Seat) -> String {
    match seat {
        Seat::Empty => ".".to_string(),
        Seat::Booked { class: Class::Economy, .. } => "E".to_string(),
        Seat::Booked { class: Class::First, .. } => "F".to_string(),
    }
}

impl Flight {
    fn render(&self) -> String {
        let mut out = String::new();
        out.push_str(&format!(
            "{} {}->{} (${}, {})\n",
            self.number, self.origin, self.destination, self.fare, status_label(self.status)
        ));
        out.push_str(&format!(" row 1: {} {} {}\n",
            seat_glyph(&self.seats[0].1), seat_glyph(&self.seats[1].1), seat_glyph(&self.seats[2].1)));
        out.push_str(&format!(" row 2: {} {} {}\n",
            seat_glyph(&self.seats[3].1), seat_glyph(&self.seats[4].1), seat_glyph(&self.seats[5].1)));
        out
    }
}

fn show_board(airline: &Airline) {
    for flight in &airline.flights {
        print!("{}", flight.render());
    }
}

render returns a String instead of printing because a method that prints can only be used one way and a method that returns can be printed, logged, snapshotted, or shipped to a UI. The glyphs are one character each — . for empty, E for an economy booking, F for a first-class booking — so the seat map stays narrow enough to fit on a phone screen. Names live inside the Seat value and the renderer drops them on purpose, because a map full of names is unreadable and a clerk who needs the name can look it up.

Drive a real script through the system and watch every output.

fn show_initial(airline: &Airline) {
    println!("--- opening board ---");
    show_board(airline);
    println!();
}

fn show_events(airline: &mut Airline) {
    let events: [Event; 7] = [
        Event::BookSeat { flight: "AA101", seat: "1A", passenger: "Ada", class: Class::First },
        Event::BookSeat { flight: "AA101", seat: "2B", passenger: "Ben", class: Class::Economy },
        Event::BookSeat { flight: "AA101", seat: "1A", passenger: "Cal", class: Class::Economy },
        Event::ChangeStatus { flight: "UA200", status: Status::Delayed },
        Event::BookSeat { flight: "UA200", seat: "1C", passenger: "Dee", class: Class::Economy },
        Event::CancelSeat { flight: "AA101", seat: "2B" },
        Event::ChangeStatus { flight: "AA101", status: Status::Cancelled },
    ];
    for (i, event) in events.into_iter().enumerate() {
        let outcome = match airline.apply(event) {
            Ok(msg) => format!("ok: {msg}"),
            Err(msg) => format!("err: {msg}"),
        };
        println!("event {}: {}", i + 1, outcome);
        show_board(airline);
        println!();
    }
}

The event list is hardcoded so the binary is deterministic, but it is the same shape a real terminal would feed in — book, book, double-book attempt, schedule change, book on the other flight, cancel, cancel the whole flight.

--- opening board ---
AA101 JFK->LAX ($320, on time)
 row 1: . . .
 row 2: . . .
UA200 LAX->JFK ($295, on time)
 row 1: . . .
 row 2: . . .

event 1: ok: booked 1A on AA101 for Ada ($960)
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . . .
UA200 LAX->JFK ($295, on time)
 row 1: . . .
 row 2: . . .

event 2: ok: booked 2B on AA101 for Ben ($320)
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . E .
UA200 LAX->JFK ($295, on time)
 row 1: . . .
 row 2: . . .

event 3: err: seat already booked
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . E .
UA200 LAX->JFK ($295, on time)
 row 1: . . .
 row 2: . . .

event 4: ok: UA200 status -> delayed
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . E .
UA200 LAX->JFK ($295, delayed)
 row 1: . . .
 row 2: . . .

event 5: ok: booked 1C on UA200 for Dee ($295)
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . E .
UA200 LAX->JFK ($295, delayed)
 row 1: . . E
 row 2: . . .

event 6: ok: cancelled 2B on AA101
AA101 JFK->LAX ($320, on time)
 row 1: F . .
 row 2: . . .
UA200 LAX->JFK ($295, delayed)
 row 1: . . E
 row 2: . . .

event 7: ok: AA101 status -> cancelled
AA101 JFK->LAX ($320, cancelled)
 row 1: F . .
 row 2: . . .
UA200 LAX->JFK ($295, delayed)
 row 1: . . E
 row 2: . . .

Read the board top to bottom. The opening board shows both flights empty and on time. Event 1 books Ada in 1A on AA101 as a First Class ticket, and the price prints as $960 — $320 times 3. Event 2 books Ben in 2B for $320 as Economy. Event 3 tries to put Cal in 1A and fails with seat already booked, and the board does not change. Event 4 marks UA200 as delayed, which only flips the chalkboard label, no seats touched. Event 5 puts Dee in 1C of UA200. Event 6 cancels Ben's booking and 2B goes back to empty. Event 7 cancels AA101 entirely — the status flips to cancelled but Ada's seat is still marked, because the design refuses to invent a policy on the fly for what happens to existing bookings when a flight is killed. That decision belongs in another event the airline will add later, not in ChangeStatus.

One question worth asking — why does the code keep Ada's name in 1A even after AA101 is cancelled instead of clearing the row? The answer is that clearing a row is a refund event, not a status event, and the two have different consequences. Status changes are reversible — a cancelled flight can be uncancelled if the mechanic shows up. Refunds are not. Pushing the refund into ChangeStatus would couple two ideas that should live apart, and the next time someone wants to delay a cancellation announcement until the gate agent reviews bookings, the change would be in the wrong place. Small, single-purpose events keep the future flexible.

The thing this design cannot do on its own is handle two clerks selling at the same time. If two threads called apply on the same flight at the same moment, both could read Empty for seat 1A, both could decide the booking is legal, and both could write a name — the exact bug SABRE was built to kill. The next bottleneck is making the booking step atomic across many clerks, which is what locks and transactions exist to solve.