Airline Management System
Aircraft, flights, schedules, crew, bookings, seats, itineraries. Multi-aggregate transactional design at scale.
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
Airlineoperates a fleet ofAircraft. Each aircraft has a model, capacity, and a cabin configuration (first / business / economy with per-class seat counts). - A
Scheduledefines a recurring route: origin, destination, departure / arrival times, days of week, assigned aircraft model. AFlightis one materialised instance of a schedule on a specific date. - A
Seatbelongs 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
Itinerarycandidates — each a 1-3 leg sequence of flights connecting origin to destination. - A passenger places a
SeatHoldon one seat per leg of an itinerary. Holds carry a TTL (5-15 minutes). The passenger pays within the TTL window; on payment aBookingis created (the holds become confirmed seats). On TTL expiry or payment failure, all holds in the itinerary are released atomically. - A
BookingwalksHeld -> Confirmed | Released. AFlightwalksScheduled -> Boarding -> Departed -> Landed | Cancelled | Diverted. Crewis 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
Crewaggregate is named; assignment is just a foreign key. - Dynamic pricing / revenue management.
FareStrategyseam 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 toReleased; rebooking the passenger is a follow-up workflow, not modeled. - Codeshare / interline agreements. Single-carrier itineraries only.
- GDS / channel distribution. A
GDSAdapterseam 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^3flights / day,~10^5daily seat searches,~10^4bookings / 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) andFlight.status(Scheduled -> Boarding -> Departed -> Landed | Cancelled | Diverted) andSeat.status(Available -> Held -> Booked, withReleasedgoing back toAvailable). - Strategy pattern on
FareStrategyand onSeatAllocationStrategy(auto-assign window / aisle / together-with-companion). - Aggregate boundary between
ScheduleandFlight: aScheduleis the template; aFlightis 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
BookingManagergod class. Booking owns its lifecycle; the itinerary owns its legs. - No coupling between
AircraftandBooking. Bookings referenceFlight, andFlightknows itsAircraft. An aircraft swap (operational reality) changes theFlight.aircraftfield; bookings keep their seat-id references (and the seat-map is per-flight). - No
Usergod class with payment, frequent-flyer, KYC.Passengeris 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. Seatis the lock granularity. ASeat.hold()issynchronizedon the seat object — two concurrent holds on the same seat get serialised and one throws.SeatHoldis 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;
BookingServicedoesn’t need to re-check. releaseBooking()is separate fromrelease(holdId). Releasing a held seat (TTL expiry) and releasing a booked seat (flight cancellation) are different operations.- Booking is constructed post-confirm. A
HELDbooking state is allowed in the enum but never persisted — the booking object exists only once seats areBOOKED. This is a deliberate scoping choice (the alternative — a long-livedHeldbooking — adds a second state machine).
Trade-offs and extensions#
Doubles as the what-I-cut list — recite at the end of the round.
| Decision | Why | Cost / extension shape |
|---|---|---|
| Seats are per-flight, not per-aircraft | Aircraft swap is operational reality. | Seats live on Flight; an aircraft swap is just a Flight.aircraft = newAircraft (cabin config must match). |
| Seat hold with TTL | UX during payment; the lock granularity. | The TTL is a tunable; the sweeper is a background thread. |
| Hold + confirm as a saga | Multi-leg atomicity without distributed transactions. | At ~10x flight volume per route, holds and confirms move to durable queues; the saga becomes a workflow. |
FareStrategy interface | Dynamic pricing is an HLD / ML problem. | Plug in revenue-management algorithms; the OOD doesn’t change. |
SeatAllocationStrategy interface | Window / aisle / sit-together rules vary by carrier. | Strategy seam; concrete implementations per loyalty tier. |
| Crew assignment foreign-key only | Rostering is a constrained-optimisation problem. | CrewRosteringService aggregate; legality rules (FDP, rest, qualifications) are its own domain. |
| One booking per passenger | OOD scope. | GroupBooking aggregate above Booking; same hold + confirm saga. |
| No codeshare / interline | Single-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 Released | Simple cancellation. | IROPs re-accommodation is a workflow engine — automated rebooking on the next available flight, with rules per fare class. |
| In-process search | OOD scope. | A real airline publishes inventory to GDS (Amadeus, Sabre); search becomes a distributed query. GDSAdapter seam. |
| No refund/change fee policy | One-shot cancel = full refund. | FarePolicy per ticket class; refund / change-fee Strategy. |
Scale breakpoints (where the design must change):
- At
~10xdaily flight volume, theFlight.seatsmap (~300 entries) is fine, but the search across~10^4flights/day per region needs an index — typically a separateAvailabilityServicereading from a denormalised table. - At
~10xconcurrent searches,FareStrategy.fareForbecomes 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.
GroupBookingaboveBookingholds N seats on N legs; same saga, larger fanout. - Loyalty program.
LoyaltyAccountaggregate; mileage accrual is a listener onBookingConfirmed. - Ancillary services (bags, seat selection, meals).
Ancillaryadd-ons attached to aBooking; same payment flow. - IROPs rebooking. A
RebookingPolicystrategy runs when a flight cancels — finds the next valid itinerary and creates a new booking, optionally with passenger consent. - Aircraft maintenance. A
MaintenanceWindowblocks anAircraftfromSchedulematerialisation; 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 onSeat, notBooking. - “Two passengers grab the last window seat.” —
Seat.holdissynchronized; the loser seesSeatUnavailableException, 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 = Cancelledfires a listener; the listener finds all bookings on the flight, flips each toReleased, callsseat.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.
Related#
- Movie Ticket Booking System — the closest sibling pattern at smaller scope: seat hold + payment + booking confirm.
- Hotel Management System — same multi-aggregate transactional shape over rooms instead of seats.
- Amazon Online Shopping System — the canonical saga / compensation Advanced sibling.
- State Pattern —
Booking,Flight, andSeatare three distinct state machines. - Strategy Pattern —
FareStrategyandSeatAllocationStrategycarve out the volatile rules.