Airline Management System

Aircraft, flights, schedules, crew, bookings, seats, itineraries. Multi-aggregate transactional design at scale.

System Advanced
18 min read
ood case-study airline state-pattern strategy-pattern

Context#

An airline runs aircraft on scheduled routes, sells seats on those flights, and tracks the operational state of every crew member, gate, and itinerary. The surface is enormous: fleet management, route planning, crew rostering (with FAA / DGCA / EASA rules), ground operations, baggage, loyalty programs, dynamic pricing, codeshare and interline agreements, irregular-operations (IROPs) re-accommodation, weather-driven cancellations, customs / immigration data, government reporting. Each of those is its own system.

The HLD overlap is everywhere. GDS / inventory distribution (Amadeus, Sabre, Travelport) is a federated-search HLD problem. Dynamic pricing and revenue management is an ML system. Crew rostering is a constrained-optimisation problem. IROPs handling is a workflow engine over hundreds of business rules. The LLD discipline is to name the seams (“PricingPolicy is a strategy; CrewRostering is out of scope; GDSAdapter publishes inventory”) and design the in-process shape: flight, schedule, seat, hold, booking, itinerary, and the transactional dance among them.

The interviewer’s hidden objectives, in order:

  • Can you declare scope first and cut decisively? Crew rostering and dynamic pricing are the canonical cuts.
  • Can you identify the seven core aggregates — Airline, Aircraft, Flight, Schedule, Seat, Booking, Itinerary — and keep their state machines independent?
  • Can you model a seat hold with TTL and explain why it exists (UX during payment) versus why naive locking would be wrong?
  • Can you draw the Booking lifecycle (Held -> Confirmed | Released) and the Flight lifecycle (Scheduled -> Boarding -> Departed -> Landed | Cancelled | Diverted)?
  • Can you handle multi-leg itineraries with connection-time constraints without a god-class?
  • Can you defend what fails atomically — a 3-leg itinerary either books all 3 legs or releases all 3 holds.

Requirements (functional and non-functional)#

Scope is the most points-bearing decision. The cut below fits a 45-minute round.

Functional — in scope.

  • An Airline operates a fleet of Aircraft. Each aircraft has a model, capacity, and a cabin configuration (first / business / economy with per-class seat counts).
  • A Schedule defines a recurring route: origin, destination, departure / arrival times, days of week, assigned aircraft model. A Flight is one materialised instance of a schedule on a specific date.
  • A Seat belongs to a flight (not to an aircraft — seats are per-flight so an aircraft swap doesn’t break bookings).
  • A passenger searches for flights between two airports on a date. The result is a list of Itinerary candidates — each a 1-3 leg sequence of flights connecting origin to destination.
  • A passenger places a SeatHold on one seat per leg of an itinerary. Holds carry a TTL (5-15 minutes). The passenger pays within the TTL window; on payment a Booking is created (the holds become confirmed seats). On TTL expiry or payment failure, all holds in the itinerary are released atomically.
  • A Booking walks Held -> Confirmed | Released. A Flight walks Scheduled -> Boarding -> Departed -> Landed | Cancelled | Diverted.
  • Crew is assigned to flights (captain, first officer, cabin crew). The model carries assignment; the rostering algorithm is out of scope.

Functional — out of scope (called out explicitly).

  • Crew rostering / pairing optimisation. A constrained-optimisation problem in its own right. The Crew aggregate is named; assignment is just a foreign key.
  • Dynamic pricing / revenue management. FareStrategy seam returns a fare; the algorithm is HLD / ML.
  • Loyalty programs, miles, status tiers. Out.
  • Baggage tracking and ground operations. Out.
  • IROPs re-accommodation. When a flight is Cancelled, the bookings flip to Released; rebooking the passenger is a follow-up workflow, not modeled.
  • Codeshare / interline agreements. Single-carrier itineraries only.
  • GDS / channel distribution. A GDSAdapter seam publishes inventory; the protocol is out.
  • Government / immigration / APIS reporting. Out.
  • Aircraft maintenance scheduling. Out.
  • Refunds, change fees, partial cancellations. Booking is single-state confirm / release; finer-grained money flow is a follow-up.
  • Multi-passenger group bookings. One passenger per Booking; group flows are a follow-up.

Reciting these cuts on the whiteboard first is the high-altitude move.

Non-functional.

  • The model should support ~10^3 flights / day, ~10^5 daily seat searches, ~10^4 bookings / day in principle. OOD optimises only the in-process shape — persistence is a seam.
  • Seat hold and confirm must be atomic across all legs of an itinerary (3-leg itinerary either holds all 3 or none).
  • Two passengers attempting the same seat must not both succeed.
  • Booking / flight state changes are observable (notifications, audit, downstream operations).

Use case diagram#

┌────────────────┐
│ Passenger │
└────────┬───────┘
┌────────────────┼────────────────┬────────────────┐
▼ ▼ ▼ ▼
[search flights] [hold seats] [pay / confirm] [view itinerary]
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Airline Management System │
└─────┬───────────────┬─────────────────┬─────────────────┘
▲ ▲ ▲
│ │ │
┌───────────┐ ┌──────────────┐ ┌────────────┐
│ OpsAgent │ │ Scheduler │ │ GDS │
├───────────┤ ├──────────────┤ ├────────────┤
│ cancel │ │ materialise │ │ inventory │
│ delay │ │ flights │ │ feed (cut) │
│ divert │ │ from schedule│ │ │
└───────────┘ └──────────────┘ └────────────┘

Four actors: the passenger, the operations agent (mutates flight state on the day of operations), the scheduler (materialises flights from schedules nightly), and the GDS (external distribution, cut).

Class diagram#

┌────────────────────────┐ ┌────────────────────────┐
│ Airline │ │ Aircraft │
├────────────────────────┤ 1 * ├────────────────────────┤
│ code, name ├────────►│ tailNumber, model │
│ fleet : List<Aircraft> │ │ cabin : CabinConfig │
│ schedules : List │ └────────────────────────┘
└─────────┬──────────────┘
│ 1
│ *
┌────────────────────────┐ ┌────────────────────────┐
│ Schedule │ │ Flight │
├────────────────────────┤ 1 * ├────────────────────────┤
│ id, originAirport ├────────►│ id, schedule, date │
│ destAirport │materialise│ aircraft : Aircraft │
│ departTime, arriveTime │ │ seats : Map<id,Seat> │
│ daysOfWeek │ │ status : FlightStatus │
│ aircraftModel │ │ crew : List<CrewMember>│
└────────────────────────┘ └─────────┬──────────────┘
│ 1
│ *
┌────────────────────────┐
│ Seat │
├────────────────────────┤
│ id (e.g. "12A") │
│ class : CabinClass │
│ status : SeatStatus │
│ holdId : Optional<Id> │
│ bookingId : Optional │
└────────────────────────┘
┌────────────────────────┐ ┌────────────────────────┐
│ Itinerary │ │ Booking │
├────────────────────────┤ ├────────────────────────┤
│ legs : List<Flight> │ 1 1 │ id, passenger │
│ totalDuration │◄────────┤ itinerary : Itinerary │
│ totalFare : Money │ │ seatIds : List │
│ validConnections() │ │ status : BookingStatus │
└────────────────────────┘ │ fare : Money │
│ payment : PaymentRef │
┌────────────────────────┐ └────────────────────────┘
│ SeatHold │
├────────────────────────┤ ┌────────────────────────┐
│ id, seatId, passenger │ │ FareStrategy │◁── Static,
├────────────────────────┤ ├────────────────────────┤ Bucket,
│ expiresAt │ │ fareFor(flight, cls) │ Dynamic
│ release() / confirm() │ └────────────────────────┘
└────────────────────────┘
┌────────────────────────┐
│ Crew │◁───── CrewMember (captain, FO, cabin)
└────────────────────────┘

Four patterns are doing the load-bearing work:

  • State pattern on Booking.status (Held -> Confirmed | Released) and Flight.status (Scheduled -> Boarding -> Departed -> Landed | Cancelled | Diverted) and Seat.status (Available -> Held -> Booked, with Released going back to Available).
  • Strategy pattern on FareStrategy and on SeatAllocationStrategy (auto-assign window / aisle / together-with-companion).
  • Aggregate boundary between Schedule and Flight: a Schedule is the template; a Flight is the materialised instance with its own seats. Aircraft swap on a flight does not touch the schedule.
  • Saga over hold creation across legs: hold all legs atomically; if any hold fails, release the successful ones (the classic compensation pattern from the shopping system).

What is deliberately not in the diagram:

  • No BookingManager god class. Booking owns its lifecycle; the itinerary owns its legs.
  • No coupling between Aircraft and Booking. Bookings reference Flight, and Flight knows its Aircraft. An aircraft swap (operational reality) changes the Flight.aircraft field; bookings keep their seat-id references (and the seat-map is per-flight).
  • No User god class with payment, frequent-flyer, KYC. Passenger is just identity + contact for this round.

Sequence diagram (key flows)#

3-leg itinerary hold, the multi-aggregate saga:

Passenger BookingSvc Flight1 Flight2 Flight3 HoldRegistry
│ hold(itinerary={F1,F2,F3}, seats={12A,8C,21F}) │ │
│──────────►│ │ │ │ │
│ │ hold(12A) │ │ │ │
│ │───────────────►│ │ │ │
│ │ seat.hold(holdId, ttl=10m) │ │ │
│ │ ok │ │ │
│ │◄───────────────│ │ │ │
│ │ hold(8C) │ │ │
│ │─────────────────────────────►│ │ │
│ │ ok │ │
│ │◄─────────────────────────────│ │ │
│ │ hold(21F) │ │ │
│ │───────────────────────────────────────────►│ │ │
│ │ FAIL (already held) │ │
│ │◄───────────────────────────────────────────│ │ │
│ │ compensate: release(12A), release(8C) │ │ │
│ │───────────────►│ ─────────────────────────►│ │ │
│ │ fail to caller │
│◄──────────│ │

Confirm flow on payment success:

Passenger BookingSvc Payment Seat(per leg) Booking HoldRegistry
│ pay(itinerary, holds) │
│──────────►│ │ │ │ │
│ │ pay(fare) │ │ │ │
│ │───────────────►│ │ │ │
│ │ receipt │ │ │ │
│ │◄───────────────│ │ │ │
│ │ confirm(holdId per seat) │ │
│ │──────────────────────────────►│ │ │
│ │ seat.confirm(bookingId) │ │ │
│ │ status: Held -> Booked │ │ │
│ │◄──────────────────────────────│ │ │
│ │ new Booking(Confirmed) │ │
│ │─────────────────────────────────────────────►│ │
│ │ holds.consume(holdIds) │
│ │───────────────────────────────────────────────────────────►│
│ │ Booking │
│◄──────────│ │

Hold-TTL expiry sweeper (a background timer):

HoldRegistry Seat BookingSvc
│ tick (every 30s) │
│ scan expired holds │
│ for each: seat.release(holdId)│
│─────────────────────────────►│
│ status: Held -> Available │
│◄─────────────────────────────│
│ notify(passenger, "hold expired")
│ ──────────────────────────────► (Observer)

Flight cancellation (the operational follow-up):

OpsAgent Flight BookingSvc Booking(many)
│ cancel(F) │ │ │
│───────────►│ │ │
│ │ status: Scheduled -> Cancelled │
│ │ notify listeners │
│ │────────────►│ │
│ │ │ for each booking on F:
│ │ │ booking.release()
│ │ │────────────────►│
│ │ │ notify passenger (Observer)

Activity diagram (for non-trivial state)#

The Booking lifecycle:

┌─────────┐
│ start │
└────┬────┘
┌────────────┐
│ Held │── pay ok ─────────►┌────────────┐
└─────┬──────┘ │ Confirmed │── (terminal except cancel)
│ └────────────┘
│ ttl expire / pay fail / cancel
┌────────────┐
│ Released │ (terminal)
└────────────┘

The Flight lifecycle (the operational state machine):

┌─────────────┐
│ Scheduled │── ops cancel ─────►┌────────────┐
└──────┬──────┘ │ Cancelled │ (terminal)
│ board └────────────┘
┌─────────────┐
│ Boarding │── doors closed ───►┌────────────┐
└─────────────┘ │ Departed │── divert ──►┌──────────┐
└─────┬──────┘ │ Diverted │
│ land └──────────┘
┌────────────┐
│ Landed │ (terminal)
└────────────┘

The Seat lifecycle (the resource state machine):

┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Available │── hold ►│ Held │── confirm ►│ Booked │
└─────────────┘ └──────┬──────┘ └─────────────┘
▲ │ release / expire
│ ▼
└────────────────── (back to Available)

Booked -> Available happens only on flight cancellation (all bookings flip to Released; seats reset).

Java implementation#

A representative slice: the hold saga, the seat / booking state machines, and the itinerary search. Crew and pricing are sketched.

public enum SeatStatus { AVAILABLE, HELD, BOOKED }
public enum BookingStatus { HELD, CONFIRMED, RELEASED }
public enum FlightStatus { SCHEDULED, BOARDING, DEPARTED, LANDED, CANCELLED, DIVERTED }
public enum CabinClass { ECONOMY, BUSINESS, FIRST }
public final class Seat {
private final String id; // e.g. "12A"
private final CabinClass cls;
private SeatStatus status = SeatStatus.AVAILABLE;
private String holdId;
private String bookingId;
public Seat(String id, CabinClass cls) { this.id = id; this.cls = cls; }
public synchronized void hold(String holdId) {
if (status != SeatStatus.AVAILABLE) throw new SeatUnavailableException(id);
this.status = SeatStatus.HELD;
this.holdId = holdId;
}
public synchronized void confirm(String bookingId, String holdId) {
if (status != SeatStatus.HELD || !this.holdId.equals(holdId)) {
throw new IllegalStateException("confirm requires matching hold");
}
this.status = SeatStatus.BOOKED;
this.bookingId = bookingId;
this.holdId = null;
}
public synchronized void release(String holdId) {
if (status == SeatStatus.HELD && this.holdId.equals(holdId)) {
this.status = SeatStatus.AVAILABLE;
this.holdId = null;
}
}
public synchronized void releaseBooking() {
// Called on flight cancellation; flips a booked seat back to available.
if (status == SeatStatus.BOOKED) {
this.status = SeatStatus.AVAILABLE;
this.bookingId = null;
}
}
public SeatStatus status() { return status; }
public String id() { return id; }
public CabinClass cls() { return cls; }
}
public final class SeatHold {
final String holdId;
final String flightId;
final String seatId;
final Instant expiresAt;
SeatHold(String h, String f, String s, Instant e) {
this.holdId = h; this.flightId = f; this.seatId = s; this.expiresAt = e;
}
}
public final class BookingService {
private final Map<String, Flight> flights;
private final HoldRegistry holds;
private final FareStrategy fare;
private final Duration ttl;
private final List<BookingListener> listeners = new CopyOnWriteArrayList<>();
public BookingService(Map<String, Flight> f, HoldRegistry h, FareStrategy fs, Duration ttl) {
this.flights = f; this.holds = h; this.fare = fs; this.ttl = ttl;
}
/** Hold one seat per leg, atomically. */
public List<SeatHold> hold(Itinerary it, List<String> seatIds, String passengerId) {
if (it.legs().size() != seatIds.size()) {
throw new IllegalArgumentException("seats / legs mismatch");
}
Instant expiry = Instant.now().plus(ttl);
List<SeatHold> held = new ArrayList<>();
try {
for (int i = 0; i < it.legs().size(); i++) {
Flight f = it.legs().get(i);
if (f.status() != FlightStatus.SCHEDULED) {
throw new IllegalStateException("flight not scheduled: " + f.id());
}
Seat s = f.seat(seatIds.get(i));
String holdId = UUID.randomUUID().toString();
s.hold(holdId); // throws if not AVAILABLE
SeatHold h = new SeatHold(holdId, f.id(), s.id(), expiry);
holds.put(h);
held.add(h);
}
} catch (RuntimeException e) {
// Compensate: release everything we held so far.
for (SeatHold h : held) {
flights.get(h.flightId).seat(h.seatId).release(h.holdId);
holds.remove(h.holdId);
}
throw e;
}
return held;
}
/** Confirm holds atomically into a booking. */
public Booking confirm(Itinerary it, List<SeatHold> heldSeats,
Passenger p, PaymentStrategy payment) {
// Verify all holds still valid (none expired between hold and pay).
Instant now = Instant.now();
for (SeatHold h : heldSeats) {
if (h.expiresAt.isBefore(now) || !holds.exists(h.holdId)) {
throw new HoldExpiredException(h.holdId);
}
}
Money totalFare = it.legs().stream()
.map(f -> fare.fareFor(f, CabinClass.ECONOMY)) // simplified
.reduce(Money.zero(), Money::plus);
Receipt receipt;
try {
receipt = payment.pay(totalFare);
} catch (PaymentDeclinedException e) {
// Holds remain until TTL; passenger may retry payment.
throw e;
}
String bookingId = UUID.randomUUID().toString();
for (int i = 0; i < it.legs().size(); i++) {
Flight f = it.legs().get(i);
SeatHold h = heldSeats.get(i);
f.seat(h.seatId).confirm(bookingId, h.holdId);
holds.remove(h.holdId);
}
Booking b = new Booking(bookingId, p, it, heldSeats, totalFare, receipt);
listeners.forEach(l -> l.onBookingConfirmed(b));
return b;
}
public void onFlightCancelled(Flight f) {
// Find bookings on this flight; flip to released; refund.
// (BookingRepository seam; omitted here.)
}
}
public final class Booking {
private final String id;
private final Passenger passenger;
private final Itinerary itinerary;
private final List<String> seatIds;
private final Money fare;
private final Receipt receipt;
private BookingStatus status = BookingStatus.HELD;
public Booking(String id, Passenger p, Itinerary it, List<SeatHold> holds,
Money fare, Receipt r) {
this.id = id; this.passenger = p; this.itinerary = it;
this.seatIds = holds.stream().map(h -> h.seatId).toList();
this.fare = fare; this.receipt = r;
this.status = BookingStatus.CONFIRMED; // created post-confirm
}
public void release() {
if (status == BookingStatus.RELEASED) return;
status = BookingStatus.RELEASED;
// Refund + seat reset handled by listener.
}
public BookingStatus status() { return status; }
}
public final class Itinerary {
private final List<Flight> legs;
private static final Duration MIN_CONNECTION = Duration.ofMinutes(45);
public Itinerary(List<Flight> legs) {
if (!validConnections(legs)) throw new IllegalArgumentException("bad connection times");
this.legs = List.copyOf(legs);
}
private static boolean validConnections(List<Flight> legs) {
for (int i = 0; i < legs.size() - 1; i++) {
Flight a = legs.get(i), b = legs.get(i + 1);
if (!a.dest().equals(b.origin())) return false;
Duration gap = Duration.between(a.arrival(), b.departure());
if (gap.compareTo(MIN_CONNECTION) < 0) return false;
}
return true;
}
public List<Flight> legs() { return legs; }
}

Notes the interviewer will look for:

  • Saga, not transaction. The hold step is a sequential per-leg seat.hold(...) with an explicit compensate-on-failure (release everything held so far). There is no single transaction across flights.
  • Seat is the lock granularity. A Seat.hold() is synchronized on the seat object — two concurrent holds on the same seat get serialised and one throws.
  • SeatHold is its own value. It carries the TTL and the holdId; the seat just remembers which holdId is currently parked on it.
  • Itinerary validates connections at construction. Bad itineraries can’t be built; BookingService doesn’t need to re-check.
  • releaseBooking() is separate from release(holdId). Releasing a held seat (TTL expiry) and releasing a booked seat (flight cancellation) are different operations.
  • Booking is constructed post-confirm. A HELD booking state is allowed in the enum but never persisted — the booking object exists only once seats are BOOKED. This is a deliberate scoping choice (the alternative — a long-lived Held booking — adds a second state machine).

Trade-offs and extensions#

Doubles as the what-I-cut list — recite at the end of the round.

DecisionWhyCost / extension shape
Seats are per-flight, not per-aircraftAircraft swap is operational reality.Seats live on Flight; an aircraft swap is just a Flight.aircraft = newAircraft (cabin config must match).
Seat hold with TTLUX during payment; the lock granularity.The TTL is a tunable; the sweeper is a background thread.
Hold + confirm as a sagaMulti-leg atomicity without distributed transactions.At ~10x flight volume per route, holds and confirms move to durable queues; the saga becomes a workflow.
FareStrategy interfaceDynamic pricing is an HLD / ML problem.Plug in revenue-management algorithms; the OOD doesn’t change.
SeatAllocationStrategy interfaceWindow / aisle / sit-together rules vary by carrier.Strategy seam; concrete implementations per loyalty tier.
Crew assignment foreign-key onlyRostering is a constrained-optimisation problem.CrewRosteringService aggregate; legality rules (FDP, rest, qualifications) are its own domain.
One booking per passengerOOD scope.GroupBooking aggregate above Booking; same hold + confirm saga.
No codeshare / interlineSingle-carrier.Itinerary.legs becomes List<Segment> where a segment can reference another airline’s flight; settlement between carriers is its own system.
Flight cancellation flips bookings to ReleasedSimple cancellation.IROPs re-accommodation is a workflow engine — automated rebooking on the next available flight, with rules per fare class.
In-process searchOOD scope.A real airline publishes inventory to GDS (Amadeus, Sabre); search becomes a distributed query. GDSAdapter seam.
No refund/change fee policyOne-shot cancel = full refund.FarePolicy per ticket class; refund / change-fee Strategy.

Scale breakpoints (where the design must change):

  • At ~10x daily flight volume, the Flight.seats map (~300 entries) is fine, but the search across ~10^4 flights/day per region needs an index — typically a separate AvailabilityService reading from a denormalised table.
  • At ~10x concurrent searches, FareStrategy.fareFor becomes the hot path; cache fares per (flight, class, fare-bucket) with short TTLs.
  • At GDS scale, inventory must publish to multiple distribution channels (NDC, EDIFACT) with eventual consistency; the hold itself stays local-only and the GDS sees inventory deltas.

Likely follow-up extensions:

  • Group bookings. GroupBooking above Booking holds N seats on N legs; same saga, larger fanout.
  • Loyalty program. LoyaltyAccount aggregate; mileage accrual is a listener on BookingConfirmed.
  • Ancillary services (bags, seat selection, meals). Ancillary add-ons attached to a Booking; same payment flow.
  • IROPs rebooking. A RebookingPolicy strategy runs when a flight cancels — finds the next valid itinerary and creates a new booking, optionally with passenger consent.
  • Aircraft maintenance. A MaintenanceWindow blocks an Aircraft from Schedule materialisation; flights pick a different tail or get cancelled.

Mock interview follow-ups#

  • “Why a hold with TTL instead of a database lock?” — A DB lock during payment holds a transaction open for minutes (passenger may abandon the page). A hold is a domain concept the system understands — it’s released on timer, on payment failure, or on flight cancellation. The TTL is a UX choice (5-15 minutes covers most payment flows).
  • “3-leg itinerary, leg 3 hold fails. What happens?” — The hold-saga’s compensate path releases the two successful holds and surfaces the failure. The passenger sees “seat unavailable on leg 3” and can pick a different itinerary or different seats.
  • “What’s the state machine on Booking?” — Held -> Confirmed | Released. Confirmed is terminal except for cancellation (which routes to Released). Held was actually elided — the booking object is constructed post-confirm; the Held state lives on Seat, not Booking.
  • “Two passengers grab the last window seat.”Seat.hold is synchronized; the loser sees SeatUnavailableException, the hold saga compensates, the passenger gets a clear error. No two passengers hold the same seat.
  • “Flight cancels — what happens to bookings?”Flight.status = Cancelled fires a listener; the listener finds all bookings on the flight, flips each to Released, calls seat.releaseBooking() per leg, refunds via the payment receipt, and notifies the passenger. IROPs rebooking is out of scope.
  • “What did you cut and why?” — Recite the table. Crew rostering, dynamic pricing, codeshare, GDS distribution, IROPs rebooking, refund policy, group bookings. All real and all separate systems.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.