Six economy seats, seven tickets sold, and the airline meant to do that. The hard part is not the seat map. It is knowing that "how many tickets may we sell" and "which seat is yours" are two different questions, and deciding who is left on the ground when everybody shows up.
Airline booking looks like the cinema problem with bigger planes, and the first twenty minutes of a weak answer treat it that way. It is not, in three ways. First, a cinema sells each named seat once (LLD-05 holds seats all-or-nothing); an airline sells capacity and gives the seat later, which is the only reason overbooking is possible. Second, the same cancellation returns different money depending on the fare and the hours left. Third, someone has to lose when the plane is full: denied boarding is part of the design, not a bug. The stock counters of LLD-26 give you the capacity idea; this lesson adds seat maps, policy objects and the close of check-in.
- Strategy:
OverbookingPolicy(NoOverbooking,PercentOverbooking) andRefundPolicy(TieredRefund) - State (as a transition table):
BStateandLEGAL - Facade:
BookingServiceis the one entry point over flights, seats and policies - Value objects:
Flightis immutable data; the service owns all changing state
How the round runs#
"Design the booking core for an airline. Flights have a business and an economy cabin with seat maps. A customer holds seats for a group, pays, and can cancel for a refund. The airline overbooks. Show a flight from the first hold to check-in. 90 minutes."
- 0–10 min — clarify: what is sold (seats or capacity), hold time, groups, refunds, overbooking, what happens at the gate
- 10–25 min —
Flight,Cabin,Booking, and the two limits: sold count and seat owner - 25–45 min — hold, ticket, expire and cancel, under one lock
- 45–60 min — the two policies, as strategies
- 60–75 min — the check-in close: seat, or bump
- 75–90 min — a scripted flight, then follow-ups: changes, upgrades, waitlists, multi-leg trips
Step 1: Clarify the requirements#
| You ask | They answer | What it changes |
|---|---|---|
| What do we sell: a seat, or a place in a cabin? | A place in a cabin; a specific seat is optional. | Two layers: a capacity count, and a seat map that may stay empty until check-in |
| Can a booking choose seats? | Yes, one per passenger, or none. | seats may hold None; a chosen seat is owned at once |
| How long is a hold? | 15 minutes, then it lapses. | expires_at, swept on the caller's clock |
| What is a group? | Several passengers in one cabin, all or nothing. | One booking, one capacity check for n |
| Do we overbook? | Yes, by a policy that can change. | OverbookingPolicy.limit(capacity), a strategy |
| What if everyone turns up? | Seat in the order they bought; the rest are denied boarding and compensated. | A close step with a bump rule and a compensation rule |
| How do refunds work? | By fare class and hours before departure. | A RefundPolicy with tiers, not an if per fare |
| Money type? | Whole cents. | Integers; no floats for money |
Six seats, a policy that allows 25 percent more. How many tickets?
An economy cabin has 6 seats and PercentOverbooking(0.25). What is the limit on held and ticketed passengers, and what is wrong with round(6 * 1.25)?
One strong answer
floor(6 × 1.25) = 7: one ticket more than seats. Round down. round(7.5) gives 8 in all four languages, which sells two extra, more than the policy allows; and for other halves the languages disagree (round(2.5) is 2 in Python and 3 in JavaScript), a different bug per language. The limit must also never fall below the seat count, which floor(capacity × (1 + rate)) guarantees for any rate of zero or more. One float caveat: 100 × (1 + 0.13) is 112.99999999999999 in binary floating point, so the floor gives 112, not 113. That errs on the safe side (one ticket fewer, never one more), and production code avoids it by keeping the rate in basis points: capacity * (10000 + bps) // 10000. Say the demo's 25 percent is large on purpose so six seats show the effect; real airlines use a few percent, set from no-show history.
Step 2: Identify the entities#
Step 3: Class diagram#
%% title: Airline booking: the classes
classDiagram
class BookingService {
+hold(fid, cabin, fare_class, fare, passengers, now, seats) Booking
+ticket(pnr, now) Booking
+cancel(pnr, now) int
+close(fid, now) dict
+expire(now) int
}
class Flight {
+fid str
+departs_at int
+layout dict
}
class Booking {
+pnr str
+cabin Cabin
+fare_class str
+fare int
+passengers list
+seats list
+state BState
+move(to)
}
class BState {
<<enumeration>>
HELD
TICKETED
CANCELLED
EXPIRED
BOARDED
BUMPED
}
class OverbookingPolicy {
<<interface>>
+limit(capacity) int
+compensation(fare) int
}
class RefundPolicy {
<<interface>>
+refund(paid, fare_class, hours_before) int
}
BookingService "1" *-- "many" Flight
BookingService "1" *-- "many" Booking
BookingService --> OverbookingPolicy
BookingService --> RefundPolicy
Booking --> BState
OverbookingPolicy <|.. NoOverbooking
OverbookingPolicy <|.. PercentOverbooking
RefundPolicy <|.. TieredRefundThe service keeps two maps, and everything follows from them. sold[(flight, cabin)] counts passengers on HELD or TICKETED bookings: that is capacity, and the overbooking policy caps it. owner[(flight, seat)] says which booking owns a seat: that is the seat map, and nothing caps it except the seat labels that exist. A booking with no chosen seats takes capacity and owns nothing; the close step turns that into seats, or into a bump. Overbooking is just sold being allowed to exceed the number of labels.
The two policies are the parts that change by airline and by year, so they are interfaces. Neither touches the booking logic: limit is asked once per hold, compensation once per bumped passenger, refund once per cancellation.
HELD→TICKETED(paid),CANCELLED, orEXPIRED(the hold lapsed)TICKETED→CANCELLED(with a refund),BOARDED(seated at close), orBUMPED(denied boarding)CANCELLED,EXPIRED,BOARDEDandBUMPEDare final
Step 4: Patterns applied#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy (overbooking) | OverbookingPolicy |
The overbooking level is a business lever: per route, per season, per cabin. It is one number and one payout, so a class is enough, and the booking code never changes. |
| Strategy (refunds) | RefundPolicy |
Fare rules differ by product and change often. Tiers as data in one policy beat an if fare == "FLEX" in every cancellation path. |
| State (as a table) | BState, LEGAL |
Six states, five legal moves out of the two live ones. A table makes "cancel a boarded passenger" fail in one place. |
| Facade | BookingService |
Callers do hold, ticket, cancel, close. Seat maps, counts and policies are behind one lock. |
Step 5: Code#
The interfaces#
# airline.py, part 1: cabins, flights, policies (overbooking and refunds) and the booking
import math
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
class Cabin(Enum):
BUSINESS = 1
ECONOMY = 2
class SoldOut(Exception):
pass
class SeatTaken(Exception):
pass
class HoldExpired(Exception):
pass
class IllegalState(Exception):
pass
@dataclass(frozen=True)
class Flight:
fid: str
departs_at: int # seconds on the caller's clock
layout: dict # Cabin -> seat labels, such as ["10A", "10B"]
class OverbookingPolicy(ABC):
@abstractmethod
def limit(self, capacity: int) -> int:
"""How many passengers may hold or have tickets in a cabin of `capacity` seats."""
@abstractmethod
def compensation(self, fare: int) -> int:
"""What a passenger denied boarding is paid on top of the refund, given the fare they paid."""
class NoOverbooking(OverbookingPolicy):
def limit(self, capacity: int) -> int:
return capacity
def compensation(self, fare: int) -> int:
return 0
class PercentOverbooking(OverbookingPolicy):
def __init__(self, rate: float, comp_multiple: int = 2) -> None:
self.rate, self.comp_multiple = rate, comp_multiple
def limit(self, capacity: int) -> int:
return math.floor(capacity * (1 + self.rate)) # never fewer than the seats
def compensation(self, fare: int) -> int:
return fare * self.comp_multiple
class RefundPolicy(ABC):
@abstractmethod
def refund(self, paid: int, fare_class: str, hours_before: int) -> int: ...
class TieredRefund(RefundPolicy):
def __init__(self, tiers: dict) -> None:
self.tiers = tiers # fare class -> [(minimum hours before departure, percent refunded)], any order
def refund(self, paid: int, fare_class: str, hours_before: int) -> int:
for min_hours, percent in sorted(self.tiers.get(fare_class, []), reverse=True):
if hours_before >= min_hours:
return paid * percent // 100
return 0
class BState(Enum):
HELD = 1 # seats and capacity reserved, not paid
TICKETED = 2 # paid
CANCELLED = 3
EXPIRED = 4 # the hold lapsed before payment
BOARDED = 5
BUMPED = 6 # denied boarding on an overbooked flight
LEGAL = {
BState.HELD: {BState.TICKETED, BState.CANCELLED, BState.EXPIRED},
BState.TICKETED: {BState.CANCELLED, BState.BOARDED, BState.BUMPED},
}
@dataclass
class Booking:
pnr: str
fid: str
cabin: Cabin
fare_class: str
fare: int # per passenger, in cents
passengers: list
seats: list # one label per passenger, or None until seats are assigned
expires_at: int
state: BState = BState.HELD
ticketed_at: int = 0
money: int = 0 # refund or compensation paid out, in cents
def move(self, to: BState) -> None:
if to not in LEGAL.get(self.state, set()):
raise IllegalState(f"{self.pnr}: {self.state.name} -> {to.name}")
self.state = to// AirlineDemo.java: flights, overbooking and refund policies, holds with expiry, and a booking state machine
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
enum Cabin { BUSINESS, ECONOMY }
class SoldOut extends RuntimeException {
SoldOut(String message) { super(message); }
}
class SeatTaken extends RuntimeException {
SeatTaken(String message) { super(message); }
}
class HoldExpired extends RuntimeException {
HoldExpired(String message) { super(message); }
}
class IllegalState extends RuntimeException {
IllegalState(String message) { super(message); }
}
record Flight(String fid, int departsAt, Map<Cabin, List<String>> layout) {} // departsAt: seconds on the caller's clock
interface OverbookingPolicy {
/** How many passengers may hold or have tickets in a cabin of `capacity` seats. */
int limit(int capacity);
/** What a passenger denied boarding is paid on top of the refund, given the fare they paid. */
int compensation(int fare);
}
final class NoOverbooking implements OverbookingPolicy {
public int limit(int capacity) { return capacity; }
public int compensation(int fare) { return 0; }
}
final class PercentOverbooking implements OverbookingPolicy {
final double rate;
final int compMultiple;
PercentOverbooking(double rate, int compMultiple) {
this.rate = rate;
this.compMultiple = compMultiple;
}
public int limit(int capacity) { return (int) Math.floor(capacity * (1 + rate)); } // never fewer than the seats
public int compensation(int fare) { return fare * compMultiple; }
}
interface RefundPolicy {
int refund(int paid, String fareClass, int hoursBefore);
}
final class TieredRefund implements RefundPolicy {
final Map<String, List<int[]>> tiers; // fare class -> {minimum hours before departure, percent refunded}
TieredRefund(Map<String, List<int[]>> tiers) { this.tiers = tiers; }
public int refund(int paid, String fareClass, int hoursBefore) {
List<int[]> sorted = new ArrayList<>(tiers.getOrDefault(fareClass, List.of()));
sorted.sort(Comparator.<int[]>comparingInt(t -> -t[0]));
for (int[] t : sorted) if (hoursBefore >= t[0]) return paid * t[1] / 100;
return 0;
}
}
enum BState {
HELD, // seats and capacity reserved, not paid
TICKETED, // paid
CANCELLED,
EXPIRED, // the hold lapsed before payment
BOARDED,
BUMPED // denied boarding on an overbooked flight
}
final class Booking {
static final Map<BState, Set<BState>> LEGAL = Map.of(
BState.HELD, EnumSet.of(BState.TICKETED, BState.CANCELLED, BState.EXPIRED),
BState.TICKETED, EnumSet.of(BState.CANCELLED, BState.BOARDED, BState.BUMPED));
final String pnr, fid, fareClass;
final Cabin cabin;
final int fare; // per passenger, in cents
final List<String> passengers;
List<String> seats; // one label per passenger, or null until seats are assigned
final int expiresAt;
BState state = BState.HELD;
int ticketedAt = 0;
int money = 0; // refund or compensation paid out, in cents
Booking(String pnr, String fid, Cabin cabin, String fareClass, int fare, List<String> passengers, List<String> seats, int expiresAt) {
this.pnr = pnr;
this.fid = fid;
this.cabin = cabin;
this.fareClass = fareClass;
this.fare = fare;
this.passengers = passengers;
this.seats = seats;
this.expiresAt = expiresAt;
}
void move(BState to) {
if (!LEGAL.getOrDefault(state, Set.of()).contains(to)) throw new IllegalState(pnr + ": " + state + " -> " + to);
state = to;
}
}// airline.cpp: flights, overbooking and refund policies, holds with expiry, and a booking state machine
#include <algorithm>
#include <cmath>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
enum class Cabin { Business, Economy };
std::string name(Cabin c) { return c == Cabin::Business ? "BUSINESS" : "ECONOMY"; }
struct SoldOut : std::runtime_error { using std::runtime_error::runtime_error; };
struct SeatTaken : std::runtime_error { using std::runtime_error::runtime_error; };
struct HoldExpired : std::runtime_error { using std::runtime_error::runtime_error; };
struct IllegalState : std::runtime_error { using std::runtime_error::runtime_error; };
struct Flight {
std::string fid;
int departsAt; // seconds on the caller's clock
std::map<Cabin, std::vector<std::string>> layout;
};
class OverbookingPolicy {
public:
virtual ~OverbookingPolicy() = default;
// How many passengers may hold or have tickets in a cabin of `capacity` seats.
virtual int limit(int capacity) const = 0;
// What a passenger denied boarding is paid on top of the refund, given the fare they paid.
virtual int compensation(int fare) const = 0;
};
class NoOverbooking : public OverbookingPolicy {
public:
int limit(int capacity) const override { return capacity; }
int compensation(int fare) const override { return 0; }
};
class PercentOverbooking : public OverbookingPolicy {
public:
PercentOverbooking(double rate, int compMultiple) : rate_(rate), compMultiple_(compMultiple) {}
int limit(int capacity) const override { return static_cast<int>(std::floor(capacity * (1 + rate_))); } // never fewer than the seats
int compensation(int fare) const override { return fare * compMultiple_; }
private:
double rate_;
int compMultiple_;
};
class RefundPolicy {
public:
virtual ~RefundPolicy() = default;
virtual int refund(int paid, const std::string& fareClass, int hoursBefore) const = 0;
};
class TieredRefund : public RefundPolicy {
public:
// fare class -> {minimum hours before departure, percent refunded}
explicit TieredRefund(std::map<std::string, std::vector<std::pair<int, int>>> tiers) : tiers_(std::move(tiers)) {}
int refund(int paid, const std::string& fareClass, int hoursBefore) const override {
auto it = tiers_.find(fareClass);
if (it == tiers_.end()) return 0;
auto sorted = it->second;
std::sort(sorted.begin(), sorted.end(), std::greater<>());
for (const auto& t : sorted) if (hoursBefore >= t.first) return paid * t.second / 100;
return 0;
}
private:
std::map<std::string, std::vector<std::pair<int, int>>> tiers_;
};
enum class BState {
Held, // seats and capacity reserved, not paid
Ticketed, // paid
Cancelled,
Expired, // the hold lapsed before payment
Boarded,
Bumped // denied boarding on an overbooked flight
};
std::string name(BState s) {
static const char* names[] = {"HELD", "TICKETED", "CANCELLED", "EXPIRED", "BOARDED", "BUMPED"};
return names[static_cast<int>(s)];
}
struct Booking {
std::string pnr, fid;
Cabin cabin;
std::string fareClass;
int fare; // per passenger, in cents
std::vector<std::string> passengers;
std::vector<std::optional<std::string>> seats; // one label per passenger, or empty until seats are assigned
int expiresAt;
BState state = BState::Held;
int ticketedAt = 0;
int money = 0; // refund or compensation paid out, in cents
void move(BState to) {
static const std::map<BState, std::set<BState>> legal = {
{BState::Held, {BState::Ticketed, BState::Cancelled, BState::Expired}},
{BState::Ticketed, {BState::Cancelled, BState::Boarded, BState::Bumped}},
};
auto it = legal.find(state);
if (it == legal.end() || !it->second.count(to)) throw IllegalState(pnr + ": " + name(state) + " -> " + name(to));
state = to;
}
};// airline.js: flights, overbooking and refund policies, holds with expiry, and a booking state machine
const Cabin = Object.freeze({ BUSINESS: "BUSINESS", ECONOMY: "ECONOMY" });
class SoldOut extends Error {}
class SeatTaken extends Error {}
class HoldExpired extends Error {}
class IllegalState extends Error {}
// departsAt: seconds on the caller's clock; layout: cabin -> seat labels
const flight = (fid, departsAt, layout) => Object.freeze({ fid, departsAt, layout });
class OverbookingPolicy {
limit(capacity) { throw new Error("not implemented"); } // how many passengers may hold or have tickets in a cabin
compensation(fare) { throw new Error("not implemented"); } // paid on top of the refund to a passenger denied boarding
}
class NoOverbooking extends OverbookingPolicy {
limit(capacity) { return capacity; }
compensation(fare) { return 0; }
}
class PercentOverbooking extends OverbookingPolicy {
constructor(rate, compMultiple = 2) {
super();
Object.assign(this, { rate, compMultiple });
}
limit(capacity) { return Math.floor(capacity * (1 + this.rate)); } // never fewer than the seats
compensation(fare) { return fare * this.compMultiple; }
}
class RefundPolicy {
refund(paid, fareClass, hoursBefore) { throw new Error("not implemented"); }
}
class TieredRefund extends RefundPolicy {
constructor(tiers) {
super();
this.tiers = tiers; // fare class -> [[minimum hours before departure, percent refunded]], any order
}
refund(paid, fareClass, hoursBefore) {
const sorted = [...(this.tiers[fareClass] ?? [])].sort((a, b) => b[0] - a[0]);
for (const [minHours, percent] of sorted) if (hoursBefore >= minHours) return Math.floor((paid * percent) / 100);
return 0;
}
}
const BState = Object.freeze({
HELD: "HELD", // seats and capacity reserved, not paid
TICKETED: "TICKETED", // paid
CANCELLED: "CANCELLED",
EXPIRED: "EXPIRED", // the hold lapsed before payment
BOARDED: "BOARDED",
BUMPED: "BUMPED", // denied boarding on an overbooked flight
});
const LEGAL = {
HELD: ["TICKETED", "CANCELLED", "EXPIRED"],
TICKETED: ["CANCELLED", "BOARDED", "BUMPED"],
};
class Booking {
constructor(pnr, fid, cabin, fareClass, fare, passengers, seats, expiresAt) {
Object.assign(this, { pnr, fid, cabin, fareClass, fare, passengers, seats, expiresAt }); // fare: per passenger, in cents; seats: a label per passenger, or null
this.state = BState.HELD;
this.ticketedAt = 0;
this.money = 0; // refund or compensation paid out, in cents
}
move(to) {
if (!(LEGAL[this.state] ?? []).includes(to)) throw new IllegalState(`${this.pnr}: ${this.state} -> ${to}`);
this.state = to;
}
}TieredRefund stores tiers as (minimum hours before departure, percent) and takes the first tier, longest notice first, that the cancellation still meets, so FLEX: [(24, 100), (2, 50)] reads "full refund from 24 hours out, half from 2 hours out, nothing after". Money is whole cents, and paid * percent // 100 is integer arithmetic in all four languages. Booking.move is the only way to change a state, and it checks the table.
The booking service#
hold does everything at once, under one lock: sweep expired holds, check the flight has not left, check sold + n <= limit, check each requested seat is in the cabin, unique and free, then create the booking and take the capacity and the seats together. If any check fails nothing has changed. ticket stamps the order of purchase, which the close step uses. cancel refunds only what was paid, and HELD bookings cancel for nothing. close runs once check-in is shut.
# part 2: the booking service: holds, tickets, cancellations and the check-in close
class BookingService:
def __init__(self, flights: list, overbooking: OverbookingPolicy, refunds: RefundPolicy, hold_ttl: int = 900) -> None:
self.flights = {f.fid: f for f in flights}
self.overbooking, self.refunds, self.hold_ttl = overbooking, refunds, hold_ttl
self.bookings: dict[str, Booking] = {}
self.owner: dict[tuple, str] = {} # (flight id, seat label) -> pnr
self.sold: dict[tuple, int] = {} # (flight id, cabin) -> passengers on HELD or TICKETED bookings
self._next = 1001
self._lock = threading.RLock() # capacity check, seat check and write are one step
def _free(self, b: Booking) -> None: # give back capacity and any seats
self.sold[(b.fid, b.cabin)] -= len(b.passengers)
for seat in b.seats:
if seat:
del self.owner[(b.fid, seat)]
b.seats = [None] * len(b.passengers)
def expire(self, now: int) -> int: # lazy: called before every hold and ticket
with self._lock:
lapsed = [b for b in self.bookings.values() if b.state is BState.HELD and b.expires_at <= now]
for b in lapsed:
b.move(BState.EXPIRED)
self._free(b)
return len(lapsed)
def hold(self, fid: str, cabin: Cabin, fare_class: str, fare: int, passengers: list, now: int, seats: list | None = None) -> Booking:
with self._lock:
self.expire(now)
flight, n = self.flights[fid], len(passengers)
layout = flight.layout[cabin]
if now >= flight.departs_at:
raise IllegalState(f"{fid} has departed")
if self.sold.get((fid, cabin), 0) + n > self.overbooking.limit(len(layout)):
raise SoldOut(f"{fid} {cabin.name}: no room for {n}")
chosen = list(seats) if seats else [None] * n
if len(chosen) != n:
raise ValueError("one seat per passenger, or none")
for s in (s for s in chosen if s):
if s not in layout or chosen.count(s) > 1:
raise ValueError(f"{s} is not a free choice in {cabin.name}")
if (fid, s) in self.owner:
raise SeatTaken(f"{fid} {s}")
b = Booking(f"P{self._next}", fid, cabin, fare_class, fare, list(passengers), chosen, now + self.hold_ttl)
self._next += 1
self.bookings[b.pnr] = b
self.sold[(fid, cabin)] = self.sold.get((fid, cabin), 0) + n
for s in chosen:
if s:
self.owner[(fid, s)] = b.pnr
return b
def ticket(self, pnr: str, now: int) -> Booking: # payment arrived
with self._lock:
self.expire(now)
b = self.bookings[pnr]
if b.state is BState.EXPIRED:
raise HoldExpired(pnr)
b.move(BState.TICKETED)
b.ticketed_at = now
return b
def cancel(self, pnr: str, now: int) -> int: # returns the refund in cents
with self._lock:
self.expire(now)
b = self.bookings[pnr]
flight = self.flights[b.fid]
if now >= flight.departs_at:
raise IllegalState(f"{b.fid} has departed")
paid = b.fare * len(b.passengers) if b.state is BState.TICKETED else 0
b.move(BState.CANCELLED)
self._free(b)
b.money = self.refunds.refund(paid, b.fare_class, (flight.departs_at - now) // 3600) if paid else 0
return b.money
def close(self, fid: str, now: int) -> dict: # check-in closes: seat everyone it can, bump the rest
with self._lock:
self.expire(now)
flight, out = self.flights[fid], {"boarded": [], "bumped": []}
for cabin, layout in flight.layout.items():
queue = sorted((b for b in self.bookings.values() if b.fid == fid and b.cabin is cabin and b.state is BState.TICKETED),
key=lambda b: (b.ticketed_at, b.pnr)) # last to buy is first bumped
for b in queue:
free = [s for s in layout if (fid, s) not in self.owner]
need = b.seats.count(None)
if need > len(free):
paid = b.fare * len(b.passengers)
b.move(BState.BUMPED)
self._free(b)
b.money = paid + len(b.passengers) * self.overbooking.compensation(b.fare)
out["bumped"].append(b.pnr)
continue
for i, s in enumerate(b.seats):
if s is None:
b.seats[i] = free.pop(0)
self.owner[(fid, b.seats[i])] = b.pnr
b.move(BState.BOARDED)
out["boarded"].append(b.pnr)
return out/** The booking service: holds, tickets, cancellations and the check-in close. */
final class BookingService {
final Map<String, Flight> flights = new HashMap<>();
final OverbookingPolicy overbooking;
final RefundPolicy refunds;
final int holdTtl;
final Map<String, Booking> bookings = new LinkedHashMap<>();
final Map<String, String> owner = new HashMap<>(); // "flight/seat" -> pnr
final Map<String, Integer> sold = new HashMap<>(); // "flight/cabin" -> passengers on HELD or TICKETED bookings
private int next = 1001;
private final ReentrantLock lock = new ReentrantLock(); // capacity check, seat check and write are one step
BookingService(List<Flight> flights, OverbookingPolicy overbooking, RefundPolicy refunds, int holdTtl) {
for (Flight f : flights) this.flights.put(f.fid(), f);
this.overbooking = overbooking;
this.refunds = refunds;
this.holdTtl = holdTtl;
}
int soldIn(String fid, Cabin cabin) { return sold.getOrDefault(fid + "/" + cabin, 0); }
private void free(Booking b) { // give back capacity and any seats
sold.merge(b.fid + "/" + b.cabin, -b.passengers.size(), Integer::sum);
for (String seat : b.seats) if (seat != null) owner.remove(b.fid + "/" + seat);
b.seats = new ArrayList<>(java.util.Collections.nCopies(b.passengers.size(), null));
}
int expire(int now) { // lazy: called before every hold and ticket
lock.lock();
try {
List<Booking> lapsed = new ArrayList<>();
for (Booking b : bookings.values()) if (b.state == BState.HELD && b.expiresAt <= now) lapsed.add(b);
for (Booking b : lapsed) {
b.move(BState.EXPIRED);
free(b);
}
return lapsed.size();
} finally { lock.unlock(); }
}
Booking hold(String fid, Cabin cabin, String fareClass, int fare, List<String> passengers, int now, List<String> seats) {
lock.lock();
try {
expire(now);
Flight flight = flights.get(fid);
int n = passengers.size();
List<String> layout = flight.layout().get(cabin);
if (now >= flight.departsAt()) throw new IllegalState(fid + " has departed");
if (soldIn(fid, cabin) + n > overbooking.limit(layout.size())) throw new SoldOut(fid + " " + cabin + ": no room for " + n);
List<String> chosen = seats == null ? new ArrayList<>(java.util.Collections.nCopies(n, null)) : new ArrayList<>(seats);
if (chosen.size() != n) throw new IllegalArgumentException("one seat per passenger, or none");
for (String s : chosen) {
if (s == null) continue;
if (!layout.contains(s) || chosen.indexOf(s) != chosen.lastIndexOf(s)) throw new IllegalArgumentException(s + " is not a free choice in " + cabin);
if (owner.containsKey(fid + "/" + s)) throw new SeatTaken(fid + " " + s);
}
Booking b = new Booking("P" + next++, fid, cabin, fareClass, fare, new ArrayList<>(passengers), chosen, now + holdTtl);
bookings.put(b.pnr, b);
sold.merge(fid + "/" + cabin, n, Integer::sum);
for (String s : chosen) if (s != null) owner.put(fid + "/" + s, b.pnr);
return b;
} finally { lock.unlock(); }
}
Booking ticket(String pnr, int now) { // payment arrived
lock.lock();
try {
expire(now);
Booking b = bookings.get(pnr);
if (b.state == BState.EXPIRED) throw new HoldExpired(pnr);
b.move(BState.TICKETED);
b.ticketedAt = now;
return b;
} finally { lock.unlock(); }
}
int cancel(String pnr, int now) { // returns the refund in cents
lock.lock();
try {
expire(now);
Booking b = bookings.get(pnr);
Flight flight = flights.get(b.fid);
if (now >= flight.departsAt()) throw new IllegalState(b.fid + " has departed");
int paid = b.state == BState.TICKETED ? b.fare * b.passengers.size() : 0;
b.move(BState.CANCELLED);
free(b);
b.money = paid > 0 ? refunds.refund(paid, b.fareClass, (flight.departsAt() - now) / 3600) : 0;
return b.money;
} finally { lock.unlock(); }
}
/** Check-in closes: seat everyone it can, bump the rest. Returns {"boarded": [...], "bumped": [...]}. */
Map<String, List<String>> close(String fid, int now) {
lock.lock();
try {
expire(now);
Flight flight = flights.get(fid);
Map<String, List<String>> out = new LinkedHashMap<>();
out.put("boarded", new ArrayList<>());
out.put("bumped", new ArrayList<>());
for (var entry : flight.layout().entrySet()) {
List<Booking> queue = new ArrayList<>();
for (Booking b : bookings.values()) if (b.fid.equals(fid) && b.cabin == entry.getKey() && b.state == BState.TICKETED) queue.add(b);
queue.sort(Comparator.<Booking>comparingInt(b -> b.ticketedAt).thenComparing(b -> b.pnr)); // last to buy is first bumped
for (Booking b : queue) {
List<String> free = new ArrayList<>();
for (String s : entry.getValue()) if (!owner.containsKey(fid + "/" + s)) free.add(s);
int need = (int) b.seats.stream().filter(s -> s == null).count();
if (need > free.size()) {
int paid = b.fare * b.passengers.size();
b.move(BState.BUMPED);
free(b);
b.money = paid + b.passengers.size() * overbooking.compensation(b.fare);
out.get("bumped").add(b.pnr);
continue;
}
for (int i = 0; i < b.seats.size(); i++) {
if (b.seats.get(i) == null) {
b.seats.set(i, free.remove(0));
owner.put(fid + "/" + b.seats.get(i), b.pnr);
}
}
b.move(BState.BOARDED);
out.get("boarded").add(b.pnr);
}
}
return out;
} finally { lock.unlock(); }
}
}// The booking service: holds, tickets, cancellations and the check-in close.
class BookingService {
public:
BookingService(std::vector<Flight> flights, std::unique_ptr<OverbookingPolicy> overbooking, std::unique_ptr<RefundPolicy> refunds, int holdTtl)
: overbooking_(std::move(overbooking)), refunds_(std::move(refunds)), holdTtl_(holdTtl) {
for (auto& f : flights) flights_[f.fid] = f;
}
int soldIn(const std::string& fid, Cabin cabin) const {
auto it = sold_.find({fid, cabin});
return it == sold_.end() ? 0 : it->second;
}
int limit(int capacity) const { return overbooking_->limit(capacity); }
int expire(int now) { // lazy: called before every hold and ticket
std::lock_guard<std::recursive_mutex> g(lock_);
int lapsed = 0;
for (auto& entry : bookings_) {
Booking& b = *entry.second;
if (b.state == BState::Held && b.expiresAt <= now) {
b.move(BState::Expired);
free(b);
lapsed++;
}
}
return lapsed;
}
Booking& hold(const std::string& fid, Cabin cabin, const std::string& fareClass, int fare, std::vector<std::string> passengers,
int now, std::vector<std::optional<std::string>> seats = {}) {
std::lock_guard<std::recursive_mutex> g(lock_);
expire(now);
const Flight& flight = flights_.at(fid);
int n = static_cast<int>(passengers.size());
const auto& layout = flight.layout.at(cabin);
if (now >= flight.departsAt) throw IllegalState(fid + " has departed");
if (soldIn(fid, cabin) + n > overbooking_->limit(static_cast<int>(layout.size()))) {
throw SoldOut(fid + " " + name(cabin) + ": no room for " + std::to_string(n));
}
std::vector<std::optional<std::string>> chosen = seats.empty() ? std::vector<std::optional<std::string>>(n) : seats;
if (static_cast<int>(chosen.size()) != n) throw std::invalid_argument("one seat per passenger, or none");
for (const auto& s : chosen) {
if (!s) continue;
if (std::find(layout.begin(), layout.end(), *s) == layout.end() || std::count(chosen.begin(), chosen.end(), s) > 1) {
throw std::invalid_argument(*s + " is not a free choice in " + name(cabin));
}
if (owner_.count({fid, *s})) throw SeatTaken(fid + " " + *s);
}
auto b = std::make_shared<Booking>(Booking{"P" + std::to_string(next_++), fid, cabin, fareClass, fare, std::move(passengers), chosen, now + holdTtl_});
bookings_[b->pnr] = b;
sold_[{fid, cabin}] += n;
for (const auto& s : chosen) if (s) owner_[{fid, *s}] = b->pnr;
return *b;
}
Booking& ticket(const std::string& pnr, int now) { // payment arrived
std::lock_guard<std::recursive_mutex> g(lock_);
expire(now);
Booking& b = *bookings_.at(pnr);
if (b.state == BState::Expired) throw HoldExpired(pnr);
b.move(BState::Ticketed);
b.ticketedAt = now;
return b;
}
int cancel(const std::string& pnr, int now) { // returns the refund in cents
std::lock_guard<std::recursive_mutex> g(lock_);
expire(now);
Booking& b = *bookings_.at(pnr);
const Flight& flight = flights_.at(b.fid);
if (now >= flight.departsAt) throw IllegalState(b.fid + " has departed");
int paid = b.state == BState::Ticketed ? b.fare * static_cast<int>(b.passengers.size()) : 0;
b.move(BState::Cancelled);
free(b);
b.money = paid > 0 ? refunds_->refund(paid, b.fareClass, (flight.departsAt - now) / 3600) : 0;
return b.money;
}
// Check-in closes: seat everyone it can, bump the rest. Returns the boarded and the bumped pnrs.
std::pair<std::vector<std::string>, std::vector<std::string>> close(const std::string& fid, int now) {
std::lock_guard<std::recursive_mutex> g(lock_);
expire(now);
const Flight& flight = flights_.at(fid);
std::vector<std::string> boarded, bumped;
for (const auto& entry : flight.layout) {
std::vector<Booking*> queue;
for (auto& kv : bookings_) {
Booking& b = *kv.second;
if (b.fid == fid && b.cabin == entry.first && b.state == BState::Ticketed) queue.push_back(&b);
}
std::sort(queue.begin(), queue.end(), [](const Booking* a, const Booking* b) { // last to buy is first bumped
return std::make_pair(a->ticketedAt, a->pnr) < std::make_pair(b->ticketedAt, b->pnr);
});
for (Booking* b : queue) {
std::vector<std::string> freeSeats;
for (const auto& s : entry.second) if (!owner_.count({fid, s})) freeSeats.push_back(s);
int need = static_cast<int>(std::count_if(b->seats.begin(), b->seats.end(), [](const auto& s) { return !s; }));
if (need > static_cast<int>(freeSeats.size())) {
int paid = b->fare * static_cast<int>(b->passengers.size());
b->move(BState::Bumped);
free(*b);
b->money = paid + static_cast<int>(b->passengers.size()) * overbooking_->compensation(b->fare);
bumped.push_back(b->pnr);
continue;
}
for (auto& s : b->seats) {
if (!s) {
s = freeSeats.front();
freeSeats.erase(freeSeats.begin());
owner_[{fid, *s}] = b->pnr;
}
}
b->move(BState::Boarded);
boarded.push_back(b->pnr);
}
}
return {boarded, bumped};
}
private:
void free(Booking& b) { // give back capacity and any seats
sold_[{b.fid, b.cabin}] -= static_cast<int>(b.passengers.size());
for (const auto& s : b.seats) if (s) owner_.erase({b.fid, *s});
b.seats.assign(b.passengers.size(), std::nullopt);
}
std::map<std::string, Flight> flights_;
std::unique_ptr<OverbookingPolicy> overbooking_;
std::unique_ptr<RefundPolicy> refunds_;
int holdTtl_;
std::map<std::string, std::shared_ptr<Booking>> bookings_;
std::map<std::pair<std::string, std::string>, std::string> owner_; // (flight, seat) -> pnr
std::map<std::pair<std::string, Cabin>, int> sold_; // (flight, cabin) -> passengers on HELD or TICKETED bookings
int next_ = 1001;
std::recursive_mutex lock_; // capacity check, seat check and write are one step
};/** The booking service: holds, tickets, cancellations and the check-in close. (No lock: JS runs one call at a time.) */
class BookingService {
constructor(flights, overbooking, refunds, holdTtl = 900) {
this.flights = new Map(flights.map((f) => [f.fid, f]));
Object.assign(this, { overbooking, refunds, holdTtl });
this.bookings = new Map();
this.owner = new Map(); // "flight/seat" -> pnr
this.sold = new Map(); // "flight/cabin" -> passengers on HELD or TICKETED bookings
this.next = 1001;
}
soldIn(fid, cabin) { return this.sold.get(`${fid}/${cabin}`) ?? 0; }
#free(b) { // give back capacity and any seats
this.sold.set(`${b.fid}/${b.cabin}`, this.soldIn(b.fid, b.cabin) - b.passengers.length);
for (const seat of b.seats) if (seat) this.owner.delete(`${b.fid}/${seat}`);
b.seats = b.passengers.map(() => null);
}
expire(now) { // lazy: called before every hold and ticket
const lapsed = [...this.bookings.values()].filter((b) => b.state === BState.HELD && b.expiresAt <= now);
for (const b of lapsed) {
b.move(BState.EXPIRED);
this.#free(b);
}
return lapsed.length;
}
hold(fid, cabin, fareClass, fare, passengers, now, seats = null) {
this.expire(now);
const f = this.flights.get(fid);
const n = passengers.length;
const layout = f.layout[cabin];
if (now >= f.departsAt) throw new IllegalState(`${fid} has departed`);
if (this.soldIn(fid, cabin) + n > this.overbooking.limit(layout.length)) throw new SoldOut(`${fid} ${cabin}: no room for ${n}`);
const chosen = seats ? [...seats] : passengers.map(() => null);
if (chosen.length !== n) throw new RangeError("one seat per passenger, or none");
for (const s of chosen.filter(Boolean)) {
if (!layout.includes(s) || chosen.filter((x) => x === s).length > 1) throw new RangeError(`${s} is not a free choice in ${cabin}`);
if (this.owner.has(`${fid}/${s}`)) throw new SeatTaken(`${fid} ${s}`);
}
const b = new Booking(`P${this.next++}`, fid, cabin, fareClass, fare, [...passengers], chosen, now + this.holdTtl);
this.bookings.set(b.pnr, b);
this.sold.set(`${fid}/${cabin}`, this.soldIn(fid, cabin) + n);
for (const s of chosen.filter(Boolean)) this.owner.set(`${fid}/${s}`, b.pnr);
return b;
}
ticket(pnr, now) { // payment arrived
this.expire(now);
const b = this.bookings.get(pnr);
if (b.state === BState.EXPIRED) throw new HoldExpired(pnr);
b.move(BState.TICKETED);
b.ticketedAt = now;
return b;
}
cancel(pnr, now) { // returns the refund in cents
this.expire(now);
const b = this.bookings.get(pnr);
const f = this.flights.get(b.fid);
if (now >= f.departsAt) throw new IllegalState(`${b.fid} has departed`);
const paid = b.state === BState.TICKETED ? b.fare * b.passengers.length : 0;
b.move(BState.CANCELLED);
this.#free(b);
b.money = paid ? this.refunds.refund(paid, b.fareClass, Math.floor((f.departsAt - now) / 3600)) : 0;
return b.money;
}
close(fid, now) { // check-in closes: seat everyone it can, bump the rest
this.expire(now);
const f = this.flights.get(fid);
const out = { boarded: [], bumped: [] };
for (const [cabin, layout] of Object.entries(f.layout)) {
const queue = [...this.bookings.values()]
.filter((b) => b.fid === fid && b.cabin === cabin && b.state === BState.TICKETED)
.sort((a, b) => a.ticketedAt - b.ticketedAt || (a.pnr < b.pnr ? -1 : 1)); // last to buy is first bumped
for (const b of queue) {
const free = layout.filter((s) => !this.owner.has(`${fid}/${s}`));
const need = b.seats.filter((s) => s === null).length;
if (need > free.length) {
const paid = b.fare * b.passengers.length;
b.move(BState.BUMPED);
this.#free(b);
b.money = paid + b.passengers.length * this.overbooking.compensation(b.fare);
out.bumped.push(b.pnr);
continue;
}
b.seats = b.seats.map((s) => s ?? free.shift());
for (const s of b.seats) this.owner.set(`${fid}/${s}`, b.pnr);
b.move(BState.BOARDED);
out.boarded.push(b.pnr);
}
}
return out;
}
}The code#
One flight with 4 business and 6 economy seats, 25 percent overbooking, and a day of bookings. Asha and Ben choose seats 10A and 10B. A second request for 10A fails. Two groups buy without choosing seats and fill economy to seven tickets for six seats. A business hold lapses, two business tickets are cancelled with different refunds, and the check-in close seats one group and bumps the last buyer.
# part 3: one flight, a 25 percent overbooking policy (large so six seats show it), and a day of bookings
def hrs(h: int, m: int = 0) -> int:
return h * 3600 + m * 60
def show(b: Booking) -> str: # "10A,10B"; a passenger without a seat yet shows as "-"
return ",".join(s or "-" for s in b.seats)
if __name__ == "__main__":
flight = Flight("AB100", hrs(200), {Cabin.BUSINESS: ["1A", "1B", "2A", "2B"],
Cabin.ECONOMY: ["10A", "10B", "10C", "11A", "11B", "11C"]})
refunds = TieredRefund({"FLEX": [(24, 100), (2, 50)], "SAVER": [(72, 50)]})
svc = BookingService([flight], PercentOverbooking(0.25), refunds)
Y, J = Cabin.ECONOMY, Cabin.BUSINESS
b1 = svc.hold("AB100", Y, "FLEX", 30000, ["Asha", "Ben"], hrs(0), ["10A", "10B"])
print(b1.pnr, b1.state.name, show(b1), "expires", b1.expires_at)
try:
svc.hold("AB100", Y, "SAVER", 12000, ["Cy"], hrs(0, 1), ["10A"])
except SeatTaken as e:
print("seat taken:", e)
svc.ticket(b1.pnr, hrs(0, 5))
b2 = svc.hold("AB100", Y, "SAVER", 12000, ["Cy", "Di", "Eli"], hrs(0, 6))
b3 = svc.hold("AB100", Y, "SAVER", 12000, ["Fay", "Gus"], hrs(0, 7))
svc.ticket(b2.pnr, hrs(0, 8))
svc.ticket(b3.pnr, hrs(0, 9))
print("economy sold:", svc.sold[("AB100", Y)], "of", len(flight.layout[Y]), "seats, limit", svc.overbooking.limit(6))
try:
svc.hold("AB100", Y, "SAVER", 12000, ["Hal"], hrs(0, 10))
except SoldOut as e:
print("sold out:", e)
j1 = svc.hold("AB100", J, "FLEX", 90000, ["Ivy"], hrs(0, 10))
print("sweep at 1h expired", svc.expire(hrs(1)), "->", j1.state.name)
try:
svc.ticket(j1.pnr, hrs(1))
except HoldExpired as e:
print("too late to pay:", e)
j2 = svc.ticket(svc.hold("AB100", J, "SAVER", 50000, ["Jo"], hrs(1)).pnr, hrs(1, 1))
j3 = svc.ticket(svc.hold("AB100", J, "FLEX", 90000, ["Kim"], hrs(1, 2)).pnr, hrs(1, 3))
print("cancel", j2.pnr, "100h before:", svc.cancel(j2.pnr, hrs(100)))
print("cancel", j3.pnr, "10h before:", svc.cancel(j3.pnr, hrs(190)))
result = svc.close("AB100", hrs(199))
print("boarded:", ",".join(result["boarded"]), "| bumped:", ",".join(result["bumped"]))
for b in (b1, b2, b3):
print(" ", b.pnr, b.state.name, show(b), "money", b.money)public class AirlineDemo {
static int hrs(int h, int m) { return h * 3600 + m * 60; }
static int hrs(int h) { return hrs(h, 0); }
static String show(Booking b) { // "10A,10B"; a passenger without a seat yet shows as "-"
return String.join(",", b.seats.stream().map(s -> s == null ? "-" : s).toList());
}
// one flight, a 25 percent overbooking policy (large so six seats show it), and a day of bookings
public static void main(String[] args) {
Map<Cabin, List<String>> layout = new EnumMap<>(Cabin.class);
layout.put(Cabin.BUSINESS, List.of("1A", "1B", "2A", "2B"));
layout.put(Cabin.ECONOMY, List.of("10A", "10B", "10C", "11A", "11B", "11C"));
Flight flight = new Flight("AB100", hrs(200), layout);
Map<String, List<int[]>> tiers = new HashMap<>();
tiers.put("FLEX", List.of(new int[] {24, 100}, new int[] {2, 50}));
tiers.put("SAVER", List.of(new int[] {72, 50}));
BookingService svc = new BookingService(List.of(flight), new PercentOverbooking(0.25, 2), new TieredRefund(tiers), 900);
Cabin y = Cabin.ECONOMY, j = Cabin.BUSINESS;
Booking b1 = svc.hold("AB100", y, "FLEX", 30000, List.of("Asha", "Ben"), hrs(0), List.of("10A", "10B"));
System.out.println(b1.pnr + " " + b1.state + " " + show(b1) + " expires " + b1.expiresAt);
try {
svc.hold("AB100", y, "SAVER", 12000, List.of("Cy"), hrs(0, 1), List.of("10A"));
} catch (SeatTaken e) {
System.out.println("seat taken: " + e.getMessage());
}
svc.ticket(b1.pnr, hrs(0, 5));
Booking b2 = svc.hold("AB100", y, "SAVER", 12000, List.of("Cy", "Di", "Eli"), hrs(0, 6), null);
Booking b3 = svc.hold("AB100", y, "SAVER", 12000, List.of("Fay", "Gus"), hrs(0, 7), null);
svc.ticket(b2.pnr, hrs(0, 8));
svc.ticket(b3.pnr, hrs(0, 9));
System.out.println("economy sold: " + svc.soldIn("AB100", y) + " of " + layout.get(y).size() + " seats, limit " + svc.overbooking.limit(6));
try {
svc.hold("AB100", y, "SAVER", 12000, List.of("Hal"), hrs(0, 10), null);
} catch (SoldOut e) {
System.out.println("sold out: " + e.getMessage());
}
Booking j1 = svc.hold("AB100", j, "FLEX", 90000, List.of("Ivy"), hrs(0, 10), null);
System.out.println("sweep at 1h expired " + svc.expire(hrs(1)) + " -> " + j1.state);
try {
svc.ticket(j1.pnr, hrs(1));
} catch (HoldExpired e) {
System.out.println("too late to pay: " + e.getMessage());
}
Booking j2 = svc.ticket(svc.hold("AB100", j, "SAVER", 50000, List.of("Jo"), hrs(1), null).pnr, hrs(1, 1));
Booking j3 = svc.ticket(svc.hold("AB100", j, "FLEX", 90000, List.of("Kim"), hrs(1, 2), null).pnr, hrs(1, 3));
System.out.println("cancel " + j2.pnr + " 100h before: " + svc.cancel(j2.pnr, hrs(100)));
System.out.println("cancel " + j3.pnr + " 10h before: " + svc.cancel(j3.pnr, hrs(190)));
Map<String, List<String>> result = svc.close("AB100", hrs(199));
System.out.println("boarded: " + String.join(",", result.get("boarded")) + " | bumped: " + String.join(",", result.get("bumped")));
for (Booking b : List.of(b1, b2, b3)) System.out.println(" " + b.pnr + " " + b.state + " " + show(b) + " money " + b.money);
}
}int hrs(int h, int m = 0) { return h * 3600 + m * 60; }
std::string show(const Booking& b) { // "10A,10B"; a passenger without a seat yet shows as "-"
std::string out;
for (const auto& s : b.seats) out += (out.empty() ? "" : ",") + (s ? *s : "-");
return out;
}
std::string join(const std::vector<std::string>& v) {
std::string out;
for (const auto& s : v) out += (out.empty() ? "" : ",") + s;
return out;
}
// one flight, a 25 percent overbooking policy (large so six seats show it), and a day of bookings
int main() {
std::map<Cabin, std::vector<std::string>> layout = {
{Cabin::Business, {"1A", "1B", "2A", "2B"}},
{Cabin::Economy, {"10A", "10B", "10C", "11A", "11B", "11C"}},
};
BookingService svc({Flight{"AB100", hrs(200), layout}}, std::make_unique<PercentOverbooking>(0.25, 2),
std::make_unique<TieredRefund>(std::map<std::string, std::vector<std::pair<int, int>>>{
{"FLEX", {{24, 100}, {2, 50}}}, {"SAVER", {{72, 50}}}}),
900);
Cabin y = Cabin::Economy, j = Cabin::Business;
Booking& b1 = svc.hold("AB100", y, "FLEX", 30000, {"Asha", "Ben"}, hrs(0), {std::string("10A"), std::string("10B")});
std::cout << b1.pnr << " " << name(b1.state) << " " << show(b1) << " expires " << b1.expiresAt << "\n";
try {
svc.hold("AB100", y, "SAVER", 12000, {"Cy"}, hrs(0, 1), {std::string("10A")});
} catch (const SeatTaken& e) {
std::cout << "seat taken: " << e.what() << "\n";
}
svc.ticket(b1.pnr, hrs(0, 5));
Booking& b2 = svc.hold("AB100", y, "SAVER", 12000, {"Cy", "Di", "Eli"}, hrs(0, 6));
Booking& b3 = svc.hold("AB100", y, "SAVER", 12000, {"Fay", "Gus"}, hrs(0, 7));
svc.ticket(b2.pnr, hrs(0, 8));
svc.ticket(b3.pnr, hrs(0, 9));
std::cout << "economy sold: " << svc.soldIn("AB100", y) << " of " << layout[y].size() << " seats, limit " << svc.limit(6) << "\n";
try {
svc.hold("AB100", y, "SAVER", 12000, {"Hal"}, hrs(0, 10));
} catch (const SoldOut& e) {
std::cout << "sold out: " << e.what() << "\n";
}
Booking& j1 = svc.hold("AB100", j, "FLEX", 90000, {"Ivy"}, hrs(0, 10));
std::cout << "sweep at 1h expired " << svc.expire(hrs(1)) << " -> " << name(j1.state) << "\n";
try {
svc.ticket(j1.pnr, hrs(1));
} catch (const HoldExpired& e) {
std::cout << "too late to pay: " << e.what() << "\n";
}
Booking& j2 = svc.ticket(svc.hold("AB100", j, "SAVER", 50000, {"Jo"}, hrs(1)).pnr, hrs(1, 1));
Booking& j3 = svc.ticket(svc.hold("AB100", j, "FLEX", 90000, {"Kim"}, hrs(1, 2)).pnr, hrs(1, 3));
std::cout << "cancel " << j2.pnr << " 100h before: " << svc.cancel(j2.pnr, hrs(100)) << "\n";
std::cout << "cancel " << j3.pnr << " 10h before: " << svc.cancel(j3.pnr, hrs(190)) << "\n";
auto result = svc.close("AB100", hrs(199));
std::cout << "boarded: " << join(result.first) << " | bumped: " << join(result.second) << "\n";
for (Booking* b : {&b1, &b2, &b3}) std::cout << " " << b->pnr << " " << name(b->state) << " " << show(*b) << " money " << b->money << "\n";
}const hrs = (h, m = 0) => h * 3600 + m * 60;
const show = (b) => b.seats.map((s) => s ?? "-").join(","); // "10A,10B"; a passenger without a seat yet shows as "-"
// one flight, a 25 percent overbooking policy (large so six seats show it), and a day of bookings
const layout = {
[Cabin.BUSINESS]: ["1A", "1B", "2A", "2B"],
[Cabin.ECONOMY]: ["10A", "10B", "10C", "11A", "11B", "11C"],
};
const refunds = new TieredRefund({ FLEX: [[24, 100], [2, 50]], SAVER: [[72, 50]] });
const svc = new BookingService([flight("AB100", hrs(200), layout)], new PercentOverbooking(0.25), refunds);
const Y = Cabin.ECONOMY;
const J = Cabin.BUSINESS;
const b1 = svc.hold("AB100", Y, "FLEX", 30000, ["Asha", "Ben"], hrs(0), ["10A", "10B"]);
console.log(b1.pnr, b1.state, show(b1), "expires", b1.expiresAt);
try {
svc.hold("AB100", Y, "SAVER", 12000, ["Cy"], hrs(0, 1), ["10A"]);
} catch (e) {
if (!(e instanceof SeatTaken)) throw e;
console.log("seat taken:", e.message);
}
svc.ticket(b1.pnr, hrs(0, 5));
const b2 = svc.hold("AB100", Y, "SAVER", 12000, ["Cy", "Di", "Eli"], hrs(0, 6));
const b3 = svc.hold("AB100", Y, "SAVER", 12000, ["Fay", "Gus"], hrs(0, 7));
svc.ticket(b2.pnr, hrs(0, 8));
svc.ticket(b3.pnr, hrs(0, 9));
console.log("economy sold:", svc.soldIn("AB100", Y), "of", layout[Y].length, "seats, limit", svc.overbooking.limit(6));
try {
svc.hold("AB100", Y, "SAVER", 12000, ["Hal"], hrs(0, 10));
} catch (e) {
if (!(e instanceof SoldOut)) throw e;
console.log("sold out:", e.message);
}
const j1 = svc.hold("AB100", J, "FLEX", 90000, ["Ivy"], hrs(0, 10));
console.log("sweep at 1h expired", svc.expire(hrs(1)), "->", j1.state);
try {
svc.ticket(j1.pnr, hrs(1));
} catch (e) {
if (!(e instanceof HoldExpired)) throw e;
console.log("too late to pay:", e.message);
}
const j2 = svc.ticket(svc.hold("AB100", J, "SAVER", 50000, ["Jo"], hrs(1)).pnr, hrs(1, 1));
const j3 = svc.ticket(svc.hold("AB100", J, "FLEX", 90000, ["Kim"], hrs(1, 2)).pnr, hrs(1, 3));
console.log("cancel", j2.pnr, "100h before:", svc.cancel(j2.pnr, hrs(100)));
console.log("cancel", j3.pnr, "10h before:", svc.cancel(j3.pnr, hrs(190)));
const result = svc.close("AB100", hrs(199));
console.log("boarded:", result.boarded.join(","), "| bumped:", result.bumped.join(","));
for (const b of [b1, b2, b3]) console.log(" ", b.pnr, b.state, show(b), "money", b.money);P1001 HELD 10A,10B expires 900
seat taken: AB100 10A
economy sold: 7 of 6 seats, limit 7
sold out: AB100 ECONOMY: no room for 1
sweep at 1h expired 1 -> EXPIRED
too late to pay: P1004
cancel P1005 100h before: 25000
cancel P1006 10h before: 45000
boarded: P1001,P1002 | bumped: P1003
P1001 BOARDED 10A,10B money 0
P1002 BOARDED 10C,11A,11B money 0
P1003 BUMPED -,- money 72000Economy sold 7 tickets for 6 seats: 2 for Asha and Ben, 3 for the first group and 2 for the second. The eighth is refused, because the limit is floor(6 × 1.25) = 7. The business hold made at 0:10 lapses by the sweep at hour 1 and cannot be paid. Cancelling the saver business ticket 100 hours out returns 50 percent of 50,000 cents; cancelling the flexible ticket 10 hours out falls in the 2-hour tier and returns 50 percent of 90,000. At close, P1001 already owns 10A and 10B; P1002 takes the next three free seats; only one seat is left, so P1003, the last to buy and needing two, is bumped. It gets its 24,000 cents back plus compensation of 2 passengers × 2 × 12,000 = 48,000: 72,000 in all. The bump is a designed outcome, with a price attached.
Step 6: Concurrency and edge cases#
Two customers want the last seat, or the last unit of capacity; both pass the check if the check and the write are separate. One lock makes expire, the capacity check, the seat checks and the writes one step. The lock is re-entrant because hold calls expire. At airline scale one lock per flight (not per service) is the unit, and the same logic becomes a conditional update on the flight's counter and a unique constraint on (flight, seat).
- Two groups choosing the same seat: the second raises
SeatTaken; neither booking is half-made - A seat in the wrong cabin, or the same seat twice in one request: reject before touching anything
- A hold that lapses before payment:
ticketraisesHoldExpired; the capacity and seats were already returned - Cancelling a
HELDbooking: free and instant; only aTICKETEDone earns a refund - Cancelling after departure: not allowed; a no-show is a different flow
- Close called twice: only
TICKETEDbookings are processed, so the second run does nothing - A group that cannot be seated together: the whole group is bumped, never split, so a family is not separated
- Overbooking that never exceeds the seats:
limitis never below capacity, soNoOverbookingand a rate of zero agree
Step 7: Follow-ups and the interview checklist#
- "Change the flight." A move is a cancellation without a refund plus a new hold, in one operation: hold on the new flight first, then cancel the old one, so a failure never leaves the customer with nothing. The fare difference is a payment or a refund.
- "Upgrade to business." Hold the business seat first, then release the economy one, for the same reason. Charge the difference.
- "A waitlist for a full flight." A queue per
(flight, cabin). When a cancellation or an expiry frees capacity, the service offers it to the first entry with a short hold, reusingholdand its expiry. - "Who is bumped first?" The lesson bumps the last to buy. Real rules look at fare class, status and check-in time, and the compensation is set by regulation. Make it a third strategy,
BumpOrder, that sorts the queue passed to the close step; the rest does not change. - "Multi-leg itineraries." A booking per leg inside one itinerary object: hold all legs, and if any hold fails release the others. The same all-or-nothing as seats in a group, one level up.
- "Thousands of customers at once." One lock per flight, or a conditional update on the flight's
soldrow. The expiry sweep becomes a periodic job instead of running before every call.
Interview checklist#
Tick these off before you say done.
- I asked whether we sell seats or capacity, how long a hold lasts, and what happens when everyone shows up.
- I kept two separate things:
soldper cabin (a number the policy caps) andownerper seat (labels), so a booking can exist with no seat. - I put overbooking behind
OverbookingPolicywithlimitandcompensation, and floored the limit so it is never below the seat count. - I made hold atomic: sweep, check capacity and every requested seat, then write, all under one lock, with nothing changed on failure.
- I gave holds an expiry on a passed-in clock, swept lazily, with
ticketfailing cleanly on a lapsed hold. - I wrote the booking's legal moves as a table and made every change go through
move. - I put refunds behind a
RefundPolicywith tiers by hours before departure and fare class, in whole cents. - I closed check-in in purchase order, seating groups together and bumping a whole group rather than splitting it.
- I priced the bump: the refund plus compensation from the policy, not a magic number.
- I said what changes at scale: a lock or conditional update per flight, a unique constraint on
(flight, seat), and a sweeper job.
Common mistakes#
- Modelling the seat map only, so there is no capacity count and overbooking can't be expressed
- Overbooking as
if seats < capacity + 5insidehold, instead of a policy that can change - Check, then write, so two buyers pass the check for the last seat
- Splitting a group at the gate, or booking some of its passengers and failing the rest
- Refund rules as an
ifper fare name, scattered throughcancel - Floats for money, and a refund of
0.1 + 0.2cents time.sleepfor the hold timer, instead of a clock you pass in- No state table, so a boarded passenger can be cancelled and refunded
Airline booking
Q1/4 A cabin has 6 seats and an overbooking policy of 25 percent. What is the most passengers it may hold or have ticketed?
Answer & explanation
Answer: C
The limit is
floor(capacity × (1 + rate)), which is never below the seat count. Rounding up could sell more than the policy allows.Q2/4 Why can a booking exist without a seat, and why does it matter for overbooking?
Answer & explanation
Answer: B
If a hold always took a specific seat, you could never sell more tickets than seats. The two layers are what make the policy possible.
Q3/4 A flexible ticket worth 90,000 cents is cancelled 10 hours before departure. The tiers are (24 h, 100 percent) and (2 h, 50 percent). What is refunded?
Answer & explanation
Answer: C
Walk the tiers from the longest notice: 10 hours is under 24, so the first tier fails; it is at least 2, so the second applies: 50 percent of 90,000.
Q4/4 At close, 2 seats are free and a group of 3 who have not chosen seats is next in line. What happens?
Answer & explanation
Answer: B
A group is never split. The booking either gets all its seats or is bumped as one unit with refund and compensation. Later bookings in the queue may still fit the free seats.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


