Airline Management LLD: Seats Have Names, and Holds Have Timers
A low-level design walkthrough of an airline booking system: identity inventory where seat 12A is nobody's substitute, the PNR as hidden noun, and seat holds that expire by the lazy clock.
"Design an airline management system." It sounds like the hotel with wings — rooms become seats, stays become flights — and that's exactly the trap worth walking into deliberately, because the differences are the entire lesson. A hotel guest books "a deluxe room"; a flyer books seat 12A — the window one, with the legroom, next to their kid. And an airline checkout doesn't book instantly: it holds your seats while you fumble for a card, and that hold expires.
Identity inventory and timed holds: two upgrades to everything this queue has built, and together they make the booking flow you've used a hundred times suddenly legible.
Let's start nowhere near a computer
Two shops, side by side. The bakery sells "a loaf" — any loaf; the clerk grabs the nearest one, and counting loaves is all the inventory there is. Next door, the engraver sells lockets with your name already on them — nobody wants "any locket", they want theirs, and the clerk tracks each one individually: this locket is free, this one's promised to Asha until six, this one's sold.
The hotel was the bakery: rooms within a type are interchangeable, so booking is counting gaps. The airline is the engraver: every seat is somebody's specific choice, so booking is locking units by name — and a promise on a named unit needs an expiry, or one indecisive customer freezes the shop.
Where identity inventory runs your life
- Cinema and stadium seats, train berths — pick-your-spot inventory everywhere.
- The hold-with-timer is every checkout you've raced: "seats held for 10 minutes" on a booking site, the concert queue countdown.
- The PNR — that six-character code on your boarding pass — is this queue's beloved hidden noun wearing its most famous uniform.
Step 1 — Functional requirements (sentences first)
What the system must do, as plain sentences — the functional requirements.
- A flyer can search flights by route and date.
- A flyer can hold specific seats on a flight; a hold lasts ten minutes.
- Confirming an unexpired hold issues the booking (the PNR); an expired hold is dead.
- A held or confirmed seat can't be held by anyone else — but an expired hold frees its seats.
- Cancelling a PNR returns its seats to the pool.
That second sentence is a deliberate stance. A booking site that reserved your seat instantly but couldn't take payment yet would either charge before you're ready or hand the seat to two people while you both type a card. The hold — a promise with an expiry — is the honest middle: the seat is yours for now, and "for now" has a clock on it.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for seat reservation they are the design:
- Correctness. One seat goes to exactly one passenger; the all-or-nothing family rule holds; nobody is overbooked beyond stated policy. A double-sold seat is the failure the whole thing exists to prevent.
- Thread-safety. Two flyers reaching for 12A on the same flight race; the design must let exactly one win, cleanly, never both.
- Performance. "Is 12A free?" is an O(1) seat-map lookup, not a scan; search by route and date narrows by an index, not by walking every flight.
- Extensibility. Fare classes, aircraft layouts, and pricing plug in as data and strategies without reopening the booking core.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | check every seat before locking any (all-or-nothing); confirm re-checks status and clock — Step 6 |
| Thread-safety | the check-then-reserve is one synchronized critical section, so two racers can't both pass the check — Step 6 |
| Performance | seat state is a seat → owner map (O(1) lookup); search filters flights by route/date — Steps 4, 6 |
| Correctness (holds) | an expired hold is treated as absent on the next read — lazy expiry, no sweeper to miss — Step 5 |
| Extensibility | fare classes are a strategy, money is integer minor units, seat layout is just the seat set — Step 7 |
Every trade-off below is chosen to keep one of these.
Step 3 — The PNR: the hidden noun gets famous
What ties a passenger to flight AI-202 and seats 12A, 12B, with a lifecycle of held → confirmed → cancelled? Not the flight (it has hundreds of passengers), not the passenger (they have many trips). A new noun:
public static final class Pnr {
public enum Status { HELD, CONFIRMED, CANCELLED }
private final String id;
private final String flightNo;
private final Set<String> seats;
private final String passenger;
private final Instant holdExpiry; // the timer, stamped at creation
private Status status = Status.HELD;
}The parking ticket, the reservation, the hotel booking — and now the PNR. Four articles, one shape: a relationship promoted to a record because it has its own lifecycle. If this queue teaches one modeling reflex, it's this one.
Step 4 — The seat map: identity inventory by name
Before the hold, settle how a flight holds its seats — because the engraver's problem is "track each named unit", and the data structure is the answer.
Which structure — and why. A flight keeps a Map<String, String> of seat → PNR id (seatOwner), plus an immutable Set of every seat that exists on the aircraft. The map, not a list of booleans, is the load-bearing choice. A boolean[] per seat would answer "is 12A taken?" but lose who holds it and until when — and a held seat without an owner can't be re-checked for expiry, can't be cancelled by its PNR, can't tell a family apart from a stranger. Mapping the seat to its owning PNR keeps correctness (the seat knows whose it is) and buys performance for free: every "is this seat free?" is one hash lookup, O(1), no scan — the restaurant's table map reflex applied to seats. The seat set being immutable is the aircraft layout itself: swap the layout, swap the set, and the booking core never notices — that's the extensibility seam. Money, when fares arrive, is integer minor units (paise), never a double — the vending machine's rule, because a fare you can't add exactly is a fare you'll mis-charge.
Step 5 — The hold: a lock with a timer
Holding seats is two checks and a stamp — and the timer question is where the design gets graded. Who un-holds expired seats? The rookie answer is a background sweeper thread. The better answer this site keeps arriving at — from the rate limiter's buckets to the bakery's sticky notes — is lazy expiry: nobody sweeps; whoever looks at a seat checks the clock.
private boolean seatTaken(Flight flight, String seat) {
String pnrId = flight.seatOwner.get(seat);
if (pnrId == null) return false;
Pnr pnr = pnrs.get(pnrId);
if (pnr.status() == Pnr.Status.HELD && clock.instant().isAfter(pnr.holdExpiry())) {
return false; // the hold died of old age — seat walks back, lazily
}
return pnr.status() != Pnr.Status.CANCELLED;
}No threads, no schedules, no missed sweeps — an expired hold is simply treated as absent by the next reader. And of course the clock is injected, because a ten-minute expiry you can't fast-forward is an expiry you can't test.
Step 6 — Hold, confirm, and the all-or-nothing rule
public synchronized Pnr hold(String flightNo, List<String> seats, String passenger) {
Flight flight = flightOrThrow(flightNo);
for (String seat : seats) {
flight.requireExists(seat);
if (seatTaken(flight, seat)) {
throw new IllegalStateException("seat " + seat + " is taken"); // ALL or nothing —
} // no partial families
}
Pnr pnr = new Pnr("P-" + nextId++, flightNo, seats, passenger,
clock.instant().plus(Duration.ofMinutes(10)));
pnrs.put(pnr.id(), pnr);
seats.forEach(s -> flight.seatOwner.put(s, pnr.id()));
return pnr;
}
public synchronized void confirm(String pnrId) {
Pnr pnr = pnrOrThrow(pnrId);
pnr.requireStatus(Pnr.Status.HELD);
if (clock.instant().isAfter(pnr.holdExpiry())) {
pnr.cancel(); // honesty at the moment of truth
throw new IllegalStateException("hold expired — seats were released");
}
pnr.confirm();
}Two beats to narrate. All-or-nothing: checking every seat before locking any means a family of four never ends up with three seats and a sad child — the restaurant's transaction instinct, applied to seats. And confirm re-checks the clock: the hold that looked fine at selection may be dead by payment, and the design tells the truth right then, not at the gate.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
seat → owner map | a boolean[] of free/taken | the seat knows whose it is and until when, so expiry and cancellation work at all | correctness |
| check all seats, then lock all | lock seats as you go | a family never ends up with three seats and a stranded child — no partial booking | correctness |
synchronized check-then-reserve | check, then reserve unguarded | two racers for 12A can't both pass the gap — exactly one wins | thread-safety |
| lazy expiry on read | a background sweeper thread | no missed sweep, no schedule to tune; an expired hold is simply absent to the next eye | correctness |
seat → owner hash lookup | scan the passenger list | "is 12A free?" is O(1), not a walk over everyone aboard | performance |
| fares as a strategy, money in paise | price branching inside hold | pricing and layouts plug in without reopening the booking core | extensibility |
Growth — when bookings outpace one flight. The first knobs are the hold duration (shorter frees indecisive seats faster) and an overbooking quota above the seat map per cabin. Beyond that you don't make hold cleverer — you narrow what it contends on: a per-flight lock instead of one lock over the whole airline, so a race for 12A on AI-202 never blocks a booking on AI-911. Same instinct as shrinking a contended lock — make the guarded thing smaller, not the method smarter.
The complete implementation
package dev.fiveyear.airline;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class Airline {
public static final class Pnr {
public enum Status { HELD, CONFIRMED, CANCELLED }
private final String id;
private final String flightNo;
private final Set<String> seats;
private final String passenger;
private final Instant holdExpiry;
private Status status = Status.HELD;
Pnr(String id, String flightNo, List<String> seats, String passenger, Instant holdExpiry) {
this.id = id;
this.flightNo = flightNo;
this.seats = new HashSet<>(seats);
this.passenger = passenger;
this.holdExpiry = holdExpiry;
}
void confirm() { status = Status.CONFIRMED; }
void cancel() { status = Status.CANCELLED; }
void requireStatus(Status expected) {
if (status != expected) {
throw new IllegalStateException("PNR is " + status + ", expected " + expected);
}
}
public String id() { return id; }
public Set<String> seats() { return Set.copyOf(seats); }
public Instant holdExpiry() { return holdExpiry; }
public Status status() { return status; }
}
static final class Flight {
final String number;
final String from;
final String to;
final LocalDate date;
final Set<String> allSeats;
final Map<String, String> seatOwner = new HashMap<>(); // seat → PNR id
Flight(String number, String from, String to, LocalDate date, Set<String> seats) {
this.number = number;
this.from = from;
this.to = to;
this.date = date;
this.allSeats = Set.copyOf(seats);
}
void requireExists(String seat) {
if (!allSeats.contains(seat)) {
throw new IllegalArgumentException("no seat " + seat + " on " + number);
}
}
}
private final Clock clock;
private final Map<String, Flight> flights = new HashMap<>();
private final Map<String, Pnr> pnrs = new HashMap<>();
private int nextId = 1;
public Airline(Clock clock) {
this.clock = clock;
}
public void addFlight(String number, String from, String to, LocalDate date, Set<String> seats) {
flights.put(number, new Flight(number, from, to, date, seats));
}
public synchronized List<String> search(String from, String to, LocalDate date) {
List<String> result = new ArrayList<>();
for (Flight f : flights.values()) {
if (f.from.equals(from) && f.to.equals(to) && f.date.equals(date)) {
result.add(f.number);
}
}
return result;
}
public synchronized Pnr hold(String flightNo, List<String> seats, String passenger) {
Flight flight = flightOrThrow(flightNo);
for (String seat : seats) {
flight.requireExists(seat);
if (seatTaken(flight, seat)) {
throw new IllegalStateException("seat " + seat + " is taken");
}
}
Pnr pnr = new Pnr("P-" + nextId++, flightNo, seats, passenger,
clock.instant().plus(Duration.ofMinutes(10)));
pnrs.put(pnr.id(), pnr);
seats.forEach(s -> flight.seatOwner.put(s, pnr.id()));
return pnr;
}
public synchronized void confirm(String pnrId) {
Pnr pnr = pnrOrThrow(pnrId);
pnr.requireStatus(Pnr.Status.HELD);
if (clock.instant().isAfter(pnr.holdExpiry())) {
pnr.cancel();
throw new IllegalStateException("hold expired — seats were released");
}
pnr.confirm();
}
public synchronized void cancel(String pnrId) {
Pnr pnr = pnrOrThrow(pnrId);
pnr.cancel(); // seats free themselves: ownership is re-checked lazily
}
public synchronized Set<String> availableSeats(String flightNo) {
Flight flight = flightOrThrow(flightNo);
Set<String> free = new HashSet<>();
for (String seat : flight.allSeats) {
if (!seatTaken(flight, seat)) {
free.add(seat);
}
}
return free;
}
private boolean seatTaken(Flight flight, String seat) {
String pnrId = flight.seatOwner.get(seat);
if (pnrId == null) return false;
Pnr pnr = pnrs.get(pnrId);
if (pnr.status() == Pnr.Status.HELD && clock.instant().isAfter(pnr.holdExpiry())) {
return false;
}
return pnr.status() != Pnr.Status.CANCELLED;
}
private Flight flightOrThrow(String flightNo) {
Flight f = flights.get(flightNo);
if (f == null) {
throw new IllegalArgumentException("no flight " + flightNo);
}
return f;
}
private Pnr pnrOrThrow(String pnrId) {
Pnr p = pnrs.get(pnrId);
if (p == null) {
throw new IllegalArgumentException("no PNR " + pnrId);
}
return p;
}
}A checkout race you've lived:
Airline airline = new Airline(clock); // injected — tests fast-forward it
airline.addFlight("AI-202", "BLR", "DEL", june15, Set.of("11A", "11B", "12A", "12B"));
Pnr family = airline.hold("AI-202", List.of("12A", "12B"), "asha");
airline.hold("AI-202", List.of("12A"), "dev"); // throws: seat 12A is taken
// …asha hunts for her credit card for eleven minutes…
airline.confirm(family.id()); // throws: hold expired — seats released
airline.hold("AI-202", List.of("12A", "12B"), "dev"); // dev gets them — lazily freed,
// no sweeper thread ever ranThe interview corner
Clarify before you code: Single segments or multi-city itineraries? Cabin classes? Is schedule-change handling (the hard one) in scope?
The follow-up ladder:
- "Book a two-flight itinerary." The PNR spans segments, and the hold must be all-or-nothing across flights — the family rule, scaled up: no stranding a passenger mid-journey.
- "Overbook economy by 5%." A quota above the seat map: count confirmed PNRs per cabin against capacity + buffer, and assign actual seats at check-in for the unassigned tail.
- "The aircraft was swapped." Re-accommodation: map old seats to equivalent new ones, preserving adjacency where possible — name it as constrained matching, sketch the greedy version, scope the rest.
- "Add check-in and boarding." Two more PNR states; the machine grows linearly — which is exactly why it was a state machine and not a pile of booleans.
- "Two devices confirm one PNR at once."
confirmis synchronized and re-checks status — the second caller gets "already confirmed," never a second charge. The check-then-act answer, again.
Mistakes that fail the round: seats as booleans (loses who holds them, and until when); sweeper threads for expiry; granting a family three of their four seats.
Where to go from here
Pocket version: seats are identity inventory locked by name, the PNR is the hidden noun with a timer in its pocket, holds are all-or-nothing, expiry is lazy, and confirm re-checks the clock at the moment of truth.
- Add overbooking — airlines sell more tickets than seats on purpose; it's a per-flight quota above the seat map, and suddenly you understand why boarding sometimes pays volunteers.
- Add fare classes — price per (flight, seat-class, time-of-booking) is a lookup, and yield management is data, not ifs at industrial scale.
- Phase 2 closes next with the AWS-style alarm service — from booking seats to watching metrics.
That countdown on the booking page, ticking while you type a card number? Now you know: a timestamp in a record, an honest re-check at confirm — and not a single background thread sweeping up after the indecisive.