All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Shared State and Contention

Design a Flight Booking System

IntermediateSeat holdTimeoutsConcurrencyPricing⏱ 2-3 h

Two people, one seat, the same second: holding seats for ten minutes, giving them back when nobody pays, and never selling a seat twice.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyIntermediate
Time2–3 hours to design and code; about 45 minutes in an interview
PrerequisitesInterfaces, maps, locks
ConceptsSeat states (free, held, booked), holds that expire, an injected clock, all-or-nothing holds, a pricing Strategy wrapped by a Decorator, one lock for check-and-change
Real worldAirline and bus ticket sites, cinema and concert seats, train tickets on Eid, any "pick a seat, then pay" flow

Flight booking is the classic "shared resource" question. Choosing a seat and paying are two steps with minutes in between, and thousands of people are looking at the same seat map. The design has to answer three things: how a seat is kept for you while you pay, what happens when you never pay, and how two people clicking the same seat in the same millisecond can't both get it.

Problem statement

Flight CF-141 flies Dhaka to Cox's Bazar. It has 2 business seats (row 1) and 6 economy seats (rows 2 and 3). Before Eid, everyone opens the app at once.

A passenger picks one or more seats. The seats are held for 10 minutes so nobody else can take them while the passenger pays. If they pay in time, the seats are booked and they get a PNR. If they don't pay, or they close the app, the seats go back on sale.

What we are building: the booking core for one flight: the seat map, holds, payment, releasing seats, and prices.

Why it matters: if two people get the same seat, one of them is standing at the gate with a ticket and no seat. If abandoned holds never come back, a full-looking flight takes off half empty.

In one line: let one passenger hold seats for a short time, turn the hold into a booking when they pay, give the seats back when they don't, and never sell a seat twice.

Requirements

Functional requirements

  1. A flight has seats; each seat has a number (2A) and a class (business, economy).
  2. Hold(passenger, seats): keep all the chosen seats for 10 minutes, or none of them if any is taken.
  3. Pay(hold): turn a live hold into a booking with a PNR. A hold whose time is up is refused.
  4. Release(hold): give the seats back at once (the passenger gave up).
  5. When a hold's time is up, its seats become free again without anyone doing anything.
  6. Price each seat by its class; the last free seat of a class costs 25% more.
  7. Refuse seats that don't exist, and the same seat picked twice.

Non-functional requirements

RequirementWhat it means here
Never double-sellTwo people holding the same seat at the same moment: exactly one wins
Seats come backAn unpaid hold frees its seats on time, so they can be sold again
ExtensibilityA new pricing rule (Eid surge, student discount) doesn't touch booking code
TestabilityTime comes from a clock we pass in, so a test can jump 10 minutes without waiting

Assumptions and out of scope

  • One flight, one currency (taka). Payment is one call that always succeeds; no real gateway, no refunds, no cancelling a booking, no passenger details.

What to hand in

  • Entities, the seat state diagram, and where the lock is.
  • Code for hold, pay, release and expiry.
  • Tests for all-or-nothing holds, expiry, pricing and concurrency.
Hint 1: a seat's life

Write down the states a seat can be in, and what moves it from one to the next. There's a way back from one of them.

Hint 2: two seats, one taken

If someone holds 2A and 2B but 2B is taken, should they keep 2A? Families want seats together. Check every seat before changing any.

Hint 3: who frees an expired hold?

You could run a background timer, or you could check for expired holds at the start of every operation. The second one needs no extra goroutine.

Hint 4: the same millisecond

"Is it free?" and "mark it held" must happen as one step. If another goroutine can run between them, both see "free".

Core entities

EntityJob in one sentenceHoldsDoes
SeatOne seat and where it is in its lifenumber, class, state, which hold has itnothing: data
StateA seat's statefree / held / bookednothing: an enum
HoldSeats kept for one passenger until a deadlineid, passenger, seats, total, expiresnothing: a value
BookingA paid reservationPNR, passenger, seats, totalnothing: a value
Fare (interface)Prices one seatdepends on the rulePrice(class, left)
ClassFare, SurgeA fixed price per class; a wrapper that adds a percentage for the last seatsprices; base fare, threshold, percentPrice
Clock (interface)Tells the timenothingNow()
FlightThe entry point; owns seats, holds and the lockseats in map order, live holds, expired holds, countersHold, Pay, Release, Seats

Relationships: Flight owns its seats and holds. It uses a Fare (Strategy) and a Clock, both passed in. Surge wraps another Fare (Decorator). A Seat points to the hold that has it by id, not by pointer, so there's one source of truth: the flight's map of holds.

Diagrams

Class diagram

Seat+ No: string+ Class: Class+ State: State+ Hold: stringFlight- seats: map[string]*Seat- holds: map[string]*Hold- clock: Clock- fare: Fare- holdFor: time.Duration- mu: sync.Mutex+ Hold(who, seats...) (Hold, error)+ Pay(holdID) (Booking, error)+ Release(holdID) error+ Seats() []Seat- releaseExpired()Hold+ ID, Passenger: string+ Seats: []string+ Total: int+ Expires: time.TimeBooking+ PNR, Passenger: string+ Seats: []string+ Total: int«interface»Fare+ Price(c, left) int«interface»Clock+ Now() time.TimeClassFare(a map[Class]int)+ Price(c, left) intSurge+ Base: Fare+ Below, Percent: int+ Price(c, left) intseats1*holdscreatesfareclockbaseFlight owns its seats and holds; one lock guards bothPay turns a live Hold into a Bookingthe fare rule and the clock are interfaces, passed inClassFare and Surge implement Fare (Strategy)Surge holds another Fare and adds to its price (Decorator)
Press play. First Flight with the seats and holds it owns, then the Booking that Pay creates, then the two interfaces it is given, then the fare rules behind Fare, and last how Surge wraps another Fare.

How to read it: Flight owns Seats and Holds, and creates a Booking when someone pays. It is handed two interfaces: Fare for prices and Clock for time, which is why tests can control both. ClassFare and Surge both implement Fare; the hollow diamond from Surge back to Fare is the Decorator: a Surge contains another Fare and adds to its price. A hollow diamond ◇ is a weaker has a: the Surge holds a Fare that was made outside it and can live without it.

Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.

Sequence: a hold, then a late payment

PassengerFlightClockFareHold(Nadia, 2C, 3A)releaseExpired()Now()10:01all seats free?Price(economy, 4) ×25000 eachH2, 10000 tk, pay by 10:11Pay(H2) at 10:12H2 timed outerror: H2 expired at 10:11
Press play. At 10:01 Nadia holds 2C and 3A: Flight clears old holds, asks the clock, checks the seats and asks the fare. At 10:12 she pays, but her hold timed out at 10:11, so the payment is refused.

How to read it: Each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. Both calls start with releaseExpired(), so the seat map is always cleaned before anyone reads it. Notice that nothing calls Flight at 10:11: the hold just stops being valid, and the next call that comes in notices.

A seat's life

holdpaytime up / give upFreeanyone can take itHeldone passenger, 10 minBookedpaid2C10:01 Nadia holds 2C: pay by 10:1110:11: no payment, the seat comes back by itself10:12 Sabbir holds 2C, then paysbooked: nobody else can touch it
Press play. Nadia holds 2C at 10:01 but never pays. At 10:11 the seat goes back to free on its own. Sabbir holds it at 10:12 and pays, so it ends up booked.

How to read it: a seat moves free → held when someone holds it, and held → booked when they pay. The only way back is held → free: the time ran out, or the passenger gave up. A booked seat never goes back in this design (cancelling a booking is out of scope).

One seat, two buyers

Rahimhold 2ANadiahold 2AFlight.muone at a timeseat 2Afree → held (H1)RNrefusedboth press "hold 2A" in the same instantRahim gets the lock first: 2A is free, so it's held for himNadia gets the lock next: 2A is held, so she's refusedcheck and change happen under one lock: only one can win
Press play. Rahim and Nadia both press "hold 2A" at the same instant. Rahim gets the lock first, sees 2A free and holds it. Nadia gets the lock next, sees 2A held, and is refused.

How to read it: the lock makes "check every seat" and "mark them held" one step. Whoever takes the lock second sees the first one's change. Without the lock, both can read "free" before either writes "held", and both walk away with seat 2A.

Design principles

  • All or nothing: Hold checks every seat (exists, not picked twice, free) before it changes any. A refused hold leaves no trace.
  • Time is an input: Flight never calls time.Now() directly. It asks the Clock it was given, so tests and the demo decide what time it is.
  • Expire lazily: every public method first frees the holds whose time is up. No background goroutine, and no moment where a stale hold can be paid.
  • One lock for check-and-change: the seats and the holds change together, so they share one lock.
  • Open/closed: a new pricing rule is a new Fare; Flight doesn't change.

Design patterns

State (for a seat)

Three states and four transitions: free → held (hold), held → booked (pay), held → free (expiry or release). An enum plus checks in Flight is enough. Separate state classes would make sense only if each state had a lot of its own behaviour.

Strategy (for pricing)

  1. Why: fares change all the time: by class, by how full the flight is, by season, by promo code.
  2. What it solves: pricing is one swappable object, Fare.
  3. Without it: if eid { … } else if lastSeats { … } grows inside Hold.
  4. Trade-off: one interface; trivial cost.
  5. Where: fare.go; Hold calls f.fare.Price(class, left).

Decorator (for the surge)

Surge is itself a Fare and holds another Fare. It asks the base for a price and adds 25% when the class is almost full. You can stack them: a student discount wrapped around a surge wrapped around class fares, and Flight still sees one Fare.

Complete solution in Go

Project structure

flights/
├── go.mod
├── seat.go            Class, State, Seat
├── fare.go            the Fare strategy, ClassFare and the Surge decorator
├── flight.go          Flight: Hold, Pay, Release, expiry, the lock
├── flight_test.go     tests, including 100 buyers for one seat
└── cmd/demo/main.go   a morning before Eid

Step 1: seats and their states

// seat.go
package flights

// Class is a seat's fare class.
type Class int

const (
	Economy Class = iota
	Business
)

func (c Class) String() string { return [...]string{"economy", "business"}[c] }

// State is where a seat is in its life: free -> held -> booked, or held -> free again.
type State int

const (
	Free State = iota
	Held
	Booked
)

func (s State) String() string { return [...]string{"free", "held", "booked"}[s] }

// Seat is one seat on the flight. Hold names the hold that has it while it is Held.
type Seat struct {
	No    string
	Class Class
	State State
	Hold  string
}

Step 2: pricing

left is how many seats of that class were free before this hold. Passing it in keeps the fare rules pure: they never look at the flight.

// fare.go
package flights

// Fare prices one seat. left is how many seats of that class were free before this hold.
type Fare interface {
	Price(c Class, left int) int
}

// ClassFare is a fixed price per class.
type ClassFare map[Class]int

func (f ClassFare) Price(c Class, _ int) int { return f[c] }

// Surge wraps another Fare and adds Percent when only Below seats or fewer are left.
type Surge struct {
	Base    Fare
	Below   int
	Percent int
}

func (s Surge) Price(c Class, left int) int {
	p := s.Base.Price(c, left)
	if left <= s.Below {
		p += p * s.Percent / 100
	}
	return p
}

Step 3: the flight

Read Hold in two halves. The first loop only checks and adds up the price. The second loop runs only when everything passed, and changes the seats. Both halves are under the same lock.

// flight.go
package flights

import (
	"fmt"
	"slices"
	"sync"
	"time"
)

// Clock is passed in, so tests and the demo decide what time it is.
type Clock interface{ Now() time.Time }

// Hold keeps seats for one passenger until Expires. Nobody else can take them meanwhile.
type Hold struct {
	ID        string
	Passenger string
	Seats     []string
	Total     int
	Expires   time.Time
}

// Booking is a paid, confirmed reservation.
type Booking struct {
	PNR       string
	Passenger string
	Seats     []string
	Total     int
}

// Flight owns its seats and every hold on them. One lock guards all of it.
type Flight struct {
	mu       sync.Mutex
	Code     string
	order    []string // seat numbers in map order: 1A, 1B, 2A ...
	seats    map[string]*Seat
	holds    map[string]*Hold
	expired  map[string]time.Time // hold id -> when it ran out, for a clear error
	clock    Clock
	fare     Fare
	holdFor  time.Duration
	nextHold int
	nextPNR  int
}

func NewFlight(code string, clock Clock, fare Fare, holdFor time.Duration) *Flight {
	return &Flight{Code: code, seats: map[string]*Seat{}, holds: map[string]*Hold{},
		expired: map[string]time.Time{}, clock: clock, fare: fare, holdFor: holdFor}
}

// AddRow adds one row of seats, e.g. AddRow(2, Economy, "ABC") adds 2A 2B 2C.
func (f *Flight) AddRow(row int, class Class, letters string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for _, l := range letters {
		no := fmt.Sprintf("%d%c", row, l)
		f.seats[no] = &Seat{No: no, Class: class}
		f.order = append(f.order, no)
	}
}

// Hold keeps all the seats for one passenger, or none of them.
func (f *Flight) Hold(passenger string, seatNos ...string) (Hold, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.releaseExpired()
	if len(seatNos) == 0 {
		return Hold{}, fmt.Errorf("pick at least one seat")
	}
	left := map[Class]int{} // free seats per class, before this hold
	for _, s := range f.seats {
		if s.State == Free {
			left[s.Class]++
		}
	}
	// First check every seat, without touching anything.
	total, seen := 0, map[string]bool{}
	for _, no := range seatNos {
		s, ok := f.seats[no]
		switch {
		case !ok:
			return Hold{}, fmt.Errorf("no seat %s", no)
		case seen[no]:
			return Hold{}, fmt.Errorf("seat %s picked twice", no)
		case s.State != Free:
			return Hold{}, fmt.Errorf("seat %s is %s", no, s.State)
		}
		seen[no] = true
		total += f.fare.Price(s.Class, left[s.Class])
	}
	// All free: now take them.
	f.nextHold++
	h := &Hold{ID: fmt.Sprintf("H%d", f.nextHold), Passenger: passenger,
		Seats: slices.Clone(seatNos), Total: total, Expires: f.clock.Now().Add(f.holdFor)}
	for _, no := range seatNos {
		f.seats[no].State, f.seats[no].Hold = Held, h.ID
	}
	f.holds[h.ID] = h
	return *h, nil
}

// Pay turns a live hold into a booking. A hold that ran out is refused.
func (f *Flight) Pay(holdID string) (Booking, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.releaseExpired()
	h, ok := f.holds[holdID]
	if !ok {
		if at, gone := f.expired[holdID]; gone {
			return Booking{}, fmt.Errorf("hold %s expired at %s", holdID, at.Format("15:04"))
		}
		return Booking{}, fmt.Errorf("no hold %s", holdID)
	}
	for _, no := range h.Seats {
		f.seats[no].State, f.seats[no].Hold = Booked, ""
	}
	delete(f.holds, holdID)
	f.nextPNR++
	return Booking{PNR: fmt.Sprintf("PNR-%d", f.nextPNR), Passenger: h.Passenger,
		Seats: slices.Clone(h.Seats), Total: h.Total}, nil
}

// Release gives the seats back at once: the passenger closed the app or changed their mind.
func (f *Flight) Release(holdID string) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	h, ok := f.holds[holdID]
	if !ok {
		return fmt.Errorf("no hold %s", holdID)
	}
	f.free(h)
	delete(f.holds, holdID)
	return nil
}

// Seats returns a copy of every seat, in map order.
func (f *Flight) Seats() []Seat {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.releaseExpired()
	out := make([]Seat, 0, len(f.order))
	for _, no := range f.order {
		out = append(out, *f.seats[no])
	}
	return out
}

// releaseExpired frees the seats of every hold whose time is up. Callers hold the lock.
func (f *Flight) releaseExpired() {
	now := f.clock.Now()
	for id, h := range f.holds {
		if !now.Before(h.Expires) {
			f.free(h)
			delete(f.holds, id)
			f.expired[id] = h.Expires
		}
	}
}

func (f *Flight) free(h *Hold) {
	for _, no := range h.Seats {
		f.seats[no].State, f.seats[no].Hold = Free, ""
	}
}

Step 4: run it

// cmd/demo/main.go
package main

import (
	"fmt"
	"strings"
	"time"

	"flights"
)

// fakeClock lets the story jump from minute to minute.
type fakeClock struct{ t time.Time }

func (c *fakeClock) Now() time.Time  { return c.t }
func (c *fakeClock) set(hhmm string) { c.t, _ = time.Parse("15:04", hhmm) }

func main() {
	clock := &fakeClock{}
	fare := flights.Surge{
		Base:  flights.ClassFare{flights.Economy: 5000, flights.Business: 12000},
		Below: 1, Percent: 25,
	}
	f := flights.NewFlight("CF-141", clock, fare, 10*time.Minute)
	f.AddRow(1, flights.Business, "AB")
	f.AddRow(2, flights.Economy, "ABC")
	f.AddRow(3, flights.Economy, "ABC")
	fmt.Println("flight CF-141 Dhaka -> Cox's Bazar: 2 business, 6 economy, holds last 10 min")

	hold := func(at, who string, seats ...string) {
		clock.set(at)
		label := fmt.Sprintf("%s %-6s holds %s", at, who, strings.Join(seats, " "))
		h, err := f.Hold(who, seats...)
		if err != nil {
			fmt.Printf("%-28s -> refused: %v\n", label, err)
			return
		}
		fmt.Printf("%-28s -> %s, %d tk, pay by %s\n", label, h.ID, h.Total, h.Expires.Format("15:04"))
	}
	pay := func(at, who, id string) {
		clock.set(at)
		label := fmt.Sprintf("%s %-6s pays %s", at, who, id)
		b, err := f.Pay(id)
		if err != nil {
			fmt.Printf("%-28s -> refused: %v\n", label, err)
			return
		}
		fmt.Printf("%-28s -> booked %s: %s\n", label, b.PNR, strings.Join(b.Seats, " "))
	}

	hold("10:00", "Rahim", "2A", "2B")
	hold("10:01", "Nadia", "2B", "2C")
	hold("10:01", "Nadia", "2C", "3A")
	pay("10:03", "Rahim", "H1")
	hold("10:05", "Tania", "1A")
	pay("10:06", "Tania", "H3")
	pay("10:12", "Nadia", "H2")
	hold("10:12", "Sabbir", "2C", "3A", "3B")
	hold("10:13", "Karim", "3C")

	clock.set("10:14")
	f.Release("H5")
	fmt.Printf("%-28s -> 3C is free again\n", "10:14 Karim  gives up H5")

	by := map[flights.State][]string{}
	for _, s := range f.Seats() {
		by[s.State] = append(by[s.State], s.No)
	}
	fmt.Printf("10:14 booked: %s | held: %s | free: %s\n",
		strings.Join(by[flights.Booked], " "), strings.Join(by[flights.Held], " "), strings.Join(by[flights.Free], " "))
}
$ go run ./cmd/demo
flight CF-141 Dhaka -> Cox's Bazar: 2 business, 6 economy, holds last 10 min
10:00 Rahim  holds 2A 2B     -> H1, 10000 tk, pay by 10:10
10:01 Nadia  holds 2B 2C     -> refused: seat 2B is held
10:01 Nadia  holds 2C 3A     -> H2, 10000 tk, pay by 10:11
10:03 Rahim  pays H1         -> booked PNR-1: 2A 2B
10:05 Tania  holds 1A        -> H3, 12000 tk, pay by 10:15
10:06 Tania  pays H3         -> booked PNR-2: 1A
10:12 Nadia  pays H2         -> refused: hold H2 expired at 10:11
10:12 Sabbir holds 2C 3A 3B  -> H4, 15000 tk, pay by 10:22
10:13 Karim  holds 3C        -> H5, 6250 tk, pay by 10:23
10:14 Karim  gives up H5     -> 3C is free again
10:14 booked: 1A 2A 2B | held: 2C 3A 3B | free: 1B 3C

Reading the output:

  • 10:00: Rahim holds 2A and 2B: two economy seats at 5000 each, deadline 10:10.
  • 10:01: Nadia asks for 2B and 2C. 2B is Rahim's, so the whole hold is refused, and 2C stays free (that's the all-or-nothing rule). She then holds 2C and 3A.
  • 10:03: Rahim pays in time and gets PNR-1. Hold ids and PNRs are separate counters, which is why Tania's hold is H3 but her booking is PNR-2.
  • 10:12: Nadia tries to pay, but her deadline was 10:11. Her seats were freed the moment someone touched the flight after 10:11, so the payment is refused.
  • 10:12: Sabbir takes 2C, 3A and 3B: the seats Nadia lost, plus one more. 4 economy seats were free, so no surge: 15000.
  • 10:13: Karim holds 3C, the last economy seat: 5000 + 25% = 6250.
  • 10:14: Karim gives up, and 3C is free again at once. The final line is the seat map.

The other five languages

from __future__ import annotations

import threading
from dataclasses import dataclass, field
from enum import Enum
from typing import Protocol

# Time is minutes since midnight, to keep the demo short. 10:05 is 605.


def hhmm(m: int) -> str:
    return f"{m // 60:02d}:{m % 60:02d}"


class Class(Enum):
    ECONOMY = "economy"
    BUSINESS = "business"


class State(Enum):
    """free -> held -> booked, or held -> free again."""
    FREE = "free"
    HELD = "held"
    BOOKED = "booked"


@dataclass
class Seat:
    no: str
    cls: Class
    state: State = State.FREE
    hold: str = ""


class Clock(Protocol):
    def now(self) -> int: ...


class Fare(Protocol):
    """Prices one seat. left = free seats of that class before this hold."""
    def price(self, cls: Class, left: int) -> int: ...


class ClassFare:
    def __init__(self, prices: dict[Class, int]):
        self.prices = prices

    def price(self, cls: Class, left: int) -> int:
        return self.prices[cls]


class Surge:
    """Wraps another Fare and adds percent when only `below` seats or fewer are left."""
    def __init__(self, base: Fare, below: int, percent: int):
        self.base, self.below, self.percent = base, below, percent

    def price(self, cls: Class, left: int) -> int:
        p = self.base.price(cls, left)
        return p + p * self.percent // 100 if left <= self.below else p


@dataclass
class Hold:
    id: str
    passenger: str
    seats: list[str]
    total: int
    expires: int


@dataclass
class Booking:
    pnr: str
    passenger: str
    seats: list[str]
    total: int


class Refused(Exception):
    pass


@dataclass
class Flight:
    code: str
    clock: Clock
    fare: Fare
    hold_for: int
    seats: dict[str, Seat] = field(default_factory=dict)  # insertion order = map order
    holds: dict[str, Hold] = field(default_factory=dict)
    expired: dict[str, int] = field(default_factory=dict)
    next_hold: int = 0
    next_pnr: int = 0
    lock: threading.Lock = field(default_factory=threading.Lock)

    def add_row(self, row: int, cls: Class, letters: str) -> None:
        with self.lock:
            for l in letters:
                self.seats[f"{row}{l}"] = Seat(f"{row}{l}", cls)

    def hold(self, passenger: str, *nos: str) -> Hold:
        """Keeps all the seats for one passenger, or none of them."""
        with self.lock:
            self._release_expired()
            if not nos:
                raise Refused("pick at least one seat")
            left: dict[Class, int] = {}
            for s in self.seats.values():
                if s.state is State.FREE:
                    left[s.cls] = left.get(s.cls, 0) + 1
            total, seen = 0, set()
            for no in nos:  # check every seat first, touch nothing
                s = self.seats.get(no)
                if s is None:
                    raise Refused(f"no seat {no}")
                if no in seen:
                    raise Refused(f"seat {no} picked twice")
                if s.state is not State.FREE:
                    raise Refused(f"seat {no} is {s.state.value}")
                seen.add(no)
                total += self.fare.price(s.cls, left.get(s.cls, 0))
            self.next_hold += 1
            h = Hold(f"H{self.next_hold}", passenger, list(nos), total, self.clock.now() + self.hold_for)
            for no in nos:
                self.seats[no].state, self.seats[no].hold = State.HELD, h.id
            self.holds[h.id] = h
            return h

    def pay(self, hold_id: str) -> Booking:
        with self.lock:
            self._release_expired()
            h = self.holds.pop(hold_id, None)
            if h is None:
                if hold_id in self.expired:
                    raise Refused(f"hold {hold_id} expired at {hhmm(self.expired[hold_id])}")
                raise Refused(f"no hold {hold_id}")
            for no in h.seats:
                self.seats[no].state, self.seats[no].hold = State.BOOKED, ""
            self.next_pnr += 1
            return Booking(f"PNR-{self.next_pnr}", h.passenger, list(h.seats), h.total)

    def release(self, hold_id: str) -> None:
        with self.lock:
            h = self.holds.pop(hold_id, None)
            if h is None:
                raise Refused(f"no hold {hold_id}")
            self._free(h)

    def all_seats(self) -> list[Seat]:
        with self.lock:
            self._release_expired()
            return [Seat(s.no, s.cls, s.state, s.hold) for s in self.seats.values()]

    def _release_expired(self) -> None:
        now = self.clock.now()
        for hid in [i for i, h in self.holds.items() if now >= h.expires]:
            h = self.holds.pop(hid)
            self._free(h)
            self.expired[hid] = h.expires

    def _free(self, h: Hold) -> None:
        for no in h.seats:
            self.seats[no].state, self.seats[no].hold = State.FREE, ""


class FakeClock:
    def __init__(self):
        self.t = 0

    def now(self) -> int:
        return self.t

    def set(self, s: str) -> None:
        h, m = s.split(":")
        self.t = int(h) * 60 + int(m)


def main() -> None:
    clock = FakeClock()
    fare = Surge(ClassFare({Class.ECONOMY: 5000, Class.BUSINESS: 12000}), below=1, percent=25)
    f = Flight("CF-141", clock, fare, hold_for=10)
    f.add_row(1, Class.BUSINESS, "AB")
    f.add_row(2, Class.ECONOMY, "ABC")
    f.add_row(3, Class.ECONOMY, "ABC")
    print("flight CF-141 Dhaka -> Cox's Bazar: 2 business, 6 economy, holds last 10 min")

    def hold(at: str, who: str, *seats: str) -> None:
        clock.set(at)
        label = f"{at} {who:<6} holds {' '.join(seats)}"
        try:
            h = f.hold(who, *seats)
            print(f"{label:<28} -> {h.id}, {h.total} tk, pay by {hhmm(h.expires)}")
        except Refused as e:
            print(f"{label:<28} -> refused: {e}")

    def pay(at: str, who: str, hid: str) -> None:
        clock.set(at)
        label = f"{at} {who:<6} pays {hid}"
        try:
            b = f.pay(hid)
            print(f"{label:<28} -> booked {b.pnr}: {' '.join(b.seats)}")
        except Refused as e:
            print(f"{label:<28} -> refused: {e}")

    hold("10:00", "Rahim", "2A", "2B")
    hold("10:01", "Nadia", "2B", "2C")
    hold("10:01", "Nadia", "2C", "3A")
    pay("10:03", "Rahim", "H1")
    hold("10:05", "Tania", "1A")
    pay("10:06", "Tania", "H3")
    pay("10:12", "Nadia", "H2")
    hold("10:12", "Sabbir", "2C", "3A", "3B")
    hold("10:13", "Karim", "3C")

    clock.set("10:14")
    f.release("H5")
    print(f"{'10:14 Karim  gives up H5':<28} -> 3C is free again")

    by: dict[State, list[str]] = {s: [] for s in State}
    for s in f.all_seats():
        by[s.state].append(s.no)
    print(f"10:14 booked: {' '.join(by[State.BOOKED])} | held: {' '.join(by[State.HELD])} | free: {' '.join(by[State.FREE])}")


if __name__ == "__main__":
    main()

What changes from language to language: Go uses time.Time; the other five count minutes since midnight to keep the demo short (real code would use their date-time types). Rust keeps every changing field inside the Mutex (the Inner struct), so the compiler won't let you touch seats without the lock. Java uses synchronized methods and a LinkedHashMap to keep the seat order. C++ uses std::lock_guard and an order_ vector because unordered_map has no order. TypeScript runs on one thread and a method can't be interrupted halfway, so it needs no lock.

Testing

// flight_test.go
package flights

import (
	"fmt"
	"strings"
	"sync"
	"testing"
	"time"
)

type clock struct{ t time.Time }

func (c *clock) Now() time.Time { return c.t }

func newFlight(c *clock) *Flight {
	f := NewFlight("T-1", c, Surge{ClassFare{Economy: 5000, Business: 12000}, 1, 25}, 10*time.Minute)
	f.AddRow(1, Business, "AB")
	f.AddRow(2, Economy, "ABC")
	f.AddRow(3, Economy, "ABC")
	return f
}

func state(f *Flight, no string) State {
	for _, s := range f.Seats() {
		if s.No == no {
			return s.State
		}
	}
	panic("no seat " + no)
}

func TestHoldIsAllOrNothing(t *testing.T) {
	f := newFlight(&clock{})
	if _, err := f.Hold("Rahim", "2B"); err != nil {
		t.Fatal(err)
	}
	if _, err := f.Hold("Nadia", "2A", "2B"); err == nil {
		t.Fatal("2B is held, the whole hold should be refused")
	}
	if state(f, "2A") != Free {
		t.Fatal("a refused hold must not keep 2A")
	}
}

func TestExpiredHoldIsRefusedAndSeatsComeBack(t *testing.T) {
	c := &clock{}
	f := newFlight(c)
	h, _ := f.Hold("Nadia", "2C")
	c.t = c.t.Add(10 * time.Minute) // exactly at the deadline: too late
	if _, err := f.Pay(h.ID); err == nil || !strings.Contains(err.Error(), "expired") {
		t.Fatalf("want expired, got %v", err)
	}
	if _, err := f.Hold("Sabbir", "2C"); err != nil {
		t.Fatalf("2C should be free again: %v", err)
	}
}

func TestPayBooksSeatsOnce(t *testing.T) {
	f := newFlight(&clock{})
	h, _ := f.Hold("Rahim", "2A", "2B")
	b, err := f.Pay(h.ID)
	if err != nil || b.Total != 10000 || state(f, "2A") != Booked {
		t.Fatalf("booking %+v, err %v", b, err)
	}
	if _, err := f.Pay(h.ID); err == nil {
		t.Fatal("paying the same hold twice must fail")
	}
	if _, err := f.Hold("Nadia", "2A"); err == nil {
		t.Fatal("a booked seat can't be held")
	}
}

func TestReleaseFreesSeatsAtOnce(t *testing.T) {
	f := newFlight(&clock{})
	h, _ := f.Hold("Karim", "3C")
	if err := f.Release(h.ID); err != nil || state(f, "3C") != Free {
		t.Fatalf("err %v, 3C is %v", err, state(f, "3C"))
	}
}

func TestLastSeatCostsMore(t *testing.T) {
	f := newFlight(&clock{})
	h1, _ := f.Hold("A", "1A") // 2 business seats free: normal price
	h2, _ := f.Hold("B", "1B") // only 1 left: +25%
	if h1.Total != 12000 || h2.Total != 15000 {
		t.Fatalf("got %d and %d", h1.Total, h2.Total)
	}
}

func TestBadSeatsRefused(t *testing.T) {
	f := newFlight(&clock{})
	for _, seats := range [][]string{{"9Z"}, {"2A", "2A"}, {}} {
		if _, err := f.Hold("X", seats...); err == nil {
			t.Fatalf("%v should be refused", seats)
		}
	}
}

// 100 people press "hold 2A" in the same instant. Exactly one may win. Run with -race.
func TestOneSeatManyBuyers(t *testing.T) {
	f := newFlight(&clock{})
	var wg sync.WaitGroup
	var mu sync.Mutex
	wins := 0
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if _, err := f.Hold(fmt.Sprint("p", i), "2A"); err == nil {
				mu.Lock()
				wins++
				mu.Unlock()
			}
		}()
	}
	wg.Wait()
	if wins != 1 {
		t.Fatalf("%d people got seat 2A", wins)
	}
}

// Overlapping pairs held and paid from many goroutines: no seat ends up in two bookings.
func TestNoSeatBookedTwice(t *testing.T) {
	f := newFlight(&clock{})
	all := []string{"1A", "1B", "2A", "2B", "2C", "3A", "3B", "3C"}
	var wg sync.WaitGroup
	var mu sync.Mutex
	owner := map[string]string{}
	for i := range 200 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			pair := []string{all[i%8], all[(i+1)%8]}
			h, err := f.Hold(fmt.Sprint("p", i), pair...)
			if err != nil {
				return
			}
			b, err := f.Pay(h.ID)
			if err != nil {
				return
			}
			mu.Lock()
			defer mu.Unlock()
			for _, no := range b.Seats {
				if prev, taken := owner[no]; taken {
					t.Errorf("seat %s booked by %s and %s", no, prev, b.PNR)
				}
				owner[no] = b.PNR
			}
		}()
	}
	wg.Wait()
}
$ go test -race -v ./...
=== RUN   TestHoldIsAllOrNothing
--- PASS: TestHoldIsAllOrNothing (0.00s)
=== RUN   TestExpiredHoldIsRefusedAndSeatsComeBack
--- PASS: TestExpiredHoldIsRefusedAndSeatsComeBack (0.00s)
=== RUN   TestPayBooksSeatsOnce
--- PASS: TestPayBooksSeatsOnce (0.00s)
=== RUN   TestReleaseFreesSeatsAtOnce
--- PASS: TestReleaseFreesSeatsAtOnce (0.00s)
=== RUN   TestLastSeatCostsMore
--- PASS: TestLastSeatCostsMore (0.00s)
=== RUN   TestBadSeatsRefused
--- PASS: TestBadSeatsRefused (0.00s)
=== RUN   TestOneSeatManyBuyers
--- PASS: TestOneSeatManyBuyers (0.00s)
=== RUN   TestNoSeatBookedTwice
--- PASS: TestNoSeatBookedTwice (0.00s)
PASS
ok  	flights	1.019s
?   	flights/cmd/demo	[no test files]
TestChecks
TestHoldIsAllOrNothingIf 2B is taken, a hold for 2A + 2B is refused and 2A stays free
TestExpiredHoldIsRefusedAndSeatsComeBackAt exactly 10 minutes the payment is refused and someone else can hold the seat
TestPayBooksSeatsOncePaying books the seats; paying the same hold again fails; a booked seat can't be held
TestReleaseFreesSeatsAtOnceGiving up frees the seat immediately
TestLastSeatCostsMoreThe second-to-last business seat is 12000, the last is 15000
TestBadSeatsRefusedUnknown seat, the same seat twice, and no seats are refused
TestOneSeatManyBuyers100 goroutines hold 2A at once: exactly one wins
TestNoSeatBookedTwice200 goroutines hold and pay overlapping pairs: no seat ends up in two bookings

We deleted the lock lines from flight.go and ran the tests again. The race detector reported data races on every run (15 to 23 in our runs), and TestNoSeatBookedTwice caught real double sales:

flight_test.go:146: seat 1A booked by PNR-2 and PNR-4
flight_test.go:146: seat 1B booked by PNR-1 and PNR-4

Design review

What's good: a seat's life is small and explicit; holds can't leak because expiry runs before every operation; time and pricing are injected, so tests are instant and exact.

"What if…"What to change
A real payment gatewayPaying takes seconds; don't call it under the lock. Mark the hold "paying" under the lock, call the gateway without it, then take the lock again to book (or to free the seats if the payment failed). Extend the deadline while a payment is in flight.
Many serversAn in-memory lock only protects one process. Put seats in a database and hold with one conditional update (UPDATE seats SET state='held' WHERE id IN (…) AND state='free', then check the row count), or use row locks inside a transaction.
Thousands of flightsOne Flight per flight already gives you one lock per flight, so different flights never wait for each other.
The expired map grows foreverKeep expired ids for a day, or store holds with a status in the database instead.
Cancel a bookingAdd booked → free with a refund rule; that's a new transition, and probably a Refund strategy.
Overbooking (airlines really do it)Allow holds up to capacity + N for economy; it becomes a business rule in Hold, not a change to the seat states.

Interview questions

Practice questions

Beginner

  1. What states can a seat be in, and what moves it between them?
  2. Why hold seats at all, instead of booking on click?

Intermediate

  1. How do you make sure a family's three seats are held together or not at all?
  2. How do you test a 10-minute timeout without waiting 10 minutes?

Advanced

  1. Two people press "hold 2A" in the same millisecond. Walk through what happens with and without the lock.
  2. The payment gateway takes 5 seconds. Where does the lock go?

Common design mistakes

  • Checking and marking in separate steps, so two buyers both see "free".
  • Holding seats one by one, leaving half a family's seats held when one fails.
  • Holds that never expire, so abandoned carts empty the flight.
  • Calling time.Now() everywhere, which makes expiry impossible to test.
  • Calling the payment gateway while holding the lock, so one slow payment freezes the whole flight.

Answers

1. A seat's states?

Free, held, booked. Hold moves free → held; pay moves held → booked; expiry or release moves held → free. Nothing leaves booked here.

2. Why hold first?

Paying takes minutes. Without a hold, someone else could buy the seat while you type your card number. The hold keeps it for you; the timeout makes sure it comes back if you leave.

3. Three seats together?

Check all three first (they exist, aren't repeated, are free) without changing anything. Only if all pass, mark all three held. Both steps run under one lock, so nobody can sneak in between.

4. Testing a 10-minute timeout?

Pass in a Clock. The test uses a fake one and moves it forward 10 minutes, then calls Pay. The flight sees the new time and frees the hold. The test takes microseconds.

5. Same millisecond, with and without the lock?

With the lock, one goroutine checks and marks 2A held; the other waits, then sees "held" and is refused. Without it, both read "free" before either writes, and both get the seat. In Go that's also a data race on the map, which can crash the program. Without the lock, our test caught seat 1A booked under two PNRs.

6. Slow payment gateway?

Never call it while holding the lock. Under the lock, mark the hold as paying; release the lock; call the gateway; take the lock again and book the seats or free them. The hold's state stops other buyers in the meantime.

What interviewers look for: clear seat states, holds with a deadline, all-or-nothing, check-and-change as one step, testable time, and knowing what changes with real payments and many servers.

Designed it yourself and understood the solution? Mark it to track your progress.