Design a Movie Ticket Booking System

Hardobject-oriented design · design patterns · concurrency · locking · state machine · java⏱ 16 min readAsked at Amazon, Microsoft, Google, Uber, FlipkartUpdated

Model cinemas, shows and per-show seats, then build all-or-nothing seat holds with expiry, safe concurrent booking and an idempotent payment flow.

Requirements

This is the "hard" cousin of the parking lot. The modelling is moderate; the real test is the booking flow: thousands of people open the same popular show, pick overlapping seats, and some of them abandon checkout or fail payment. The design must never sell a seat twice and must not lock seats forever.

Functional

  • Users browse movies by city and see the shows (movie + screen + start time) at each cinema.
  • Users see the seat map of a show with each seat's state: available, held or booked.
  • A user selects one or more seats; the system holds them for a few minutes (all or nothing) while the user pays.
  • On successful payment the booking is confirmed and the seats become booked. On failure, cancellation or hold timeout, the seats return to the pool.
  • Price depends on seat type (regular, premium, recliner) and on show time (weekend surcharge).
  • Users can cancel a confirmed booking and receive a refund.

Non-functional

  • No double booking, ever, under heavy concurrency.
  • Abandoned holds must free seats automatically (typical timeout: 5 to 10 minutes).
  • Payment retries (double clicks, network retries) must not charge twice.
  • Pricing and payment providers should be pluggable.

Out of scope (confirm first)

  • Search ranking, recommendations, food and beverages, coupons, user accounts and notifications.
Tip

Draw a clear line between catalog data (movies, cinemas, screens, seats), which rarely changes and can be cached, and inventory data (the state of each seat for one show), which is hot, contended and must be strongly consistent. Interviewers love hearing this distinction.

Core Entities

The key insight is that a Seat is physical (row C, seat 4, a recliner) and is reused by every show on that screen, while its availability belongs to one show. So we introduce ShowSeat: one per seat per show.

Entity Responsibility Key fields
Movie Catalog item id, title, durationMinutes
Cinema A venue in a city with several screens id, name, city, screens
Screen An auditorium with a fixed seat layout id, name, seats
Seat Physical seat (immutable) id, row, number, type
Show One screening; owns the inventory and its lock id, movie, cinema, screen, startTime, seats, lock
ShowSeat State of one seat for one show seat, state, bookingId, holdExpiresAt
Booking A user's order; moves through a state machine id, userId, show, seatIds, amountCents, status, paymentId
PricingStrategy Price for one seat at one show interface
PaymentGateway Charge and refund, idempotent by key interface
BookingService Facade: search, hold, pay, cancel shows, bookings, clock, hold TTL

SeatType, SeatState (AVAILABLE, HELD, BOOKED) and BookingStatus (PENDING, CONFIRMED, CANCELLED, EXPIRED, FAILED) are enums.

Class Diagram

classDiagram
  class Movie {
    +String id
    +String title
    +int durationMinutes
  }
  class Cinema {
    +String id
    +String city
    +List~Screen~ screens
  }
  class Screen {
    +String id
    +List~Seat~ seats
  }
  class Seat {
    +String id
    +SeatType type
  }
  class Show {
    -LocalDateTime startTime
    -Map seats
    -ReentrantLock lock
    +hold(List, String, Instant, Duration) boolean
    +confirm(List, String, Instant) boolean
    +release(List, String) void
    +availableSeatIds(Instant) List
  }
  class ShowSeat {
    -SeatState state
    -String bookingId
    -Instant holdExpiresAt
    +isFree(Instant) boolean
  }
  class Booking {
    -String id
    -long amountCents
    -BookingStatus status
    +moveTo(BookingStatus) void
  }
  class PricingStrategy {
    <<interface>>
    +priceCents(Show, Seat) long
  }
  class WeekendSurgePricing
  class PaymentGateway {
    <<interface>>
    +charge(String, long, String) PaymentResult
    +refund(String) void
  }
  class MockPaymentGateway
  class BookingService {
    +findShows(String, String) List
    +holdSeats(String, String, List) Booking
    +pay(String, String) Booking
    +cancel(String) Booking
  }

  Cinema "1" *-- "many" Screen
  Screen "1" *-- "many" Seat
  Show --> Movie
  Show --> Cinema
  Show --> Screen
  Show "1" *-- "many" ShowSeat
  ShowSeat --> Seat
  Booking --> Show
  BookingService "1" o-- "many" Show
  BookingService "1" o-- "many" Booking
  BookingService --> PricingStrategy
  BookingService --> PaymentGateway
  PricingStrategy <|.. WeekendSurgePricing
  PaymentGateway <|.. MockPaymentGateway

Design Patterns Used

Pattern Where Why it is justified
State machine BookingStatus.canMoveTo() Bookings have strict legal transitions (PENDING -> CONFIRMED, never EXPIRED -> CONFIRMED). Encoding them in one table makes illegal moves fail loudly
Strategy PricingStrategy Weekday, weekend, surge, matinee and member pricing vary independently of booking logic
Adapter / Strategy PaymentGateway Each real provider (cards, UPI, wallets) is wrapped behind one interface
Facade BookingService Clients call four verbs; the service coordinates show inventory, bookings and payments
Dependency injection of Clock BookingService Hold expiry is time-based; an injected clock makes it testable without sleep()
Insight

Expiry is lazy: a held seat whose holdExpiresAt has passed is simply treated as free by ShowSeat.isFree(now). No background thread has to run on time for correctness. A periodic sweeper is still useful to mark stale bookings EXPIRED and update counters, but it is an optimisation, not a correctness requirement.

Java Implementation

The code compiles as a single Main.java (Java 17+). A small MutableClock lets the demo jump forward in time to show hold expiry deterministically. Prices are in integer cents.

java
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;

enum SeatType { REGULAR, PREMIUM, RECLINER }

enum SeatState { AVAILABLE, HELD, BOOKED }

enum BookingStatus {
    PENDING, CONFIRMED, CANCELLED, EXPIRED, FAILED;

    boolean canMoveTo(BookingStatus next) {
        return switch (this) {
            case PENDING -> next == CONFIRMED || next == CANCELLED || next == EXPIRED || next == FAILED;
            case CONFIRMED -> next == CANCELLED;
            default -> false;   // terminal states
        };
    }
}

record Movie(String id, String title, int durationMinutes) {}

record Seat(String id, int row, int number, SeatType type) {}

record Screen(String id, String name, List<Seat> seats) {}

record Cinema(String id, String name, String city, List<Screen> screens) {}

class SeatUnavailableException extends RuntimeException {
    SeatUnavailableException(String msg) { super(msg); }
}

// Mutable per-show state; always accessed under the owning Show's lock.
class ShowSeat {
    final Seat seat;
    SeatState state = SeatState.AVAILABLE;
    String bookingId;
    Instant holdExpiresAt;

    ShowSeat(Seat seat) { this.seat = seat; }

    boolean isFree(Instant now) {
        return state == SeatState.AVAILABLE || (state == SeatState.HELD && !now.isBefore(holdExpiresAt));
    }

    boolean isHeldBy(String id, Instant now) {
        return state == SeatState.HELD && id.equals(bookingId) && now.isBefore(holdExpiresAt);
    }

    void reset() { state = SeatState.AVAILABLE; bookingId = null; holdExpiresAt = null; }
}

class Show {
    private final String id;
    private final Movie movie;
    private final Cinema cinema;
    private final Screen screen;
    private final LocalDateTime startTime;
    private final Map<String, ShowSeat> seats = new LinkedHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    Show(String id, Movie movie, Cinema cinema, Screen screen, LocalDateTime startTime) {
        this.id = id;
        this.movie = movie;
        this.cinema = cinema;
        this.screen = screen;
        this.startTime = startTime;
        screen.seats().forEach(s -> seats.put(s.id(), new ShowSeat(s)));
    }

    String getId() { return id; }
    Movie getMovie() { return movie; }
    Cinema getCinema() { return cinema; }
    LocalDateTime getStartTime() { return startTime; }

    Seat seat(String seatId) {
        ShowSeat ss = seats.get(seatId);
        if (ss == null) throw new IllegalArgumentException("No seat " + seatId);
        return ss.seat;
    }

    // All-or-nothing: either every requested seat becomes HELD for this booking, or none does.
    boolean hold(List<String> seatIds, String bookingId, Instant now, Duration ttl) {
        lock.lock();
        try {
            for (String sid : seatIds) {
                if (!seats.get(sid).isFree(now)) return false;
            }
            for (String sid : seatIds) {
                ShowSeat ss = seats.get(sid);
                ss.state = SeatState.HELD;
                ss.bookingId = bookingId;
                ss.holdExpiresAt = now.plus(ttl);
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

    boolean isHeldBy(List<String> seatIds, String bookingId, Instant now) {
        lock.lock();
        try {
            return seatIds.stream().allMatch(sid -> seats.get(sid).isHeldBy(bookingId, now));
        } finally {
            lock.unlock();
        }
    }

    // Converts a still-valid hold into a sale; fails if any seat's hold expired or was taken over.
    boolean confirm(List<String> seatIds, String bookingId, Instant now) {
        lock.lock();
        try {
            if (!seatIds.stream().allMatch(sid -> seats.get(sid).isHeldBy(bookingId, now))) return false;
            for (String sid : seatIds) {
                ShowSeat ss = seats.get(sid);
                ss.state = SeatState.BOOKED;
                ss.holdExpiresAt = null;
            }
            return true;
        } finally {
            lock.unlock();
        }
    }

    // Frees only the seats this booking still owns (a held seat may already belong to someone else).
    void release(List<String> seatIds, String bookingId) {
        lock.lock();
        try {
            for (String sid : seatIds) {
                ShowSeat ss = seats.get(sid);
                if (bookingId.equals(ss.bookingId)) ss.reset();
            }
        } finally {
            lock.unlock();
        }
    }

    List<String> availableSeatIds(Instant now) {
        lock.lock();
        try {
            return seats.values().stream().filter(ss -> ss.isFree(now)).map(ss -> ss.seat.id()).toList();
        } finally {
            lock.unlock();
        }
    }
}

class Booking {
    private final String id;
    private final String userId;
    private final Show show;
    private final List<String> seatIds;
    private final long amountCents;
    private BookingStatus status = BookingStatus.PENDING;
    private String paymentId;

    Booking(String id, String userId, Show show, List<String> seatIds, long amountCents) {
        this.id = id;
        this.userId = userId;
        this.show = show;
        this.seatIds = List.copyOf(seatIds);
        this.amountCents = amountCents;
    }

    String getId() { return id; }
    String getUserId() { return userId; }
    Show getShow() { return show; }
    List<String> getSeatIds() { return seatIds; }
    long getAmountCents() { return amountCents; }
    synchronized BookingStatus getStatus() { return status; }
    synchronized String getPaymentId() { return paymentId; }
    synchronized void setPaymentId(String p) { paymentId = p; }

    synchronized void moveTo(BookingStatus next) {
        if (!status.canMoveTo(next)) throw new IllegalStateException(id + ": " + status + " -> " + next + " not allowed");
        status = next;
    }

    @Override public synchronized String toString() {
        return id + "[" + userId + " " + seatIds + " $" + String.format("%.2f", amountCents / 100.0) + " " + status + "]";
    }
}

interface PricingStrategy {
    long priceCents(Show show, Seat seat);
}

class WeekendSurgePricing implements PricingStrategy {
    private final Map<SeatType, Long> base = new EnumMap<>(Map.of(
            SeatType.REGULAR, 1000L, SeatType.PREMIUM, 1500L, SeatType.RECLINER, 2500L));

    public long priceCents(Show show, Seat seat) {
        DayOfWeek day = show.getStartTime().getDayOfWeek();
        boolean weekend = day == DayOfWeek.FRIDAY || day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
        long price = base.get(seat.type());
        return weekend ? price * 120 / 100 : price;
    }
}

record PaymentResult(boolean success, String paymentId, String message) {}

interface PaymentGateway {
    PaymentResult charge(String idempotencyKey, long amountCents, String cardToken);
    void refund(String paymentId);
}

class MockPaymentGateway implements PaymentGateway {
    private final Map<String, PaymentResult> byKey = new ConcurrentHashMap<>();
    private final AtomicLong charges = new AtomicLong();
    private final AtomicLong sequence = new AtomicLong();

    // Same idempotency key -> same result, and the card is charged only once.
    public PaymentResult charge(String key, long amountCents, String cardToken) {
        return byKey.computeIfAbsent(key, k -> {
            if (cardToken.contains("declined")) return new PaymentResult(false, null, "card declined");
            charges.incrementAndGet();
            return new PaymentResult(true, "PAY" + sequence.incrementAndGet(), "ok");
        });
    }

    public void refund(String paymentId) { System.out.println("  refunded " + paymentId); }

    long chargeCount() { return charges.get(); }
}

class BookingService {
    private final Clock clock;
    private final Duration holdTtl;
    private final PricingStrategy pricing;
    private final PaymentGateway gateway;
    private final Map<String, Show> shows = new ConcurrentHashMap<>();
    private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();

    BookingService(Clock clock, Duration holdTtl, PricingStrategy pricing, PaymentGateway gateway) {
        this.clock = clock;
        this.holdTtl = holdTtl;
        this.pricing = pricing;
        this.gateway = gateway;
    }

    void addShow(Show show) { shows.put(show.getId(), show); }

    List<Show> findShows(String city, String title) {
        return shows.values().stream()
                .filter(s -> s.getCinema().city().equalsIgnoreCase(city) && s.getMovie().title().equalsIgnoreCase(title))
                .sorted(Comparator.comparing(Show::getStartTime)).toList();
    }

    List<String> availableSeats(String showId) { return show(showId).availableSeatIds(clock.instant()); }

    Booking holdSeats(String userId, String showId, List<String> requested) {
        Show show = show(showId);
        List<String> seatIds = requested.stream().distinct().sorted().toList();
        long amount = seatIds.stream().mapToLong(id -> pricing.priceCents(show, show.seat(id))).sum();
        String bookingId = "BK" + sequence.incrementAndGet();
        if (!show.hold(seatIds, bookingId, clock.instant(), holdTtl)) {
            throw new SeatUnavailableException("Seats " + seatIds + " are not all available");
        }
        Booking booking = new Booking(bookingId, userId, show, seatIds, amount);
        bookings.put(bookingId, booking);
        return booking;
    }

    Booking pay(String bookingId, String cardToken) {
        Booking b = booking(bookingId);
        synchronized (b) {   // serialises double clicks on the same booking
            if (b.getStatus() == BookingStatus.CONFIRMED) return b;   // idempotent retry
            if (b.getStatus() != BookingStatus.PENDING) throw new IllegalStateException("cannot pay a " + b.getStatus() + " booking");
            Show show = b.getShow();
            if (!show.isHeldBy(b.getSeatIds(), bookingId, clock.instant())) {
                show.release(b.getSeatIds(), bookingId);
                b.moveTo(BookingStatus.EXPIRED);
                throw new IllegalStateException(bookingId + " hold expired; please select seats again");
            }
            PaymentResult result = gateway.charge(bookingId, b.getAmountCents(), cardToken);
            if (!result.success()) {
                show.release(b.getSeatIds(), bookingId);
                b.moveTo(BookingStatus.FAILED);
                return b;
            }
            b.setPaymentId(result.paymentId());
            if (!show.confirm(b.getSeatIds(), bookingId, clock.instant())) {
                gateway.refund(result.paymentId());   // hold lapsed while the card was being charged
                show.release(b.getSeatIds(), bookingId);
                b.moveTo(BookingStatus.EXPIRED);
                throw new IllegalStateException(bookingId + " hold expired during payment; refunded");
            }
            b.moveTo(BookingStatus.CONFIRMED);
            return b;
        }
    }

    Booking cancel(String bookingId) {
        Booking b = booking(bookingId);
        synchronized (b) {
            if (b.getStatus() == BookingStatus.CONFIRMED) gateway.refund(b.getPaymentId());
            b.moveTo(BookingStatus.CANCELLED);
            b.getShow().release(b.getSeatIds(), bookingId);
            return b;
        }
    }

    private Show show(String id) {
        Show s = shows.get(id);
        if (s == null) throw new IllegalArgumentException("Unknown show " + id);
        return s;
    }

    private Booking booking(String id) {
        Booking b = bookings.get(id);
        if (b == null) throw new IllegalArgumentException("Unknown booking " + id);
        return b;
    }
}

class MutableClock extends Clock {
    private volatile Instant now;
    private final ZoneId zone;

    MutableClock(Instant start, ZoneId zone) { this.now = start; this.zone = zone; }

    void advance(Duration d) { now = now.plus(d); }
    public ZoneId getZone() { return zone; }
    public Clock withZone(ZoneId z) { return new MutableClock(now, z); }
    public Instant instant() { return now; }
}

public class Main {
    public static void main(String[] args) throws Exception {
        List<Seat> layout = new ArrayList<>();
        for (int n = 1; n <= 4; n++) layout.add(new Seat("A" + n, 1, n, SeatType.REGULAR));
        for (int n = 1; n <= 4; n++) layout.add(new Seat("B" + n, 2, n, SeatType.PREMIUM));
        for (int n = 1; n <= 2; n++) layout.add(new Seat("C" + n, 3, n, SeatType.RECLINER));
        Screen screen1 = new Screen("S1", "Audi 1", layout);
        Cinema cinema = new Cinema("CIN1", "Cineplex Downtown", "Seattle", List.of(screen1));
        Movie movie = new Movie("M1", "Inception", 148);

        MutableClock clock = new MutableClock(Instant.parse("2026-11-06T10:00:00Z"), ZoneOffset.UTC);
        MockPaymentGateway gateway = new MockPaymentGateway();
        BookingService service = new BookingService(clock, Duration.ofMinutes(5), new WeekendSurgePricing(), gateway);
        service.addShow(new Show("SH1", movie, cinema, screen1, LocalDateTime.of(2026, 11, 6, 19, 0)));   // a Friday

        Show show = service.findShows("seattle", "inception").get(0);
        System.out.println("Found " + show.getId() + " on " + show.getStartTime().getDayOfWeek() + " " + show.getStartTime()
                + ", free seats: " + service.availableSeats("SH1"));

        // 8 users race for the same two seats; exactly one must win.
        ExecutorService pool = Executors.newFixedThreadPool(8);
        CountDownLatch start = new CountDownLatch(1);
        Queue<Booking> winners = new ConcurrentLinkedQueue<>();
        List<Future<?>> futures = new ArrayList<>();
        for (int i = 1; i <= 8; i++) {
            String user = "racer" + i;
            futures.add(pool.submit(() -> {
                start.await();
                try { winners.add(service.holdSeats(user, "SH1", List.of("A1", "A2"))); }
                catch (SeatUnavailableException ignored) { }
                return null;
            }));
        }
        start.countDown();
        for (Future<?> f : futures) f.get();
        pool.shutdown();
        Booking win = winners.peek();
        System.out.println("Racers holding A1+A2: " + winners.size() + ", price $" + String.format("%.2f", win.getAmountCents() / 100.0));

        service.pay(win.getId(), "tok_visa");
        service.pay(win.getId(), "tok_visa");   // double click
        System.out.println("After paying twice: " + win.getStatus() + ", cards charged: " + gateway.chargeCount());

        attempt("u9 holds A2+A3", () -> service.holdSeats("u9", "SH1", List.of("A3", "A2")));
        System.out.println("A3 still free? " + service.availableSeats("SH1").contains("A3"));

        Booking slow = service.holdSeats("u10", "SH1", List.of("C1"));
        System.out.println("u10 holds C1; 6 minutes pass...");
        clock.advance(Duration.ofMinutes(6));
        Booking fast = service.holdSeats("u11", "SH1", List.of("C1"));
        System.out.println("u11 took over the expired hold on C1");
        attempt("u10 pays late", () -> service.pay(slow.getId(), "tok_visa"));

        Booking declined = service.pay(fast.getId(), "tok_declined");
        System.out.println("u11 payment: " + declined.getStatus() + ", C1 free again? " + service.availableSeats("SH1").contains("C1"));

        System.out.println("Cancel the racer's booking:");
        service.cancel(win.getId());
        System.out.println("  status " + win.getStatus() + ", free seats: " + service.availableSeats("SH1"));
        attempt("Pay cancelled booking", () -> service.pay(win.getId(), "tok_visa"));
    }

    static void attempt(String label, Runnable action) {
        try {
            action.run();
            System.out.println(label + ": ok");
        } catch (RuntimeException e) {
            System.out.println(label + ": rejected - " + e.getMessage());
        }
    }
}

Demo output (the winning racer's name varies between runs, so the demo prints only counts)

text
Found SH1 on FRIDAY 2026-11-06T19:00, free seats: [A1, A2, A3, A4, B1, B2, B3, B4, C1, C2]
Racers holding A1+A2: 1, price $24.00
After paying twice: CONFIRMED, cards charged: 1
u9 holds A2+A3: rejected - Seats [A2, A3] are not all available
A3 still free? true
u10 holds C1; 6 minutes pass...
u11 took over the expired hold on C1
u10 pays late: rejected - BK10 hold expired; please select seats again
u11 payment: FAILED, C1 free again? true
Cancel the racer's booking:
  refunded PAY1
  status CANCELLED, free seats: [A1, A2, A3, A4, B1, B2, B3, B4, C1, C2]
Pay cancelled booking: rejected - cannot pay a CANCELLED booking

Walkthrough of the key decisions:

  • Eight threads request A1 and A2 at the same instant; the show lock lets exactly one hold succeed.
  • The show is on a Friday, so two regular seats cost 2 × $12.00 (base $10 plus 20%).
  • Paying twice charges the card once: the booking returns early when already CONFIRMED, and the gateway deduplicates on the booking ID as idempotency key.
  • The request for A2 and A3 fails as a whole, and A3 is not left half-held.
  • u10's hold lapses after 5 minutes, so u11 can take C1. u10's late payment is rejected before charging the card.
  • A declined card moves the booking to FAILED and returns C1 to the pool. Cancelling a confirmed booking refunds and frees its seats; a cancelled booking cannot be paid.

Concurrency Considerations

Where is the race? Two users read "A1 free" and both write "A1 held". The fix is to make check-and-set atomic for the whole set of requested seats.

  • Lock granularity. This design uses one ReentrantLock per Show. The critical section is tiny (a few map lookups), and contention is naturally partitioned: different shows never block each other. Per-seat locks give more parallelism but need ordered acquisition (sort seat IDs) to avoid deadlock when two users request overlapping sets in different orders. That is why holdSeats sorts the seat IDs anyway.
  • All-or-nothing. hold first verifies every seat and only then mutates, all under the lock, so a partial hold is impossible.
  • Ownership checks. release frees only seats whose bookingId still matches. Without that check, an expired booking being cleaned up could free a seat that another user has since held, which is a subtle but classic bug.
  • Payment never runs under the show lock. Charging a card can take seconds; holding a lock that long would block every user of the show. Instead: verify the hold, charge outside the lock, then confirm re-checks the hold atomically. If the hold expired mid-payment, we refund (compensating action).
  • Double clicks are serialized per booking (synchronized (b)) and the gateway is idempotent by key.
Warning

Make the hold TTL comfortably longer than the payment provider's timeout (for example, hold 10 minutes, payment session 8 minutes). Otherwise the refund path, which is correct but annoying for users, becomes common.

Distributed version. With many app servers, the in-memory lock moves into storage:

sql
UPDATE show_seat
SET status = 'HELD', booking_id = :booking, hold_expires_at = now() + interval '10 minutes'
WHERE show_id = :show AND seat_id IN (:seats)
  AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < now()));
-- commit only if updated row count = number of seats, else roll back

This is optimistic, row-level and all-or-nothing inside one transaction. Alternatives are SELECT ... FOR UPDATE (pessimistic, simple, more blocking) or a Redis SET seat_key booking NX PX 600000 per seat for very hot shows, with the database remaining the source of truth at confirmation.

Extensions

Requirement Change
Live seat map updates Observer: Show publishes seat-state changes; a WebSocket gateway pushes them to viewers
Mark stale bookings EXPIRED ScheduledExecutorService sweeper (or delayed queue) per show; correctness already holds via lazy expiry
Coupons, member discounts Decorator around PricingStrategy (e.g. DiscountPricing(base, coupon))
Very popular releases Virtual waiting room that admits users in batches before they can hold seats
Cancellation policy (no refund within 2 hours) CancellationPolicy strategy consulted by cancel
Multiple payment methods More PaymentGateway adapters chosen by a factory from the user's selection
Seat-gap rules (no single empty seat) Validation step in holdSeats using the row layout

Interview Tips

  • Separate Seat (physical) from ShowSeat (per-show state) early; it unlocks the rest of the design.
  • Explicitly walk through the hold, pay, confirm flow and every failure branch: decline, timeout, double click, expiry during payment.
  • Explain why you never hold a lock across a network call to the payment provider.
  • Mention lazy expiry plus a sweeper, and why the sweeper is not needed for correctness.
  • Be ready to translate the in-memory lock into a conditional SQL update or SELECT ... FOR UPDATE.
  • Keep money in integer cents and inject the clock; both show production instincts.

References

My notes saved in this browser