The Problem: A Blank Page Under a 45-Minute Clock
You sit down for an LLD round and the interviewer says: “Design a movie ticket booking system.” That’s it. No schema, no API contract, no acceptance criteria. The trap is to start typing class User immediately. The candidates who pass are the ones who run a method — a deterministic pipeline from words to a class model — instead of relying on inspiration.
This lesson gives you that pipeline. It has four stages, and you can narrate every one of them out loud, which is exactly what senior interviewers want to hear.
Vague prompt
│
▼
[1] CLARIFY ──► a written, scoped requirement list (functional + a few non-functional)
│
▼
[2] EXTRACT ──► nouns → candidate classes/attributes ; verbs → methods/responsibilities
│
▼
[3] VALIDATE ──► CRC cards: does each class earn its place? who collaborates with whom?
│
▼
[4] ITERATE ──► merge/split/promote; resolve relationships; pick a slice to actually code
In an interview, say: “Before I model anything, I want to lock down scope, then I’ll pull candidate classes from the nouns and responsibilities from the verbs, sanity-check with CRC cards, and we’ll iterate.” This single sentence signals method over guesswork.
Stage 1 — Clarify Requirements (5-7 minutes, non-negotiable)
The prompt is intentionally underspecified. Your first job is scoping, not modeling. You are converting an open-ended question into a closed one you can actually solve in 30 minutes.
Ask questions in three buckets:
| Bucket | What you’re pinning down | Example questions for ticket booking |
|---|---|---|
| Actors & scope | Who uses it? What’s in/out? | ”Is this just one cinema or a chain across cities? Do we handle the admin who uploads showtimes, or only the customer booking flow?” |
| Core flow | The one happy path that must work | ”Walk me through the main flow: search movie → pick show → pick seats → pay → get ticket. Is that the slice you care about?” |
| Rules & edge cases | The interesting LLD bits | ”Can two users grab the same seat? Do we hold seats during payment? Are there seat types/pricing tiers? Can a booking be cancelled?” |
Write the answers down as a short bulleted spec. This becomes your contract and your noun/verb mine.
Scoped requirements (what we’ll assume for the worked example):
- A chain has many cinemas, each in a city; a cinema has multiple screens (halls).
- Each screen runs shows of a movie at given times; a show has a seat layout.
- A customer searches movies by city, selects a show, selects one or more seats, and pays.
- Concurrency rule: a seat must never be double-booked. Selected seats are held for a short window during payment, then either confirmed or released/expired.
- Out of scope (state this explicitly): real payment gateway internals, login/auth, food/snacks, reviews. We’ll stub payment behind an interface.
Always declare what’s out of scope out loud. Saying “I’ll stub the payment gateway behind a PaymentProcessor interface so we can focus on booking and seat concurrency” earns more credit than silently ignoring it — it shows you made a deliberate, defensible cut.
The single most important clarification in almost every booking/inventory problem is the concurrency rule. Double-booking is the classic “did they actually think about it” test. Surface it now so your model can carry it — and be ready to defend how you enforce it, which we tackle head-on in Stage 4.
Stage 2 — Noun & Verb Extraction
This is the mechanical heart of the method. From your written requirements:
- Nouns → candidate classes and attributes.
- Verbs → candidate methods / responsibilities.
It’s deliberately over-inclusive. You generate a wide list, then prune. Pruning later is cheap; forgetting a concept is expensive.
Noun extraction → candidate classes
Underline every noun in the spec: chain, cinema, city, screen/hall, show, movie, seat, seat layout, customer, booking, seat hold, payment, ticket, price/pricing tier.
Now classify each noun into one of four buckets — this is the real skill:
| Noun | Verdict | Reasoning |
|---|---|---|
| Movie, Cinema, Screen, Show, Seat, Booking | Class | Has identity + state + behavior. Keep. |
| Customer | Class (maybe slim) | Has identity; but if we don’t model profiles, it can be thin — keep as User. |
| City | Attribute or Value Object | Likely just a field/enum on Cinema, not its own entity. Don’t over-model. |
| Seat Layout | Owned by Screen | A collection of seats belonging to a screen; not standalone. |
| Seat Hold | Class | This is the concurrency mechanism — promote it; it has its own lifecycle (created → expires/confirmed). |
| Payment | Class + interface | Payment (record) plus PaymentProcessor (strategy interface). |
| Ticket | Class | The artifact of a confirmed booking. |
| Pricing tier | Attribute / Value Object | A Money value + a SeatCategory enum, not a heavyweight class. |
Not every noun deserves a class. New designers turn City, Status, and Price into classes and drown in plumbing. The filter is: does it have identity and behavior, or is it just a value? Values become attributes, enums, or small immutable value objects — not entities.
Verb extraction → responsibilities/methods
Underline every verb/verb-phrase: searches, selects (show), selects (seats), holds, releases, confirms, pays, cancels, books, generates ticket.
Assign each verb to the class that owns the data it acts on — that is the Information Expert principle (behavior lives where the data lives). Watch one important nuance, though: a verb like confirm-and-pay touches no single object’s data — it orchestrates several. That belongs on a Controller, not on an entity (see the callout below).
| Verb | Owning class | Method | GRASP role |
|---|---|---|---|
| search movies by city | SearchService | searchShows(city, movie, date) | Controller |
| hold seats | Show (owns its seats) | holdSeats(seatIds, userId) → returns SeatHold | Information Expert |
| release / expire hold | Show (owns the seats) | release(hold) / expireHolds() | Information Expert |
| confirm booking & pay | BookingService | confirm(hold, payment) | Controller |
| pay | PaymentProcessor | charge(amount, method) | Strategy |
| cancel | Booking | cancel() | Information Expert |
| generate ticket | Booking | issueTicket() | Creator |
In an interview, say: “I’ll keep behavior next to its data —
Showowns its seats, soShowis responsible for holding and releasing them. That avoids an anemic model where a service mutates everyone’s internals.”
Information Expert vs Controller — name them precisely. Putting holdSeats on Show because Show owns the seat data is the Information Expert pattern. Putting confirm(hold, payment) on BookingService — which owns no data but coordinates Show, PaymentProcessor, and Booking — is the Controller/Facade pattern (a use-case coordinator). These are two different GRASP responsibilities; conflating them is a common interview slip. Naming both correctly is itself a senior signal.
Stage 3 — Validate with CRC Cards
A CRC card (Class–Responsibility–Collaborator) is an index card with three zones. It’s the fastest way to pressure-test a model before you write code, and it works beautifully on a whiteboard or in plain text.
+--------------------------------------------------+
| Class: Show |
+----------------------------+---------------------+
| Responsibilities | Collaborators |
|----------------------------|---------------------|
| - Know its movie, screen, | Movie |
| start time | Screen |
| - Track seat availability | Seat |
| - Hold seats for a user | SeatHold |
| - Release / expire holds | |
+----------------------------+---------------------+
- Responsibilities = what this class knows and does (from your verb list). Keep it short — if a card overflows, the class is doing too much (Single Responsibility smell).
- Collaborators = the other classes it must talk to to fulfill those responsibilities. These edges become your associations / dependencies in the UML.
Write one card per candidate class and walk the happy path as a conversation between cards: “Customer asks BookingService to confirm. BookingService asks Show to hold seats. Show creates a SeatHold. BookingService asks PaymentProcessor to charge. On success it creates a Booking, which can issueTicket.”
Two things fall out of this walk-through automatically:
- Dead classes — a card with no responsibilities or no collaborators is probably an attribute, not a class. (e.g.,
Citynever came up as a collaborator → demote it.) - Missing classes / responsibilities — if the walk-through needs someone to do a job and no card owns it, you’ve found a gap. Here, asking “who unlocks the seat if the user never pays?” surfaces that
Showmust own arelease()/expireHolds()path andSeatHoldmust carry anexpiresAt. We close that gap explicitly in Stage 4 — naming a gap but never resolving it is exactly what loses points.
CRC cards turn modeling into a conversation between objects. If you can narrate the happy path as cards passing messages to each other, your responsibilities and collaborators are right. If the narration stalls (“…and then somehow the seat gets unlocked”), you’ve found a missing responsibility or class — chase it down rather than hand-waving past it.
Stage 4 — Iterate Into a First Class Model
Now consolidate the CRC findings into a UML sketch and resolve relationships. Decide for each pair of collaborators whether it is:
- Composition (whole owns part’s lifecycle; part dies with the whole — a
Screenowns itsSeats) — drawn with a filled diamond ◆ on the owner. - Aggregation / association (references but does not own the lifecycle — a
Showreferences a sharedMovie) — drawn with a hollow diamond ◇ on the aggregate (theShow), or a plain line. - Dependency (just uses, often via a parameter or interface —
BookingServiceusesPaymentProcessor) — drawn with a dashed/usesarrow.
Chain ◆── Cinema ◆── Screen ◆── Seat ◆ = composition (owns lifecycle)
│ ◇ = aggregation/association
│ runs ┄► = dependency (uses)
▼
Movie ───────────────◇ Show ──── creates ──► SeatHold
▲ │ (references its Seats)
SearchService ┄┄► Show │ │ confirmed into
│ ▼
User ──► BookingService ┄┄uses┄┄► PaymentProcessor (interface)
│ ▲
│ creates ┊ implements
▼ ┌─────┴───────┐
Booking ── issues ─► Ticket StripeProcessor
The hollow diamond sits on the Show end because Show is the aggregate — it references a shared Movie without owning its lifecycle (many shows point at the same movie). Contrast the composition chain above (Chain ◆── Cinema ◆── Screen ◆── Seat), where each filled diamond sits on the owner side: kill the Screen and its Seats die with it, but killing a Show must never delete the Movie. Diamond on the wrong end silently inverts ownership and is a classic UML misread.
Note that SearchService (the “search movies by city” responsibility we extracted) now appears as a real node — every documented responsibility should survive into the consolidated model, or you have silently dropped a requirement.
Here is the seat-holding core — the part interviewers actually dig into — modeled in all three languages. The concurrency rule from Stage 1 directly shapes the API: holdSeats is all-or-nothing (either every requested seat is held or none are) and returns a SeatHold rather than mutating seats one by one. Crucially, it is also guarded against concurrent callers, and it owns the matching release/expire path so a never-paid hold returns its seats to Available.
“All-or-nothing” is NOT the same as “thread-safe” — say this out loud. The naive check-then-act (read status, then write Held) has a classic race: two callers can both observe Available before either writes Held, double-booking the seat. JavaScript’s single-threaded event loop makes the synchronous body below safe as written; Python’s GIL does not save you (the read and the writes span multiple bytecode ops with possible thread switches between them); and C++ shared-memory threads are genuinely racy without a lock. Below, C++ uses a std::mutex and Python uses a threading.Lock. In a real distributed system even an in-process lock is insufficient — you enforce the hold with a DB row lock (SELECT ... FOR UPDATE), an optimistic version/CAS check, a unique constraint, or Redis SETNX with a TTL. State this in the interview; it is the #1 thing a senior interviewer probes.
enum SeatStatus { Available, Held, Booked }
class Seat {
constructor(public readonly id: string,
public status: SeatStatus = SeatStatus.Available) {}
}
class SeatHold {
readonly expiresAt: number;
constructor(public readonly userId: string,
public readonly seats: Seat[],
ttlMs = 5 * 60_000) {
this.expiresAt = Date.now() + ttlMs;
}
isExpired(): boolean { return Date.now() > this.expiresAt; }
}
class Show {
private seats = new Map<string, Seat>();
// All-or-nothing (no partial holds). Safe here only because JS runs this
// synchronous body on a single thread; a real server needs a DB/Redis guard.
holdSeats(seatIds: string[], userId: string): SeatHold {
const target = seatIds.map(id => this.seatOrThrow(id));
if (target.some(s => s.status !== SeatStatus.Available))
throw new Error("Some seats are no longer available");
target.forEach(s => (s.status = SeatStatus.Held));
return new SeatHold(userId, target);
}
// Closes the "who unlocks the seat?" gap from Stage 3.
release(hold: SeatHold): void {
for (const s of hold.seats)
if (s.status === SeatStatus.Held) s.status = SeatStatus.Available;
}
// Lazy expiry sweep; in prod a sweeper/cron or Redis TTL does this.
expireHolds(holds: SeatHold[]): void {
for (const h of holds) if (h.isExpired()) this.release(h);
}
private seatOrThrow(id: string): Seat {
const s = this.seats.get(id);
if (!s) throw new Error("Unknown seat " + id);
return s;
}
}Map the in-memory model to a real system out loud. In this exercise the hold lives in an in-memory map guarded by a lock, and expiry is enforced lazily (isExpired() checked on access). In production the hold lives in Redis (SETNX seat:{id} {user} with an EX TTL so it auto-expires) or a DB row with status + expires_at, and expiry is enforced either lazily (on the next read) or by a background sweeper/cron that releases stale holds. Mentioning where the hold lives and how it expires turns a textbook answer into a senior one.
And the payment abstraction we deliberately stubbed in Stage 1 — keeping the booking flow decoupled from any concrete gateway (Strategy pattern). Note BookingService is a Controller: it owns no entity data, it just orchestrates Show, PaymentProcessor, and Booking.
interface PaymentProcessor {
// Returns success/failure. (For richer errors a Result/PaymentResult
// with a decline reason is preferable; bool is fine for an interview slice.)
charge(amountCents: number, method: string): boolean;
}
class StripeProcessor implements PaymentProcessor {
charge(amountCents: number, method: string): boolean {
// call gateway... return success
return true;
}
}
class Booking {
constructor(public readonly hold: SeatHold) {}
issueTicket(): string { return "TICKET-" + this.hold.userId; }
}
// Controller/Facade: coordinates Show, PaymentProcessor, and Booking.
class BookingService {
constructor(private payments: PaymentProcessor, private show: Show) {}
confirm(hold: SeatHold, amountCents: number, method: string): Booking {
if (hold.isExpired()) {
this.show.release(hold); // give the seats back
throw new Error("Hold expired");
}
if (!this.payments.charge(amountCents, method)) {
this.show.release(hold); // payment failed -> release
throw new Error("Payment failed");
}
hold.seats.forEach(s => (s.status = SeatStatus.Booked));
return new Booking(hold);
}
}In an interview, say: “
PaymentProcessorreturns a boolean here for the slice, but in production I’d return aPaymentResultcarrying a decline reason and a transaction id, so the caller can distinguish ‘insufficient funds’ from ‘gateway timeout’ and decide whether to retry.” That single sentence shows you know the stub’s limits.
Putting It Together: The 45-Minute Budget
| Stage | Time | Output | Interviewer hears |
|---|---|---|---|
| 1. Clarify | 5-7 min | Written scoped spec | ”Scopes before coding” |
| 2. Extract | 5 min | Noun→class, verb→method tables | ”Has a repeatable method” |
| 3. CRC validate | 5-7 min | One card per class, happy-path walk | ”Pressure-tests before coding” |
| 4. Iterate + code | 20+ min | UML + the concurrency-critical slice | ”Can actually build it” |
The whole point is predictability. You will never again stare at a blank page hoping for inspiration — you run the pipeline, narrate each step, and converge on a defensible model every time.