Skip to content
System design course
Ch.4 · Designing real systems·how to build it ·6 min read

Building a flight booking system

Implement cached itinerary search, fare re-validation at booking, and the hold-pay-confirm reservation with PNR issuance.


Itinerary search over cached inventory

Search assembles connecting itineraries from cached availability, falling back to the GDS on a miss:

def search(origin, dest, date, pax):
    key = (origin, dest, date, pax)
    flights = fare_cache.get(key)
    if flights is None:                               # miss → query GDS (slow, rate-limited)
        flights = gds.query(origin, dest, date, pax)
        fare_cache.set(key, flights, ttl="5m")        # short TTL — fares change fast
    itineraries = build_itineraries(flights)          # direct + connections (graph search)
    return rank(itineraries, by=["price", "duration", "stops"])

build_itineraries does a constrained graph search over flights/airports (valid connection times, max stops). Pre-cache popular routes off-peak so common searches never hit the GDS.

Re-validate at booking (freshness)

Cached results can be stale, so confirm the exact fare + availability before holding:

def begin_booking(itinerary, pax):
    live = gds.price_and_availability(itinerary, pax)  # authoritative, right now
    if live.price != itinerary.price or not live.available:
        raise FareChanged(live)                        # show updated price to the user
    return live

Hold → pay → confirm

Reserve seats with a temporary hold, take payment, then confirm — releasing on failure (Airbnb/e-commerce pattern, against the airline’s inventory):

def book(itinerary, pax, payment_token, idempotency_key):
    live = begin_booking(itinerary, pax)
    hold = gds.hold_seats(itinerary, pax, ttl="10m")   # temporary reservation (lease)
    try:
        charge(payment_token, live.price, idempotency_key)   # idempotent (payment lesson)
        pnr = gds.confirm(hold)                          # commit the booking → PNR
        bookings.save(pnr, itinerary, pax, status="ticketed")
        return pnr
    except (PaymentError, ConfirmError):
        gds.release(hold)                               # free seats on any failure
        raise

The hold prevents the seat being sold to someone else during checkout; the airline’s inventory (or your mirror with a per-seat unique constraint) is the no-double-sell authority.

PNR and post-booking

A confirmed booking is a durable PNR record. Cancellations/changes are a saga with the airline (request refund + release seats), idempotent and reconciled:

def cancel(pnr_id, idempotency_key):
    Saga(pnr_id, [
        Step("airline_cancel", lambda: gds.cancel(pnr_id), undo=noop),
        Step("refund",         lambda: refund(pnr_id, idempotency_key), undo=noop),
    ]).run()
    bookings.set(pnr_id, status="cancelled")

Scale and failure handling

  • Search load → cache + pre-cached hot routes + parallel GDS fan-out; the GDS is the rate-limited, costly dependency to protect.
  • Stale fare at booking → re-validate; surface the new price (don’t silently charge a different amount).
  • Payment fails / hold expires → release seats (no orphaned holds — TTL).
  • GDS timeout on confirm → status-check + reconcile (don’t double-book or lose the ticket); idempotent confirm by booking ref.
  • CP booking → never confirm without a live hold + successful payment.

The takeaway

Concrete signals: cached itinerary search (short TTLs, pre-cache hot routes) over the GDS, fare re-validation at booking, and a hold → pay → confirm reservation with idempotent payment and a PNR, cancellations via a saga. Read-heavy volatile search split from a CP hold-and-confirm booking is the reservation blueprint — and Ticketmaster pushes the contention to the extreme next.