Machine coding · Booking and reservations
Movie ticket booking system
- Reported once
- SDE-2 (per post title)
- Repository
- State
- Dependency injection
- 1 source
One candidate write-up. Treat it as a single data point.
Design an object-oriented movie ticket booking system, organised into entity, service and repository layers, and write tests for it. The sources do not state concurrency requirements, so the answer treats seat holds and conflicting bookings as the central design question and states that assumption.
Restated in our own words, not a quote from PhonePe or any page. The answer below is written for this site.
Clarify first
Pin the scope down before you name a single class.
Ask these first:
- Is the scope one cinema or many, and do I model screens and seat layouts?
- When a user picks seats, are the seats held while they pay? For how long?
- Can a user book several seats at once, and is there a limit?
- What happens if payment fails, or arrives after the hold has expired?
- Does the test suite need to run without a database or a real clock?
Assumed scope. The report names only entity, service and repository layers and tests. Holds, payment and cancellation are this answer's assumptions. In: shows with a seat grid, a timed hold on a set of seats, confirmation with a payment step, cancellation, availability, and a clear answer on conflicts. Out: search and browsing, seat pricing tiers, refunds, notifications, and closing sales when a show starts. Storage is in memory behind repositories.
Entities and relationships
Who owns what, and who talks to whom.
- Show has an id, a movie, a start time, a price and the ids of its seats (for example A1 to B5).
- Booking has a user, a show, a set of seats, an amount, an expiry time and a status: HELD, CONFIRMED, EXPIRED, CANCELLED or FAILED.
- Repository stores entities by id. BookingRepository extends it with a query for the seats already confirmed on a show.
- SeatLockManager owns the temporary holds. It reports success or the list of conflicting seats, records the booking id as the owner of each hold, and reads time from an injected clock.
- BookingService has the show and booking repositories, the lock manager, a payment function and the clock. It exposes
hold,confirm,cancelandavailable.
The design
The class map, the main flow and the patterns that earn their place.
- A client asks to hold some seats for a show.
- The service checks the show and the seats already confirmed in the booking repository.
- It lists every seat that is confirmed or held, then asks the lock manager to take all the seats at once with the booking id as owner.
- The lock manager compares each hold's expiry with the injected clock, so expired holds block nobody.
- On confirm the service charges the user. A decline releases the seats.
Open on the whiteboardAll 3 diagrams on one tidy board, with a step-by-step walkthrough.
The hard part of a booking system is not the classes, it is making sure two people never end up with the same seat. The design uses two layers of protection: a short-lived hold while the user pays, and a permanent record once the booking is confirmed.
Seat holds. SeatLockManager.tryLock takes a show, a list of seats, an owner (the booking id, not the user) and a time to live. It checks every seat first and either takes all of them or none, returning the list of conflicting seats. A hold counts as live while clock.now() is before its expiry, so it ends exactly at the expiry time. An expired hold needs no cleanup to stop blocking others, because every check compares against the clock. That makes expiry testable with a fake clock instead of timers.
Flow. hold validates the show, the seat count and the seat ids, rejects the request with the full list of seats that are confirmed or held, otherwise takes the locks and saves a HELD booking with its expiry. confirm checks that the booking is still HELD and not past its expiry, then calls the payment function. A decline marks it FAILED and releases the seats. A success marks it CONFIRMED. Late confirmation marks it EXPIRED and releases only the locks that carry its own booking id, so someone who has since taken those seats keeps them, even if it is the same user who booked them again. Availability is the seat list minus confirmed seats minus live holds.
Status as a state machine. HELD can go to CONFIRMED, EXPIRED, FAILED or CANCELLED. CONFIRMED can go to CANCELLED. Nothing else is allowed, and every method checks the current status before it changes it.
What a real concurrent system needs. The sketch is single-threaded, so each method runs to completion and the all-or-nothing check is atomic by construction. With many servers, that atomicity must come from the store. Use a unique constraint on (show, seat) for confirmed bookings so the database rejects a double booking whatever the application does, take holds with an atomic set-if-absent that carries its own time to live, and lock several seats in one transaction or in a sorted order to avoid deadlock. Make confirm idempotent, and have a refund path for a payment that completes after the hold has expired.
Patterns. The repositories keep storage separate from the booking rules. Booking status is a small state machine. The clock and the payment function are passed in, so tests replace them without mocks.
Trade-off. Expiring by comparison keeps the code simple, but an abandoned booking stays HELD in storage until someone touches it, so a periodic sweeper should mark those EXPIRED. Availability and the sold-seat check also scan every booking, which is fine for a sketch; a real store keeps an index by show.
The code
Runnable JavaScript, with the checks it passes.
class FakeClock {
constructor(start = 0) {
this.time = start;
}
now() {
return this.time;
}
advance(seconds) {
this.time += seconds;
}
}
class Show {
constructor(id, movie, startsAt, rows, perRow, price) {
this.id = id;
this.movie = movie;
this.startsAt = startsAt;
this.price = price;
this.seatIds = [];
for (let r = 0; r < rows; r++) {
for (let n = 1; n <= perRow; n++) this.seatIds.push(String.fromCharCode(65 + r) + n);
}
}
}
class Booking {
constructor(id, userId, showId, seatIds, expiresAt, amount) {
this.id = id;
this.userId = userId;
this.showId = showId;
this.seatIds = seatIds;
this.expiresAt = expiresAt;
this.amount = amount;
this.status = "HELD";
}
}
class Repository {
constructor() {
this.rows = new Map();
}
save(entity) {
this.rows.set(entity.id, entity);
return entity;
}
get(id) {
return this.rows.get(id) || null;
}
all() {
return [...this.rows.values()];
}
}
class BookingRepository extends Repository {
confirmedSeats(showId) {
const taken = new Set();
for (const b of this.all()) {
if (b.showId === showId && b.status === "CONFIRMED") b.seatIds.forEach((s) => taken.add(s));
}
return taken;
}
}
class SeatLockManager {
constructor(clock) {
this.clock = clock;
this.locks = new Map();
}
holder(showId, seatId) {
const lock = this.locks.get(showId + ":" + seatId);
if (!lock || this.clock.now() >= lock.expiresAt) return null;
return lock.owner;
}
tryLock(showId, seatIds, owner, ttl) {
const conflicts = seatIds.filter((s) => this.holder(showId, s) !== null);
if (conflicts.length > 0) return { ok: false, conflicts };
const expiresAt = this.clock.now() + ttl;
for (const s of seatIds) this.locks.set(showId + ":" + s, { owner, expiresAt });
return { ok: true, expiresAt };
}
release(showId, seatIds, owner) {
for (const s of seatIds) {
if (this.holder(showId, s) === owner) this.locks.delete(showId + ":" + s);
}
}
}
class BookingService {
constructor(shows, bookings, locks, clock, payments, options = {}) {
this.shows = shows;
this.bookings = bookings;
this.locks = locks;
this.clock = clock;
this.payments = payments;
this.holdSeconds = options.holdSeconds || 300;
this.maxSeats = options.maxSeats || 6;
this.nextId = 1;
}
available(showId) {
const show = this.shows.get(showId);
if (!show) return [];
const sold = this.bookings.confirmedSeats(showId);
return show.seatIds.filter((s) => !sold.has(s) && this.locks.holder(showId, s) === null);
}
hold(userId, showId, seatIds) {
const show = this.shows.get(showId);
if (!show) return { ok: false, reason: "unknown show" };
const unique = [...new Set(seatIds)];
if (unique.length === 0 || unique.length > this.maxSeats) return { ok: false, reason: "bad seat count" };
if (unique.some((s) => !show.seatIds.includes(s))) return { ok: false, reason: "unknown seat" };
const sold = this.bookings.confirmedSeats(showId);
const taken = unique.filter((s) => sold.has(s) || this.locks.holder(showId, s) !== null);
if (taken.length > 0) return { ok: false, reason: "seat taken", conflicts: taken };
const id = "B" + this.nextId;
const lock = this.locks.tryLock(showId, unique, id, this.holdSeconds);
if (!lock.ok) return { ok: false, reason: "seat taken", conflicts: lock.conflicts };
this.nextId++;
const booking = new Booking(id, userId, showId, unique, lock.expiresAt, show.price * unique.length);
return { ok: true, booking: this.bookings.save(booking) };
}
confirm(bookingId) {
const booking = this.bookings.get(bookingId);
if (!booking || booking.status !== "HELD") return { ok: false, reason: "not held" };
if (this.clock.now() >= booking.expiresAt) {
this.finish(booking, "EXPIRED");
return { ok: false, reason: "hold expired" };
}
if (!this.payments(booking.userId, booking.amount)) {
this.finish(booking, "FAILED");
return { ok: false, reason: "payment declined" };
}
booking.status = "CONFIRMED";
return { ok: true, booking };
}
cancel(bookingId) {
const booking = this.bookings.get(bookingId);
if (!booking || (booking.status !== "HELD" && booking.status !== "CONFIRMED")) return false;
this.finish(booking, "CANCELLED");
return true;
}
finish(booking, status) {
booking.status = status;
this.locks.release(booking.showId, booking.seatIds, booking.id);
}
}
function createService(payments = () => true, options = {}) {
const clock = new FakeClock();
const shows = new Repository();
shows.save(new Show("S1", "Film", 1000, 2, 5, 150));
const service = new BookingService(
shows,
new BookingRepository(),
new SeatLockManager(clock),
clock,
payments,
options
);
return { service, clock };
}Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.
Try your own input
Write JavaScript that calls the code above and prints with console.log. It runs after the solution.
Extend it
The change requests an interviewer throws next.
- How would you let a user extend a hold once, and what stops them extending it forever?
- How would you support seat categories with different prices, and charge the total in one payment?
- How would you make confirm idempotent when the payment provider retries its callback?
- How would you run this on many servers, and which guarantee do you move into the database?
- How would you handle a payment that succeeds after the hold has already expired and been taken by someone else?
Where candidates slip
The mistakes this problem punishes.
The classic failures here:
- Checking availability and then locking in two separate steps, which leaves a window where two users both pass the check.
- Locking seats one at a time and leaving the first ones locked when a later seat conflicts. The hold must be all or nothing.
- Holds that never expire, so an abandoned checkout blocks seats for ever. Test the exact expiry boundary.
- Releasing locks by user instead of by booking when a hold expires, which can free seats that the same or another user has legitimately taken since.
- Treating a confirmed seat as free once its hold has expired. Confirmed seats come from bookings, not from locks.
- Reading the system clock inside the service, which makes expiry untestable.
- Ignoring payment failure, so the seats stay held after a decline.
Have a go before you read.
Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.
Now make it stick
Where this was reported
Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.