Back to the path
Level 1 intermediate 24 min #lld#ooad#crc-cards#requirements#interview-method#noun-verb-extraction#concurrency

From Requirements to Classes: The Interview Method

A repeatable, time-boxed method for converting a vague machine-coding prompt into a clean first-pass class model: clarify scope, extract candidate classes from nouns and responsibilities from verbs, validate with CRC cards, and iterate. Worked end-to-end on a movie ticket booking system, including the seat-hold concurrency question senior interviewers always probe.

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:

BucketWhat you’re pinning downExample questions for ticket booking
Actors & scopeWho 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 flowThe 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 casesThe 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.
Tip

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:

NounVerdictReasoning
Movie, Cinema, Screen, Show, Seat, BookingClassHas identity + state + behavior. Keep.
CustomerClass (maybe slim)Has identity; but if we don’t model profiles, it can be thin — keep as User.
CityAttribute or Value ObjectLikely just a field/enum on Cinema, not its own entity. Don’t over-model.
Seat LayoutOwned by ScreenA collection of seats belonging to a screen; not standalone.
Seat HoldClassThis is the concurrency mechanism — promote it; it has its own lifecycle (created → expires/confirmed).
PaymentClass + interfacePayment (record) plus PaymentProcessor (strategy interface).
TicketClassThe artifact of a confirmed booking.
Pricing tierAttribute / Value ObjectA Money value + a SeatCategory enum, not a heavyweight class.
Common pitfall

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).

VerbOwning classMethodGRASP role
search movies by citySearchServicesearchShows(city, movie, date)Controller
hold seatsShow (owns its seats)holdSeats(seatIds, userId) → returns SeatHoldInformation Expert
release / expire holdShow (owns the seats)release(hold) / expireHolds()Information Expert
confirm booking & payBookingServiceconfirm(hold, payment)Controller
payPaymentProcessorcharge(amount, method)Strategy
cancelBookingcancel()Information Expert
generate ticketBookingissueTicket()Creator

In an interview, say: “I’ll keep behavior next to its data — Show owns its seats, so Show is responsible for holding and releasing them. That avoids an anemic model where a service mutates everyone’s internals.”

Key idea

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:

  1. Dead classes — a card with no responsibilities or no collaborators is probably an attribute, not a class. (e.g., City never came up as a collaborator → demote it.)
  2. 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 Show must own a release()/expireHolds() path and SeatHold must carry an expiresAt. We close that gap explicitly in Stage 4 — naming a gap but never resolving it is exactly what loses points.
Key idea

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 Screen owns its Seats) — drawn with a filled diamond ◆ on the owner.
  • Aggregation / association (references but does not own the lifecycle — a Show references a shared Movie) — drawn with a hollow diamond ◇ on the aggregate (the Show), or a plain line.
  • Dependency (just uses, often via a parameter or interface — BookingService uses PaymentProcessor) — drawn with a dashed/uses arrow.
   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.

Watch out

“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;
}
}
Tip

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:PaymentProcessor returns a boolean here for the slice, but in production I’d return a PaymentResult carrying 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

StageTimeOutputInterviewer hears
1. Clarify5-7 minWritten scoped spec”Scopes before coding”
2. Extract5 minNoun→class, verb→method tables”Has a repeatable method”
3. CRC validate5-7 minOne card per class, happy-path walk”Pressure-tests before coding”
4. Iterate + code20+ minUML + 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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. During noun extraction you list: Movie, City, Show, Status, Seat, Price. Which subset should most likely become full entity classes (with identity + behavior) rather than attributes/value objects/enums?

2. A candidate puts `holdSeats` on `Show` and `confirm(hold, payment)` on `BookingService`, then describes BOTH placements as 'Information Expert.' What is the precise correction?

3. The naive `holdSeats` does a check-then-act (read status == Available, then write Held). Which statements about its thread-safety are TRUE? (select all)

4. In the C++ model, `SeatHold` stores `std::vector<Seat*>` pointing into `Show`'s `std::unordered_map<std::string, Seat>`. Why is this acceptable as written, and when would it break?

5. In the Stage 4 UML, the relationship between Movie and Show is drawn `Movie ───────────────◇ Show`. Why does the hollow aggregation diamond belong on the Show end, and how does this contrast with the `Screen ◆── Seat` composition?

Design problem 6

Stage 3 surfaced the question 'who unlocks a seat if the user never pays?' Design the seat-hold lifecycle so a held-but-unpaid seat reliably returns to Available, and specify how you'd enforce no-double-booking in (a) this in-memory single-process model and (b) a real distributed system. Give the key method signatures/owners and the expiry mechanism.