androidinterview.com

Low Level Design (LLD) Interview Questions

Design a Movie Ticket Booking System

Tier: EssentialDifficulty: MediumAsked of: Mid, SeniorAsked at: Flipkart, Microsoft, Google, Amazon, Meta

Seat locking is the whole problem. The class model is twenty minutes of easy work, and then the interviewer asks what happens when two people tap the same seat at the same moment, and that is where the rest of the hour goes.

Say that up front. It tells the interviewer you know where the difficulty is, and it earns you the right to move through the entities quickly.

What this really tests

Whether you can model a seat at a show as having three states rather than two, whether you handle the lock expiring while a payment is in flight, and whether you notice that a partial booking is worse than no booking when four people want to sit together.

What to clarify first

  • How long is a seat held while the user pays. Ten minutes is the usual answer, and the number matters because it has to comfortably outlast a card authorisation.
  • Can somebody book seats that are not together. Usually yes, which means the lock covers a set of seats rather than a block.
  • Are seat tiers priced differently. Silver, gold, platinum. If yes, pricing is per seat and not per booking.
  • Is search in scope, by city, movie, cinema and date, or does the user arrive at a show already chosen.
  • Can a booking be cancelled, and up to when. This decides whether seats ever go back on sale.
  • Are there other seat rules, like not leaving a single empty seat between two bookings. Real cinemas do this and it is a good follow up to have thought about.
  • Do we need to show live availability to people already on the seat map. That is where an observer earns its place.

Out of scope, said plainly. Payment card handling, seat map rendering, dynamic pricing pipelines, and food ordering at the counter.

The classes

The catalogue.

  • Movie is a title, a language and a runtime. It holds no showtimes, because a movie plays at many cinemas and the join belongs elsewhere.
  • Cinema owns its screens. Screen owns its seats and can find one by id, so pricing a basket does not walk the whole auditorium. Cinema itself is only there for the catalogue side that search would use, and nothing in the booking path touches it.
  • Seat is a physical seat in a screen. It has a row, a number and a tier, and it deliberately carries no availability at all. The same seat is free for the six o'clock show and taken for the nine, so availability belongs to the pair of a show and a seat, never to the seat on its own. Getting this wrong is the most common mistake in this problem.
  • SeatTier is an enum carrying its own price multiplier, so nothing else in the system writes a switch over seat classes.
  • Show is a movie on a screen at a time, with a base price. This is what tickets are actually sold against, and its id is half of every inventory key in the design.

The inventory, which is the real subject.

  • SeatLock is a temporary claim on a set of named seats. It records who owns it and the exact instant it dies.
  • SeatLockManager owns every seat state in the system. Free, locked, and sold. It owns the only lock in the design, because a lock split across two classes is how you get two half correct ones.
  • Booking is a confirmed set of seats. Notice there is no pending status on it. Pending is what the seat lock is, and it lives where it can expire.

The rules.

  • PricingStrategy turns a show and a seat into an amount. Priced per seat, because a single booking can mix tiers.

The front door.

  • BookingService sequences selection, payment and confirmation, and owns none of the rules.
  • ConfirmResult is five outcomes rather than a boolean. Confirmed, already confirmed, payment declined, the show has started, and refund needed. That last one is the case everybody forgets, and making it a type is how you stop forgetting it.
Movie ticket booking class diagramClasses PricingStrategy, BookingService, SeatLockManager, ConfirmResult, Booking, SeatKey, SeatLock, Show, Screen, Seat, SeatTier. BookingService prices with PricingStrategy. BookingService uses SeatLockManager. BookingService returns ConfirmResult. BookingService is composed of 0..* Booking. SeatLockManager is composed of 0..* SeatKey. SeatLockManager is composed of 0..* SeatLock. SeatKey show Show. SeatKey seat Seat. Show aggregates Screen. Screen is composed of 1..* Seat. Seat tier SeatTier.
Movie ticket booking class diagram, a UML class diagram of PricingStrategy, BookingService, SeatLockManager, ConfirmResult, Booking, SeatKey, SeatLock, Show, Screen, Seat, SeatTier
The accented arrow is the one to look at, because availability hangs off the pair of a show and a seat rather than off the seat itself, and a seat that carries its own free flag is the most common way this design goes wrong.

Counters against names, and why it matters

It is worth comparing this to hotel inventory out loud, because the interviewer often asks both.

A hotel counts. Two double rooms are interchangeable, so availability is a number per night and a booking decrements it. A cinema names. Seat 12F is not seat 12G, and somebody who wanted the aisle will notice.

That single difference is why the seat lock manager is a map keyed by a show and a seat, rather than a set of counters. Everything else about the two designs, the three states, the expiring hold, the owner checked release, is identical. Saying that shows you understand the shape rather than having memorised one problem.

How a booking actually happens

Four friends open the seat map for the nine o'clock show.

The service asks the lock manager for available seats. The lock manager sweeps out any expired locks first, so the map they see is as fresh as possible, then returns every seat that is neither sold nor currently locked by somebody else.

They pick four seats and hit continue. The service asks the lock manager to lock all four. The lock manager sorts the seat ids, checks every one of them, and only then writes anything. All four or none. Handing three friends a row of three and telling the fourth to sit elsewhere is worse than telling all four to pick again.

It creates a SeatLock with a ten minute expiry, points all four seat keys at it, and hands it back. Nothing has been charged and nothing has been sold. The clock is now running and everybody else sees those four seats as taken.

They pay. The card clears. Now the service asks the lock manager to confirm. Confirm checks the owner, removes the lock, checks the expiry itself, and only then marks the four seats sold. If it succeeds, a Booking is created and the tickets are issued.

If the ten minutes ran out while the bank was thinking, confirm returns false. The money is already gone and the seats may already belong to somebody else. That is the refund needed case, and it is a real outcome that the design has to have a name for.

One seat at one show moves through three states, and the arrow back out of the middle one is the whole interview.

One seat at one showStates Free, Locked, Sold, starting at Free. seats selected, ttl starts moves Free to Locked. ttl expires while the card is in flight moves Locked to Free. confirmed inside the ttl moves Locked to Sold. booking cancelled moves Sold to Free.
One seat at one show, a state machine over Free, Locked, Sold
The accented edge is the hard case the answer keeps coming back to, because a lock that dies while the bank is still thinking leaves money taken and seats unsold, which is the outcome the confirm result has a name for.

Patterns actually used

A hold with a time to live, and it is the answer to the question. Three states, not two. Free, locked, sold. A two state model has no way to say that somebody is in the middle of paying, which means you either sell a seat twice or hold a database transaction open across a human being typing a card number.

Strategy on pricing. One seam, one implementation, and the Kotlin shows how a weekend surcharge wraps the tier rule rather than replacing it. That composition is the same idea as surge pricing over ride pricing, and it is worth naming the connection.

A result type instead of a boolean. Five outcomes, one of which obliges the caller to refund money. A boolean return would let a caller ignore it and a nullable booking would collapse three different failures into one.

What is deliberately not here. No observer publishing live seat availability, although it is correct and cheap, because the fan out is shown properly in the food ordering answer and repeating it teaches nothing. No adapter around a payment gateway, because card handling is a different interview. No singleton on the service, which most write ups add and which contributes nothing to the design. No state classes on the booking, because it has two states and one transition.

Java

com.androidinterview.movieticket.lock.SeatKey.java

package com.androidinterview.movieticket.lock;

// The identity of one seat at one show. A record rather than a joined string,
// so a typo is a compile error instead of a silent miss, and so nothing has to
// agree on a separator.
public record SeatKey(String showId, String seatId) {
}

com.androidinterview.movieticket.lock.SeatLock.java

package com.androidinterview.movieticket.lock;

import java.time.Instant;
import java.util.List;

// A temporary claim on some named seats. The user is on the record because
// release has to check it, and the expiry is an absolute instant so any process
// that reads the lock can judge it without a timer of its own.
public record SeatLock(
        String id,
        String showId,
        List<String> seatIds,
        String userId,
        Instant expiresAt) {

    public boolean isExpiredAt(Instant now) {
        return !now.isBefore(expiresAt);
    }
}

com.androidinterview.movieticket.lock.SeatLockManager.java

package com.androidinterview.movieticket.lock;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;

import com.androidinterview.movieticket.model.Seat;
import com.androidinterview.movieticket.model.Show;

// The whole problem lives here. A seat for one show is in exactly one of three
// states, free, locked by somebody who is paying, or sold. This class owns all
// three and owns the only lock, because splitting that across two classes is
// how you get two half correct ones.
//
// A hotel counts rooms and this counts nothing, because seats are named. That
// difference is why this is a map of seat keys and not a set of counters.
//
// Be honest about the lock. This is one process. A real ticketing platform runs
// many servers behind a load balancer, so the guarantee has to come from a
// shared store, either a row per seat updated conditionally in a transaction or
// a short lived key per seat in Redis.
public final class SeatLockManager {

    private final Clock clock;
    private final Duration ttl;
    private final Map<SeatKey, SeatLock> locksBySeat = new HashMap<>();
    private final Map<String, SeatLock> byLockId = new HashMap<>();
    private final Set<SeatKey> sold = new HashSet<>();
    private final Object monitor = new Object();

    public SeatLockManager(Clock clock, Duration ttl) {
        this.clock = clock;
        this.ttl = ttl;
    }

    // A seat the caller is holding is shown as available to that caller, because
    // refreshing the seat map in the middle of your own checkout should not tell
    // you your seats are gone.
    public List<Seat> availableSeats(Show show, String userId) {
        synchronized (monitor) {
            sweepExpired();
            List<Seat> free = new ArrayList<>();
            for (Seat seat : show.screen().seats()) {
                SeatKey key = new SeatKey(show.id(), seat.id());
                SeatLock held = locksBySeat.get(key);
                boolean lockedByOther = held != null && !held.userId().equals(userId);
                if (!sold.contains(key) && !lockedByOther) {
                    free.add(seat);
                }
            }
            return free;
        }
    }

    // All the seats or none of them. Four friends given three seats together is
    // worse than four friends given nothing, so this checks every seat before it
    // writes anything.
    //
    // The seat ids are sorted first, and repeats are dropped. That canonical
    // order is not needed while one lock covers the whole map, but it is exactly
    // what a per seat locking scheme would need to avoid deadlock, so it belongs
    // in the design now.
    public Optional<SeatLock> lockSeats(String showId, List<String> seatIds, String userId) {
        List<String> ordered = seatIds.stream().distinct().sorted().toList();
        synchronized (monitor) {
            sweepExpired();
            for (String seatId : ordered) {
                SeatKey key = new SeatKey(showId, seatId);
                if (sold.contains(key) || locksBySeat.containsKey(key)) {
                    return Optional.empty();
                }
            }
            SeatLock seatLock = new SeatLock(UUID.randomUUID().toString(), showId,
                    ordered, userId, clock.instant().plus(ttl));
            for (String seatId : ordered) {
                locksBySeat.put(new SeatKey(showId, seatId), seatLock);
            }
            byLockId.put(seatLock.id(), seatLock);
            return Optional.of(seatLock);
        }
    }

    // Owner checked. Without this comparison the sweeper, or a stale request
    // from an abandoned tab, can free seats that a different user acquired a
    // moment ago and is already paying for.
    public boolean release(String lockId, String userId) {
        synchronized (monitor) {
            SeatLock held = byLockId.get(lockId);
            if (held == null || !held.userId().equals(userId)) {
                return false;
            }
            remove(held);
            return true;
        }
    }

    // The compare and set. It judges the expiry itself rather than trusting the
    // sweeper to have run, so the payment callback and the sweeper cannot both
    // believe they won. If this returns false the money has already been taken
    // and has to be refunded, which is a real outcome and not an edge case.
    public boolean confirm(String lockId, String userId) {
        synchronized (monitor) {
            SeatLock held = byLockId.get(lockId);
            if (held == null || !held.userId().equals(userId)) {
                return false;
            }
            remove(held);
            if (held.isExpiredAt(clock.instant())) {
                return false;
            }
            for (String seatId : held.seatIds()) {
                sold.add(new SeatKey(held.showId(), seatId));
            }
            return true;
        }
    }

    public void releaseSold(String showId, List<String> seatIds) {
        synchronized (monitor) {
            seatIds.forEach(seatId -> sold.remove(new SeatKey(showId, seatId)));
        }
    }

    // A scheduled job in production. Called at the top of every read here, so a
    // test can advance a fixed Clock instead of sleeping.
    public int sweepExpired() {
        synchronized (monitor) {
            Instant now = clock.instant();
            Set<SeatLock> stale = new HashSet<>();
            for (SeatLock held : byLockId.values()) {
                if (held.isExpiredAt(now)) {
                    stale.add(held);
                }
            }
            stale.forEach(this::remove);
            return stale.size();
        }
    }

    private void remove(SeatLock held) {
        held.seatIds().forEach(seatId -> locksBySeat.remove(new SeatKey(held.showId(), seatId)));
        byLockId.remove(held.id());
    }
}

com.androidinterview.movieticket.model.Booking.java

package com.androidinterview.movieticket.model;

import java.util.List;

// A confirmed set of seats for one show. There is no pending state here on
// purpose. Pending is what the seat lock is, and it lives in the lock manager
// where it can expire.
//
// Two states and one transition, so a boolean is the whole state machine. A
// status enum with a transition table would be ceremony around one flag.
public final class Booking {

    private final String id;
    private final String showId;
    private final String userId;
    private final List<String> seatIds;
    private final Money total;
    private boolean cancelled;

    public Booking(String id, String showId, String userId, List<String> seatIds, Money total) {
        this.id = id;
        this.showId = showId;
        this.userId = userId;
        this.seatIds = List.copyOf(seatIds);
        this.total = total;
    }

    public String id() { return id; }
    public String showId() { return showId; }
    public String userId() { return userId; }
    public List<String> seatIds() { return seatIds; }
    public Money total() { return total; }
    public synchronized boolean cancelled() { return cancelled; }

    public synchronized void cancel() {
        if (cancelled) {
            throw new IllegalStateException("this booking is already cancelled");
        }
        cancelled = true;
    }
}

com.androidinterview.movieticket.model.Cinema.java

package com.androidinterview.movieticket.model;

import java.util.List;

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

com.androidinterview.movieticket.model.Money.java

package com.androidinterview.movieticket.model;

public record Money(String currency, long amount) {

    public Money plus(Money other) {
        return new Money(currency, amount + other.amount);
    }

    public Money scale(double factor) {
        return new Money(currency, Math.round(amount * factor));
    }
}

com.androidinterview.movieticket.model.Movie.java

package com.androidinterview.movieticket.model;

import java.time.Duration;

public record Movie(String id, String title, String language, Duration runtime) {
}

com.androidinterview.movieticket.model.Screen.java

package com.androidinterview.movieticket.model;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// A screen and the seats in it. The lookup by id is built once, because pricing
// a basket of seats should not walk the whole auditorium per seat, and because
// an id nobody recognises has to be rejected rather than quietly skipped.
public final class Screen {

    private final String id;
    private final String name;
    private final List<Seat> seats;
    private final Map<String, Seat> byId = new LinkedHashMap<>();

    public Screen(String id, String name, List<Seat> seats) {
        this.id = id;
        this.name = name;
        this.seats = List.copyOf(seats);
        for (Seat seat : this.seats) {
            byId.put(seat.id(), seat);
        }
    }

    public String id() { return id; }
    public String name() { return name; }
    public List<Seat> seats() { return seats; }

    public Seat seatById(String seatId) {
        return byId.get(seatId);
    }
}

com.androidinterview.movieticket.model.Seat.java

package com.androidinterview.movieticket.model;

// A physical seat in a screen. It carries no availability of its own, and that
// is the point. The same seat is free for the six o'clock show and taken for
// the nine, so availability belongs to the pair of a show and a seat, never to
// the seat alone.
public record Seat(String id, String row, int number, SeatTier tier) {
}

com.androidinterview.movieticket.model.SeatTier.java

package com.androidinterview.movieticket.model;

// The price multiplier lives on the tier, so nothing else writes a switch over
// seat classes. Adding a recliner tier is one line.
public enum SeatTier {
    SILVER(1.0),
    GOLD(1.5),
    PLATINUM(2.0);

    private final double multiplier;

    SeatTier(double multiplier) {
        this.multiplier = multiplier;
    }

    public double multiplier() {
        return multiplier;
    }
}

com.androidinterview.movieticket.model.Show.java

package com.androidinterview.movieticket.model;

import java.time.Instant;

// A movie on a screen at a time. This is the thing tickets are actually sold
// against, and its id is half of every inventory key in the system.
public record Show(String id, Movie movie, Screen screen, Instant startsAt, Money basePrice) {
}

com.androidinterview.movieticket.pricing.PricingStrategy.java

package com.androidinterview.movieticket.pricing;

import com.androidinterview.movieticket.model.Money;
import com.androidinterview.movieticket.model.Seat;
import com.androidinterview.movieticket.model.Show;

// Priced per seat, not per booking, because a booking can mix tiers.
public interface PricingStrategy {
    Money priceFor(Show show, Seat seat);
}

com.androidinterview.movieticket.pricing.TierPricing.java

package com.androidinterview.movieticket.pricing;

import com.androidinterview.movieticket.model.Money;
import com.androidinterview.movieticket.model.Seat;
import com.androidinterview.movieticket.model.Show;

// The show's base price scaled by the tier of the seat. A weekend or a
// matinee rule wraps this rather than replacing it, the same way surge wraps
// ride pricing.
public final class TierPricing implements PricingStrategy {

    @Override
    public Money priceFor(Show show, Seat seat) {
        return show.basePrice().scale(seat.tier().multiplier());
    }
}

com.androidinterview.movieticket.service.BookingService.java

package com.androidinterview.movieticket.service;

import java.time.Clock;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.BiPredicate;

import com.androidinterview.movieticket.lock.SeatLock;
import com.androidinterview.movieticket.lock.SeatLockManager;
import com.androidinterview.movieticket.model.Booking;
import com.androidinterview.movieticket.model.Money;
import com.androidinterview.movieticket.model.Seat;
import com.androidinterview.movieticket.model.Show;
import com.androidinterview.movieticket.pricing.PricingStrategy;

// The one class the app talks to. It sequences selection, payment and
// confirmation, and it owns none of the rules.
//
// Payment is a two argument predicate rather than a gateway interface, because
// card handling is a different interview. The first argument is the idempotency
// key, which is what makes a retried charge safe.
public final class BookingService {

    private final SeatLockManager locks;
    private final PricingStrategy pricing;
    private final BiPredicate<String, Money> charge;
    private final Clock clock;

    // One entry per idempotency key, claimed before the card is touched. A
    // plain map read followed by a write would let two copies of the same
    // request both miss and both charge, which is the exact bug this map is
    // here to prevent.
    private final ConcurrentMap<String, CompletableFuture<ConfirmResult>> attempts =
            new ConcurrentHashMap<>();

    public BookingService(SeatLockManager locks, PricingStrategy pricing,
                          BiPredicate<String, Money> charge, Clock clock) {
        this.locks = locks;
        this.pricing = pricing;
        this.charge = charge;
        this.clock = clock;
    }

    public List<Seat> availableSeats(Show show, String userId) {
        return locks.availableSeats(show, userId);
    }

    // Every requested seat has to exist in this screen. Skipping the ones that
    // do not would quote a lower total and then charge it.
    public Money quote(Show show, List<String> seatIds) {
        Money total = new Money(show.basePrice().currency(), 0);
        for (String seatId : seatIds) {
            Seat seat = show.screen().seatById(seatId);
            if (seat == null) {
                throw new IllegalArgumentException("no seat " + seatId + " in this screen");
            }
            total = total.plus(pricing.priceFor(show, seat));
        }
        return total;
    }

    // Selection takes the lock. Nothing is charged and nothing is sold yet, and
    // the clock is now running. A show that has already started sells nothing,
    // so the request is refused here rather than ten minutes later.
    public Optional<SeatLock> selectSeats(Show show, List<String> seatIds, String userId) {
        if (hasStarted(show)) {
            return Optional.empty();
        }
        return locks.lockSeats(show.id(), seatIds, userId);
    }

    // The order here is the answer to the hardest follow up in this problem.
    // Charge first, then confirm, because a cinema seat cannot be sold twice
    // and a refund is a real remedy. If the lock died while the bank was
    // thinking, the seats may already belong to somebody else, so the honest
    // result is that a refund is owed.
    //
    // The key is claimed before any of that. One key, one answer, so a retry
    // that arrives while the first request is still at the bank waits for it
    // and reads its outcome rather than charging the card a second time.
    public ConfirmResult confirm(Show show, SeatLock seatLock, String userId, String idempotencyKey) {
        CompletableFuture<ConfirmResult> mine = new CompletableFuture<>();
        CompletableFuture<ConfirmResult> running = attempts.putIfAbsent(idempotencyKey, mine);
        if (running != null) {
            ConfirmResult first = running.join();
            return first instanceof ConfirmResult.Confirmed done
                    ? new ConfirmResult.AlreadyConfirmed(done.booking())
                    : first;
        }
        try {
            ConfirmResult result = attemptConfirm(show, seatLock, userId, idempotencyKey);
            mine.complete(result);
            return result;
        } catch (RuntimeException failure) {
            // Nothing was decided, so the key must not be poisoned by it.
            attempts.remove(idempotencyKey, mine);
            mine.completeExceptionally(failure);
            throw failure;
        }
    }

    private ConfirmResult attemptConfirm(Show show, SeatLock seatLock, String userId, String key) {
        // Ten minutes is long enough for a show to begin, so the clock is
        // checked again here and before the card rather than only at selection.
        if (hasStarted(show)) {
            locks.release(seatLock.id(), userId);
            return new ConfirmResult.ShowStarted();
        }
        Money total = quote(show, seatLock.seatIds());
        if (!charge.test(key, total)) {
            locks.release(seatLock.id(), userId);
            return new ConfirmResult.PaymentDeclined();
        }
        if (!locks.confirm(seatLock.id(), userId)) {
            return new ConfirmResult.RefundNeeded("the seat lock expired while payment was in flight");
        }
        Booking booking = new Booking(UUID.randomUUID().toString(), show.id(), userId,
                seatLock.seatIds(), total);
        return new ConfirmResult.Confirmed(booking);
    }

    // Abandoning checkout should not make three other people wait ten minutes.
    public boolean abandon(SeatLock seatLock, String userId) {
        return locks.release(seatLock.id(), userId);
    }

    public void cancel(Booking booking) {
        booking.cancel();
        locks.releaseSold(booking.showId(), booking.seatIds());
    }

    private boolean hasStarted(Show show) {
        return !clock.instant().isBefore(show.startsAt());
    }
}

com.androidinterview.movieticket.service.ConfirmResult.java

package com.androidinterview.movieticket.service;

import com.androidinterview.movieticket.model.Booking;

// Five outcomes, not a boolean and not a nullable booking. Refund needed is the
// one everybody forgets, and it is the whole reason this is a type rather than
// an if statement.
public sealed interface ConfirmResult {

    record Confirmed(Booking booking) implements ConfirmResult {}

    record AlreadyConfirmed(Booking booking) implements ConfirmResult {}

    record PaymentDeclined() implements ConfirmResult {}

    // The house lights are already down. Nothing has been charged, because the
    // clock is checked before the card is.
    record ShowStarted() implements ConfirmResult {}

    // The seats were gone by the time the money arrived. The charge has to be
    // reversed and the user has to be told, and neither of those is optional.
    record RefundNeeded(String reason) implements ConfirmResult {}
}

Kotlin

com.androidinterview.movieticket.lock.SeatLockManager.kt

package com.androidinterview.movieticket.lock

import com.androidinterview.movieticket.model.Seat
import com.androidinterview.movieticket.model.Show
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

// The identity of one seat at one show. A data class rather than a joined
// string, so a typo is a compile error instead of a silent miss.
data class SeatKey(val showId: String, val seatId: String)

// A temporary claim on some named seats. The user is on the record because
// release has to check it, and the expiry is an absolute instant so any process
// can judge it without a timer of its own.
data class SeatLock(
    val id: String,
    val showId: String,
    val seatIds: List<String>,
    val userId: String,
    val expiresAt: Instant,
) {
    fun isExpiredAt(now: Instant) = now >= expiresAt
}

// The whole problem lives here. A seat at a show is free, locked by somebody who
// is paying, or sold. This class owns all three and owns the only lock.
//
// A hotel counts rooms and this counts nothing, because seats are named. That is
// why this is a map of seat keys rather than a set of counters.
//
// Be honest about the lock. This is one process. A real ticketing platform runs
// many servers, so the guarantee has to come from a shared store, either a row
// per seat updated conditionally in a transaction or a short lived key per seat.
class SeatLockManager(private val clock: Clock, private val ttl: Duration) {

    private val locksBySeat = mutableMapOf<SeatKey, SeatLock>()
    private val byLockId = mutableMapOf<String, SeatLock>()
    private val sold = mutableSetOf<SeatKey>()
    private val guard = ReentrantLock()

    // A seat the caller is holding is shown as available to that caller, because
    // refreshing the seat map in the middle of your own checkout should not tell
    // you your seats are gone.
    fun availableSeats(show: Show, userId: String): List<Seat> = guard.withLock {
        sweepExpired()
        show.screen.seats.filter { seat ->
            val key = SeatKey(show.id, seat.id)
            val held = locksBySeat[key]
            key !in sold && (held == null || held.userId == userId)
        }
    }

    // All the seats or none of them. Four friends given three seats together is
    // worse than four friends given nothing, so every seat is checked before
    // anything is written.
    //
    // The ids are sorted first and repeats are dropped. That canonical order is
    // not needed while one lock covers the whole map, but it is exactly what a
    // per seat scheme would need to avoid deadlock, so it belongs in the design
    // now.
    fun lockSeats(showId: String, seatIds: List<String>, userId: String): SeatLock? = guard.withLock {
        sweepExpired()
        val ordered = seatIds.distinct().sorted()
        val keys = ordered.map { SeatKey(showId, it) }
        if (keys.any { it in sold || it in locksBySeat }) return@withLock null

        SeatLock(UUID.randomUUID().toString(), showId, ordered, userId, clock.instant().plus(ttl))
            .also { held ->
                keys.forEach { locksBySeat[it] = held }
                byLockId[held.id] = held
            }
    }

    // Owner checked. Without this comparison the sweeper, or a stale request
    // from an abandoned tab, frees seats that a different user acquired a moment
    // ago and is already paying for.
    fun release(lockId: String, userId: String): Boolean = guard.withLock {
        val held = findById(lockId)
        if (held == null || held.userId != userId) return@withLock false
        remove(held)
        true
    }

    // The compare and set. It judges the expiry itself rather than trusting the
    // sweeper to have run, so the payment callback and the sweeper cannot both
    // believe they won. A false here means money has been taken and has to go
    // back, which is a real outcome and not an edge case.
    fun confirm(lockId: String, userId: String): Boolean = guard.withLock {
        val held = findById(lockId)
        if (held == null || held.userId != userId) return@withLock false
        remove(held)
        if (held.isExpiredAt(clock.instant())) return@withLock false
        held.seatIds.forEach { sold += SeatKey(held.showId, it) }
        true
    }

    fun releaseSold(showId: String, seatIds: List<String>) = guard.withLock {
        seatIds.forEach { sold -= SeatKey(showId, it) }
    }

    // A scheduled job in production. Driven from every read here so a test can
    // advance a fixed Clock instead of sleeping.
    fun sweepExpired(): Int = guard.withLock {
        val now = clock.instant()
        val stale = byLockId.values.filter { it.isExpiredAt(now) }.toList()
        stale.forEach { remove(it) }
        stale.size
    }

    // Keyed by lock id as well as by seat, so confirm is a lookup rather than a
    // walk over every locked seat in a full house.
    private fun findById(lockId: String) = byLockId[lockId]

    private fun remove(held: SeatLock) {
        held.seatIds.forEach { locksBySeat.remove(SeatKey(held.showId, it)) }
        byLockId.remove(held.id)
    }
}

com.androidinterview.movieticket.model.Domain.kt

package com.androidinterview.movieticket.model

import java.time.Duration
import java.time.Instant
import kotlin.math.roundToLong

data class Money(val currency: String, val amount: Long) {
    operator fun plus(other: Money) = copy(amount = amount + other.amount)
    operator fun times(factor: Double) = copy(amount = (amount * factor).roundToLong())
    fun percentOf(basisPoints: Int) = copy(amount = (amount * basisPoints / 10000.0).roundToLong())
}

data class Movie(val id: String, val title: String, val language: String, val runtime: Duration)

// The price multiplier rides on the tier, so nothing else writes a when over
// seat classes. A recliner tier is one line.
enum class SeatTier(val multiplier: Double) {
    SILVER(1.0),
    GOLD(1.5),
    PLATINUM(2.0),
}

// A physical seat in a screen. It carries no availability of its own, and that
// is the point. The same seat is free at six and taken at nine, so availability
// belongs to the pair of a show and a seat.
data class Seat(val id: String, val row: String, val number: Int, val tier: SeatTier)

// A screen and the seats in it. The lookup by id is built once, because pricing
// a basket of seats should not walk the whole auditorium per seat, and because
// an id nobody recognises has to be rejected rather than quietly skipped.
class Screen(val id: String, val name: String, val seats: List<Seat>) {
    private val byId = seats.associateBy { it.id }
    fun seatById(seatId: String): Seat? = byId[seatId]
}

data class Cinema(val id: String, val name: String, val city: String, val screens: List<Screen>)

// A movie on a screen at a time. This is what tickets are sold against, and its
// id is half of every inventory key in the system.
data class Show(
    val id: String,
    val movie: Movie,
    val screen: Screen,
    val startsAt: Instant,
    val basePrice: Money,
)

// A confirmed set of seats. There is no pending state here on purpose. Pending
// is what the seat lock is, and it lives where it can expire.
class Booking(
    val id: String,
    val showId: String,
    val userId: String,
    val seatIds: List<String>,
    val total: Money,
) {
    var cancelled: Boolean = false
        private set

    fun cancel() {
        check(!cancelled) { "this booking is already cancelled" }
        cancelled = true
    }
}

com.androidinterview.movieticket.pricing.Pricing.kt

package com.androidinterview.movieticket.pricing

import com.androidinterview.movieticket.model.Money
import com.androidinterview.movieticket.model.Seat
import com.androidinterview.movieticket.model.Show

// Priced per seat, not per booking, because a booking can mix tiers.
fun interface PricingStrategy {
    fun priceFor(show: Show, seat: Seat): Money
}

val tierPricing = PricingStrategy { show, seat -> show.basePrice * seat.tier.multiplier }

// A weekend or a premiere rule wraps the tier rule rather than replacing it, the
// same way surge wraps ride pricing. Wrapping composes. Siblings multiply.
fun PricingStrategy.withSurcharge(basisPoints: Int, applies: (Show) -> Boolean) =
    PricingStrategy { show, seat ->
        val base = priceFor(show, seat)
        if (applies(show)) base + base.percentOf(basisPoints) else base
    }

com.androidinterview.movieticket.service.BookingService.kt

package com.androidinterview.movieticket.service

import com.androidinterview.movieticket.lock.SeatLock
import com.androidinterview.movieticket.lock.SeatLockManager
import com.androidinterview.movieticket.model.Booking
import com.androidinterview.movieticket.model.Money
import com.androidinterview.movieticket.model.Seat
import com.androidinterview.movieticket.model.Show
import com.androidinterview.movieticket.pricing.PricingStrategy
import java.time.Clock
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap

// Five outcomes, not a boolean and not a nullable booking. Refund needed is the
// one everybody forgets, and it is the whole reason this is a type.
sealed interface ConfirmResult {
    data class Confirmed(val booking: Booking) : ConfirmResult
    data class AlreadyConfirmed(val booking: Booking) : ConfirmResult
    data object PaymentDeclined : ConfirmResult

    // The house lights are already down. Nothing has been charged, because the
    // clock is checked before the card is.
    data object ShowStarted : ConfirmResult

    // The seats were gone by the time the money arrived. The charge has to be
    // reversed and the user has to be told, and neither is optional.
    data class RefundNeeded(val reason: String) : ConfirmResult
}

// The one class the app talks to. It sequences selection, payment and
// confirmation and owns none of the rules.
//
// Payment is a function type rather than a gateway interface, because card
// handling is a different interview. The key is what makes a retry safe.
class BookingService(
    private val locks: SeatLockManager,
    private val pricing: PricingStrategy,
    private val charge: (idempotencyKey: String, amount: Money) -> Boolean,
    private val clock: Clock,
) {
    // One entry per idempotency key, claimed before the card is touched. A
    // plain map read followed by a write would let two copies of the same
    // request both miss and both charge, which is the exact bug this map is
    // here to prevent.
    private val attempts = ConcurrentHashMap<String, CompletableFuture<ConfirmResult>>()

    fun availableSeats(show: Show, userId: String): List<Seat> = locks.availableSeats(show, userId)

    // Every requested seat has to exist in this screen. Skipping the ones that
    // do not would quote a lower total and then charge it.
    fun quote(show: Show, seatIds: List<String>): Money =
        seatIds.fold(Money(show.basePrice.currency, 0)) { running, seatId ->
            val seat = requireNotNull(show.screen.seatById(seatId)) { "no seat $seatId in this screen" }
            running + pricing.priceFor(show, seat)
        }

    // Selection takes the lock. Nothing is charged and nothing is sold yet, and
    // the clock is now running. A show that has already started sells nothing,
    // so the request is refused here rather than ten minutes later.
    fun selectSeats(show: Show, seatIds: List<String>, userId: String): SeatLock? =
        if (hasStarted(show)) null else locks.lockSeats(show.id, seatIds, userId)

    // The order here is the answer to the hardest follow up in this problem.
    // Charge first, then confirm, because a cinema seat cannot be sold twice and
    // a refund is a real remedy. If the lock died while the bank was thinking,
    // the seats may already belong to somebody else.
    //
    // The key is claimed before any of that. One key, one answer, so a retry
    // that arrives while the first request is still at the bank waits for it
    // and reads its outcome rather than charging the card a second time.
    fun confirm(show: Show, seatLock: SeatLock, userId: String, idempotencyKey: String): ConfirmResult {
        val mine = CompletableFuture<ConfirmResult>()
        attempts.putIfAbsent(idempotencyKey, mine)?.let { running ->
            val first = running.join()
            return if (first is ConfirmResult.Confirmed) ConfirmResult.AlreadyConfirmed(first.booking) else first
        }
        return runCatching { attemptConfirm(show, seatLock, userId, idempotencyKey) }
            .onSuccess { mine.complete(it) }
            .onFailure {
                // Nothing was decided, so the key must not be poisoned by it.
                attempts.remove(idempotencyKey, mine)
                mine.completeExceptionally(it)
            }
            .getOrThrow()
    }

    private fun attemptConfirm(show: Show, seatLock: SeatLock, userId: String, key: String): ConfirmResult {
        // Ten minutes is long enough for a show to begin, so the clock is
        // checked again here and before the card rather than only at selection.
        if (hasStarted(show)) {
            locks.release(seatLock.id, userId)
            return ConfirmResult.ShowStarted
        }
        val total = quote(show, seatLock.seatIds)
        if (!charge(key, total)) {
            locks.release(seatLock.id, userId)
            return ConfirmResult.PaymentDeclined
        }
        if (!locks.confirm(seatLock.id, userId)) {
            return ConfirmResult.RefundNeeded("the seat lock expired while payment was in flight")
        }
        return ConfirmResult.Confirmed(
            Booking(UUID.randomUUID().toString(), show.id, userId, seatLock.seatIds, total),
        )
    }

    // Abandoning checkout should not make three other people wait ten minutes.
    fun abandon(seatLock: SeatLock, userId: String) = locks.release(seatLock.id, userId)

    fun cancel(booking: Booking) {
        booking.cancel()
        locks.releaseSold(booking.showId, booking.seatIds)
    }

    private fun hasStarted(show: Show) = !clock.instant().isBefore(show.startsAt)
}

Concurrency and edge cases

Two people tap the same seat at the same instant. Both seat maps show 12F as free. Both requests arrive within milliseconds. If the code reads the seat state and then writes it as two separate steps, both reads pass before either write lands and the cinema sells one seat twice. Checking and taking has to be one indivisible operation, which is what the lock manager's critical section provides.

A partial lock is worse than no lock. Four seats requested, three free. Writing the three you can get and failing on the fourth leaves three seats claimed by somebody who will now abandon checkout, and they stay claimed for ten minutes. Check every seat first, write only if all of them pass.

The lock expires while the card is being charged. This is the follow up that separates answers, so have a real position on it.

The order this design uses is lock, then charge, then confirm. The lock is held across the payment, which is the entire reason a lock has a ten minute life rather than a ten second one. Confirm judges the expiry itself, under the same critical section, so the payment path and the sweeper cannot both believe they won. If confirm loses, the money has been taken and the seats have not been sold, and the only honest answer is a refund plus a message.

The other order exists and it is worth naming. Confirm first, then charge, and cancel on a declined card. That eliminates the refund case entirely, because seats you already hold cannot be lost. It costs you the opposite risk, seats marked sold for a payment that never arrives, and a process that dies between the two steps leaves them sold forever. The Booking.com answer takes that side, because room inventory is a counter that a cancellation cleanly returns. Pick one, say why, and name the failure you accepted.

Releasing somebody else's lock. The sweeper fires on an expired lock at the same moment another user acquires the same seats. If release only takes a lock id, the sweeper frees seats the new user is already paying for. Release compares the owner first. One line, and it is the detail interviewers listen for.

A user abandons checkout. Do not make three other people wait ten minutes for a tab that was closed. Release explicitly when the user navigates away, and let the sweeper handle only the cases where nothing came back at all.

A duplicate confirm from a retry. An idempotency key on confirmation means the second request finds the first booking and returns it rather than trying to sell already sold seats. Claim the key before the card is charged, not after. Two copies of the same request that both read an empty map both go to the bank, which is the double charge the key was supposed to prevent. The same key goes to the payment provider so a repeated webhook cannot charge twice either.

Be honest about what the lock does not solve. A synchronized block or a ReentrantLock protects one process. A ticketing platform on the morning a big film opens is running many servers, and none of them can see each other's monitors. The guarantee has to move somewhere shared. Two workable answers, and their costs.

  • A row per seat per show in the database, updated conditionally inside a transaction. Correct, easy to reason about, and it puts real load on a hot row for a popular show.
  • A short lived key per seat in a fast store, set only if absent, with the expiry doing the sweeping for free. Fast and the natural fit for a ten minute hold, and it needs care so that a paused process cannot act on a lock it no longer holds.

The smaller cases, worth naming quickly.

  • Booking after the show has started. Reject at selection, and check again at confirm before the card is touched, because ten minutes is long enough for a show to begin. That is the fifth outcome on the result type.
  • Cancellation. Seats go back on sale, which is why the manager has a way to un sell them, and whether that is allowed at all is a policy question about how close to showtime it is.
  • The lone seat rule. Some cinemas refuse a selection that leaves exactly one empty seat between two bookings. It is a validation on the seat set at lock time and a good thing to mention as an extension.
  • A screen taken out of service with live bookings on it. That is a move to another screen or a refund, and it needs a human, not a rule.

Watch