InterviewPrepKit

Home / Learn / Object-Oriented Design

02 — A Framework For The OOD Interview

“Design a parking lot. You have forty-five minutes.”

An object-oriented design (OOD) interview rewards procedure over inspiration: six steps, run minute by minute, from the opening question to the extension you volunteer at minute 40.

By the end you will be able to run the whole forty-five minutes without improvising the order:

Everything is defined here. No other chapter is required to follow it.

The one sentence the chapter turns on

A design is a bet about what will change. The framework below is a procedure for finding that bet before you write a class, so that the interviewer’s late requirement lands on an interface instead of on a switch statement.

Four terms in that sentence do all the work. Fix them now, because every later section leans on them.

What goes in and what comes out

In: one underspecified sentence of prompt, plus whatever the interviewer tells you when you ask.

Out: three artifacts.

  1. A class diagram — one box per class listing its data and its operations, with lines showing which class knows about which.
  2. 60-150 lines of code that runs.
  3. A priced answer to a requirement change: what is new, what is untouched, what it cost.

Where this chapter sits

This chapter is the method. Two neighbours fill in around it.

03 is the same vocabulary in depth, derived from running code:

Every term this chapter uses is glossed here as it appears, so you can read the two in either order. 04 and the worked problems after it apply the method without re-deriving it.


The six steps and the clock

The whole round fits in one picture and one table: what happens in each block, how many minutes it gets, and what the interviewer is scoring while it happens.

The flowchart below runs top to bottom in clock order. Each box holds four things: the step number, its minute budget, its position on the forty-five-minute clock, and the one thing that step produces.

flowchart TD
    S0["0 · Clarify and scope<br/>5 min · 0-5<br/>actors · invariant · what varies"] --> S1
    S1["1 · Actors and use cases<br/>5 min · 5-10<br/>verbs before nouns"] --> S2
    S2["2 · Objects and relationships<br/>10 min · 10-20<br/>noun triage · cardinalities"] --> S3
    S3["3 · The 2-3 decisions<br/>5 min · 20-25<br/>axis of change -> pattern -> cost"] --> S4
    S4["4 · Code the core<br/>15 min · 25-40<br/>interface + the one real method"] --> S5
    S5["5 · Extend<br/>5 min · 40-45<br/>now add X · which files stay untouched"]

Here is what each box means, with the three pieces of jargon it uses defined on the spot.

The table below is the same six steps with the clock made explicit. The column to read hardest is the last one — it is what the interviewer is writing down while you talk.

StepMinClockSub-budgetWhat is scored
0 Clarify50-52 min questions, 1 min assumptions, 2 min scope-outWhether an answer would change a class boundary
1 Use cases55-102 min actors, 3 min the operation tableWhether you found the verbs before the nouns
2 Objects1010-205 min triage, 5 min diagramThe demotions: which nouns did not become classes
3 Decisions520-252 min each, plus the cost sentencePattern tied to a named change, with its price
4 Code1525-403 min enums and value objects, 3 min the interface, 6 min the core class, 3 min assertsWhether it runs
5 Extend540-452 min per scenarioFiles new vs files edited

Step 4 is a third of the clock, so steps 0-3 are setup for it. The most common pacing failure in this round is a beautiful twenty-five-minute diagram followed by fifteen minutes of panic typing. The second most common is the inverse: typing at minute three, with no idea yet what varies, producing a class that has to be deleted at minute twenty.


Step 0 — Clarify and scope (5 min)

This step buys the information that places every interface you will write, then closes the scope so the remaining forty minutes are spent on things that are scored. It has three parts on its own clock: two minutes of questions, one minute stating assumptions, two minutes scoping out.

The six questions worth asking

Ask questions whose answers move a boundary. A question whose answer does not change a class, a cardinality, or an invariant is a question you paid a minute for and got nothing back.

These are the six that pay. The right-hand column is the point of the table: it names the specific fork in your design that each answer decides, so you can see why the question is worth a minute of a forty-five-minute round.

#QuestionThe fork it creates
1Who acts on this, including the non-humans?The clock, a sensor, and a maintenance job are actors. Each one you miss is an operation you will not model
2What is the one thing that must never be wrong?Your invariant. It tells you which object must own the mutation, and where the lock goes
3What varies, and how often?Quarterly-changing rules get an interface; never-changing rules get an if
4How many X per Y, and can it be zero?1 vs 0..1 is a null check; 1 vs 0..* is a collection and a new class
5Does this thing have states, and who may change them?States plus illegal transitions means an enum plus a guarded method, or the State pattern
6One process, or is persistence and concurrency in scope?Decides whether you write a lock and a repository. The recommended default keeps concurrency in — two entrances racing for the last spot is one of the three decisions step 3 ranks as real — and scopes persistence out. Dropping concurrency too buys back about ten minutes, and is the trade you make only when you are already behind at minute 25

Seven terms appear in that table and are used everywhere after it. Definitions, shortest first:

The one question that pays for the round

Question 3 — what varies, and how often — is the one that pays for the whole round. Say it explicitly:

“Which of these rules do you expect to change after launch? I want to put the interface exactly there and nowhere else.”

That single question converts a guess into a stated requirement, and it makes every pattern you introduce later defensible by citing the interviewer’s own answer.

The five assumptions every object model rests on

A class diagram is not a picture of the world. It is a picture of what you assumed would vary and what you assumed would hold still, and it is only correct relative to those assumptions. Every object model contains the same five, whether you say them out loud or not, and each comes with a default so that you are never blocked waiting for an answer.

The five are the same in every OOD prompt, whatever the domain. The defaults below are written for the parking lot so they stay concrete; substitute your own nouns.

The rows are lettered A through E and referred to by letter for the rest of the chapter. The last column is the sentence to say out loud when the interviewer does not volunteer an answer.

AssumptionWhat it means in plain wordsThe default to state if nobody tells you
AWhat variesWhich rules the business expects to rewrite after launchFees change; the rule matching a vehicle to a spot does not
BWhat is closedWhich lists of kinds are finished and will never gain a memberThree vehicle sizes: motorcycle, compact, large
CThe invariant and its ownerThe one fact that must never be false, and the single object allowed to break or restore itOne vehicle per spot, and the lot owns the assignment
DMultiplicityHow many of each thing there are, and whether zero or many is legalOne lot · one-or-more levels · one-or-more spots · at most one open ticket per vehicle
EThe boundaryWhat is deliberately out — stored data that outlives the process, payment, authentication (checking who the user is), more than one machine — and, for contrast, the one hard thing you are deliberately keeping inOut: persistence, payment, auth, distribution. In: concurrency, because two entrances can race and the lock is scored

Three of those rows deserve more than a table cell.

A is the assumption the whole design is a bet on, so state it in the form of a bet.

“I am betting the fee rule changes and the fit rule does not, so the fee goes behind an interface and the fit is a method.”

Said that way, the interviewer can correct you in one sentence at minute four, instead of watching you defend the wrong interface at minute thirty-five.

B is the assumption that decides enum versus registry. A registry is a lookup table that maps a name to the thing to use, filled in at startup or at runtime rather than written into the code.

The two branches are not close to each other:

Those two designs share almost no classes, which is why B is worth a sentence out loud rather than a silent guess.

C is the assumption that decides both your field types and where the lock goes.

Which assumptions are load-bearing

An assumption is load-bearing when being wrong about it does not cost you a correction — it costs you the class structure. With ninety seconds of question time and five assumptions on the card, you need a one-line test for telling the load-bearing ones apart from the ones you can simply declare.

The test: flip the assumption to its opposite and ask what the repair costs. If the repair is a method body, state it and keep going. If the repair is a different set of classes, stop and ask.

Run the test on all five. Each row takes one assumption, states its opposite, and works out what the repair would actually cost you. Row D appears twice because flipping it in two different directions gives two different answers.

AssumptionFlip itWhat actually changesLoad-bearing?
A What variesFees are fixed forever; the matching rule changes per siteThe interface is in the wrong place. Every extension question now lands on a method inside ParkingLot instead of on a new class, and the fee interface you did write is dead weightYes — the most load-bearing assumption in the round
B What is closedOperators define their own vehicle categories at runtimeThe enum becomes a catalogue object, the fit rule becomes data rather than code, and a class appears that did not exist in the closed designYes
C Invariant and ownerA spot may hold two motorcycles; the attendant may also assignOccupancy goes from a boolean to a count, is_free() changes shape, and a second writer means the lock moves to whatever both writers shareYes
D Multiplicity3 entrances become 300The same classes and a longer list. Nothing movesNo. State it and move on
D again, flipped the other wayAt most one open ticket per vehicle becomes manyVehicle gains a collection, and “which ticket does this exit close” becomes a real decision needing an ownerOnly across the 0/1/many boundaries — which is why D gets two rows, not because there is a sixth assumption
E The boundaryPersistence is in scope after allA repository interface appears, and the core class must stop constructing its own storageYes, but it is yours to declare, not to ask

Three of the five decide the class structure rather than a method body: A what varies, B which lists are closed, and C the invariant together with its owner.

The other two are cheaper. D moves the code only when it crosses the zero-or-one-or-many boundaries. E is a decision you announce rather than a fact you discover.

That ranking is what makes the next subsection executable when you have two minutes of question time and six things you would like to know.

Here is the failure the ranking prevents. You spend your whole question budget on “how many levels are there?” — the least load-bearing question available — and then discover at minute thirty-five that pricing was the thing the interviewer intended to change. That is a fork you could have found by asking A in ten seconds.

Scope out loud, then move

You will not get answers to all six questions and you should not try; the skill is knowing what to ask in priority order, what to declare without asking, and the exact sentence that buys you the right to proceed.

Ask in this order and stop when the clock says stop. With one question, ask A. With two, add C. With three, add B. Declare D and E yourself — they are yours to set, and no interviewer faults a stated default.

The table is the same instruction as a lookup. Find the row for however much question time you actually have left, ask what is in the middle column, and state the rest.

If you haveAskDeclare
1 questionA: which rules do you expect to change after launchB, C, D, E
2 questionsA, then C: what must never be wrong, and who is allowed to change itB, D, E
3 questionsA, C, then B: is that list of kinds finished, or can operators add oneD, E

Then write the card below in the corner of the editor and leave it there for the rest of the round.

It is five lines, one per assumption. Each line carries the letter, a short name, the value you settled on, and whether you ASKED for it or STATED it yourself. Every later decision points back at a row, and when the interviewer corrects one you change that row visibly instead of quietly re-deriving.

A  varies      fee rules change; the fit rule does not      ASKED   <- LOAD-BEARING
B  closed      3 sizes: motorcycle, compact, large          ASKED   <- LOAD-BEARING
C  invariant   one vehicle per spot; ParkingLot owns it     ASKED   <- LOAD-BEARING
D  counts      1 lot · 1..* levels · 1..* spots · 0..1 open ticket   STATED
E  boundary    in memory · 1 process · concurrency in scope · no payment, no auth   STATED

Two bits of shorthand on that card. The 1..* and 0..1 on row D are cardinality notation, read as “one or more” and “at most one”. Row E’s no auth is short for no authentication, meaning you are not modelling who the user is.

Then fix the rest yourself, in one breath, and name the ones you would revisit:

“I am building this in memory, single process, with concurrency in scope because two entrances can race. No persistence, no payment gateway, no auth. Vehicles are cars, motorcycles and trucks; fees are hourly. If persistence matters, the change is a repository interface behind the lot, and I will point at where it goes rather than write it. Tell me if any of that is wrong.”

“I will point at where it goes rather than write it” is the sentence that buys you the right to stub things. Without it, every stub reads as an omission.

When a correction lands, say which row moved and which classes move with it.

Suppose the interviewer says operators define their own vehicle categories. That is row B being flipped. The honest response is not to nod:

“Then Size stops being an enum, the fit rule reads a catalogue instead of a match, and I need thirty seconds to redraw.”

A load-bearing assumption corrected out loud is a recovery. The same correction absorbed silently is a design that no longer matches its own diagram.


Step 1 — Actors and use cases (5 min)

With scope fixed, the temptation is to start naming classes. Resist it: this step lists the operations before the objects. Two minutes on actors, three on the table below.

Write the verbs down before you write any nouns. Every operation you forget in step 1 is a class you will not discover in step 2, and adding it at minute 35 is the failure mode that looks like a design flaw even when it is a memory flaw.

The whole step fits in one table of four columns, filled in here for the parking lot. Two of the columns are doing design work, not documentation:

ActorOperationPreconditionChanges what
Driverpark a vehiclea free spot fits this vehiclespot occupancy, a new ticket
Driverpay and exitticket exists and is unpaidticket state, spot occupancy
Attendantclose a spot for maintenancespot is freespot state
Display boardshow free counts per levelnonenothing (read-only)
Clockexpire an unclaimed reservationreservation is past its hold windowreservation state, spot state

Two rows in that table are the ones candidates miss, and both are worth pointing at deliberately.

The first is the non-human actor. A clock or a scheduled job starts operations just as a driver does, and if nothing in your model can be triggered by the passage of time then you have no expiry, no timeout, and no way to answer “what if they never come back for the car”.

The second is the read-only actor — the display board row. It changes nothing, which means it belongs on the outside of your core objects.

That has a concrete consequence: do not put notify_display() calls inside ParkingLot.park(). If the interviewer later asks for live updates, the board is the natural home for the Observer pattern, where interested objects register to be told when something changed, so the thing that changed never has to know who is listening.

What interviewers probe: whether your operations have preconditions. “Park a vehicle” is a feature. “Park a vehicle, given a free spot that fits it, otherwise reject” is a design, because the second half is the branch that needs an owner.


Step 2 — Objects and relationships (10 min)

This step turns the verbs into a diagram: five minutes deciding which nouns deserve to be classes, five minutes drawing the lines between them and labelling what each line claims.

The noun triage, and the trap

Underline the nouns, then refuse most of them. The standard advice — “nouns become classes” — produces a design with twenty classes, fifteen of which have one field and no methods, and it is the single most reliable way to look junior.

A noun earns a class only if it passes all three tests below. Ask the middle column about the noun; if the answer is no, the right-hand column tells you what the noun becomes instead.

TestQuestionIf it fails
IdentityAre two of these different even when their fields are equal?It is a value object (@dataclass(frozen=True)) or an attribute
StateDoes it change over its lifetime?It is a constant, an enum member, or a value object
BehaviourDoes it own a decision others depend on?It is data: a field on whoever owns the decision

The identity test needs three terms defined before it means anything.

A class, by contrast, has identity: two tickets with identical fields are still two different tickets, because one of them is yours.

Now run the three tests on the parking-lot prompt. The verdict column is the answer, and the “why” column names which test decided it. 04 is the full version of this same problem.

NounVerdictWhy
Parking lotclassIdentity, state, and it owns the assignment decision
LevelclassIdentity and state; it owns “which of my spots is free”
SpotclassIdentity and mutable occupancy
TicketclassHas a lifecycle: issued, paid, closed
VehicleclassIdentity (a plate), and it answers “what size am I”
SizeenumA closed set of three, compared by value, no behaviour of its own
Rate, feenot a class; a strategyIt is a rule, not a thing. See step 3
Colour, plate, floor numberattributeNo identity, no behaviour
Moneyvalue objectTwo amounts of 5.00 are the same amount. Frozen, with arithmetic
Entrance, exitattribute or enum, at firstBecomes a class only if it holds state, like a gate or a queue

Two moves in that table are worth saying out loud, because they are where the signal is.

The demotion

A demotion is a noun you refuse to make a class. Size is the one to narrate:

Size is an enum, not a class hierarchy. If I made CompactSpot, LargeSpot and MotorcycleSpot subclasses, then does this vehicle fit this spot becomes a decision spread across three classes, and adding an EV bay means a fourth subclass plus edits everywhere the fit is computed. As an enum plus one fit-rule object, adding EV is one enum member and one line in the rule.”

The promotion

A promotion is the opposite move: the most valuable class in an OOD answer is often a noun that was not in the prompt, because it is a verb that grew state. Reservation, Payment, Assignment, MaintenanceWindow.

When a use case has a precondition, a duration, or a cancellation, the thing in the middle wants to be an object. “Reserve a spot for fifteen minutes” has all three, which is why Reservation is a class and not a timestamp field on Spot.

Naming one unprompted is one of the cheapest strong signals available in this round.

Relationships, and what the arrows claim

Every line you draw is a claim someone could prove false, and there are four kinds of line.

The table gives the notation used by mermaid class diagrams — the same symbols as UML, the Unified Modeling Language, which is the standard drawing notation for object models. The column that matters most is the last one: it is the yes/no question that tells you which of the four arrows you should have drawn.

ArrowMermaidMeansThe test
Composition*--The part cannot exist without the whole and dies with itIf I delete the whole, do I delete the part?
Aggregationo--The whole holds parts it does not ownIf I delete the whole, does the part survive elsewhere?
Association-->Holds a reference, no ownership claimCan I replace the target without telling the source?
Inheritance<|--Substitutable is-aCan a caller hold the base and never notice the subtype? See 03

The last row is the Liskov substitution principle in one line: if Truck inherits from Vehicle, then every place that works with a Vehicle must keep working when handed a Truck, with no special case. When it cannot, the relationship was never is-a.

The fix in that case is to switch mechanisms:

Composition is the default and inheritance is the one that needs an argument, for exactly that reason.

The diagram below is three of the four arrows on a deliberately small example — a customer, an order, its lines, and the products they point at. Ignore the class contents for a moment and look only at the three connecting lines: each one uses a different arrow, and each arrow is a different claim about what happens when you delete something.

classDiagram
    class Customer {
        +place(Order) void
    }
    class Order {
        +total() Money
        +cancel() void
    }
    class OrderLine {
        +qty int
    }
    class Product {
        +sku str
    }

    Customer "1" o-- "0..*" Order : aggregation
    Order "1" *-- "1..*" OrderLine : composition
    OrderLine "0..*" --> "1" Product : association

Each box is a class with its operations listed inside, and + marks what is public. place, total, cancel, qty and sku are the operations and fields those four classes expose, where sku is the stock-keeping unit, the catalogue’s identifier for a product. The quoted numbers on the line ends are the cardinalities.

Now the three arrows, each stated as a claim a reviewer could prove false:

Cardinalities are not decoration; each one is a branch in the code. 0..1 is a null check on every read. 1..* means the constructor must reject an empty list. 0..* means a collection, and a collection means somebody has to answer “in what order” and “can it contain duplicates”.

One direction rule that saves you a follow-up: a bidirectional link is two invariants, not one. If Spot knows its Vehicle and Vehicle knows its Spot, then every assignment must update both, and every failure path must unwind both. Pick one owner unless the second direction earns its keep.


Step 3 — The two or three decisions that actually matter (5 min)

This step is where you rank: find the two or three decisions that are real, name the pattern each one implies, and price it before the interviewer asks.

Most of a design is bookkeeping. Two or three things are real, and they are the ones that sit on an axis of change.

An axis of change is a dimension where you already expect a new case to arrive. “A new fee rule every quarter” is an axis. “A new kind of vehicle next year” is an axis. “The lot might have more levels” is not — that is the same code with a longer list.

The test to apply before you introduce any abstraction at all:

Name the second implementation. Out loud. If you cannot, do not write the interface.

That test is the cheapest filter available against pattern-dropping — the failure mode below — because an abstraction you cannot name a second implementation for has, by definition, nothing to abstract over. It also justifies the patterns that survive, because the second implementation you named is usually the interviewer’s own extension question.

The table maps axes of change to patterns. Go in from the left: recognise the axis, ask the question in column two to confirm it is real, and the pattern in column three is what you reach for. Never read column three without reading column four — the cost is the half of the sentence that makes you sound like you have maintained one of these.

Axis of changeQuestion that reveals itPattern it impliesWhat it costs
A rule that varies by policy“Does pricing change per site or per season?”StrategyIndirection: the flow now spans two files
An object that behaves differently per lifecycle phase“What can you do to a cancelled one?”StateMore classes than the enum-plus-guard version
Choosing a subtype from input“What decides which kind gets created?”FactoryOne more place to update when a type is added
Something wants to know when something else changed“Does the board update live?”ObserverOrdering and failure semantics of listeners
Requests that must be queued, logged, or undone“Can they cancel a submitted request?”CommandEvery action becomes an object to construct
Global access to one instanceusually nothingSingletonAlmost always wrong. 03

Six patterns are named in that table; the State pattern was already defined back in step 0, and one sentence each is enough for the rest:

The announcement script

At minute 20, say your ranking out loud. This is the moment you find out whether it matches the interviewer’s:

“There are three decisions I think are real here: how a vehicle is matched to a spot, how the fee is computed, and what happens when two entrances race for the last spot. The first two are both rules I expect to change, so both are strategies; the third is a correctness problem and I want to show the interleaving. Everything else is bookkeeping. Which do you want first?”

An interleaving, in that script, is one specific ordering in which two threads’ steps could land — the thing you have to show before a lock means anything.

Naming what is not interesting is half of that script’s value. It tells the interviewer you can rank, and it protects the fifteen minutes you need for code.


Step 4 — Code the core (15 min)

This is a third of the round and the only block scored on whether something executes. Surviving it comes down to three habits: a fixed order to type in, four standard stubs with the sentence that makes each acceptable, and one skeleton — every worked chapter in this track is a copy of it.

The order to write it in

Type in the order below. The reason for this particular order is that each layer compiles and is testable before the next one exists, so being interrupted never leaves you with nothing that runs.

OrderWhatMinutesWhy here
1Enums and constants2Fastest way to make the vocabulary concrete and visible
2Value objects (frozen=True dataclasses)1No behaviour, no risk, and they type the signatures below
3The interface(s) from step 3, plus one implementation each3The design lives here; write it before the class that uses it
4The one core class and its one real method6This is the method the whole round is about
5A main with asserts3Proof. Also your demo script

A main is the entry point that runs when the file is executed, and an assert is a line that states an expected result and crashes if it is false — which is how a forty-five-minute design proves itself without a test framework.

What to stub, and how to say it

To stub something is to declare its interface, give it the simplest possible implementation, and say out loud what the real one would be. Done with the sentence attached, it reads as scoping. Done silently, it reads as an omission.

Four things are worth stubbing in almost every OOD round. The right-hand column is the sentence to say while you type the stub.

StubThe one-liner
Persistence“A Repository protocol with an in-memory dict implementation; the SQL one is the same three methods”
Payment“A PaymentProcessor protocol that returns True; the real one is a network call and a retry policy”
Notification“A listener list. If they want fan-out semantics I will make it Observer”
Timenow() is injected, not datetime.now() called inline, so tests can move the clock”

Five terms from those four rows:

The last row is worth doing even when nothing asks for it. Injecting the clock costs one parameter, and it is the difference between a testable design and one where you cannot write the expiry test you are about to be asked for.

The skeleton

Below is the shape of a step-4 answer, written with deliberately generic names — Core, Rule, Threshold — so that it is obviously a template rather than one problem’s solution. It runs as written; the asserts at the bottom are the proof.

Read it in the same five layers the ordering table just gave you:

  1. Status — the enum. The closed set of lifecycle phases.
  2. Money — the frozen value object, with a constructor that enforces the promise its docstring makes.
  3. Rule and Threshold — the interface from step 3, plus exactly one implementation.
  4. Core — the one class with the one real method, submit. Its collaborators (rule, now) are handed in, not constructed inside.
  5. The asserts — the main.

The two things to watch for are the ones most skeletons leave out: the injected clock now is actually read inside submit, and close() exists so that Status.CLOSED is a state something can reach.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Callable


class Status(Enum):
    OPEN = "open"
    CLOSED = "closed"


@dataclass(frozen=True)
class Money:
    """Value object: non-negative integer minor units, so 0.1 + 0.2 is not a bug.

    The docstring is a promise, so the constructor keeps it. Without the
    check, Money(1.5) and Money(-500) both construct happily and the
    "integer minor units" claim is decoration.
    """
    cents: int

    def __post_init__(self) -> None:
        if not isinstance(self.cents, int) or isinstance(self.cents, bool):
            raise TypeError("cents must be an int -- minor units, not dollars")
        if self.cents < 0:
            raise ValueError("cents must be non-negative")

    def __add__(self, other: "Money") -> "Money":
        return Money(self.cents + other.cents)


class Rule(ABC):
    """The axis of change. One method, because it is a rule and not a thing."""

    @abstractmethod
    def applies(self, x: int) -> bool: ...


class Threshold(Rule):
    def __init__(self, limit: int) -> None:
        self.limit = limit

    def applies(self, x: int) -> bool:
        return x >= self.limit


class Core:
    """The one class with the one real method. Collaborators are injected."""

    def __init__(self, rule: Rule, now: Callable[[], int], deadline: int) -> None:
        self._rule = rule
        self._now = now
        self._deadline = deadline
        self._status = Status.OPEN

    @property
    def status(self) -> Status:
        return self._status

    def close(self) -> None:
        """The transition. Without it CLOSED is a state nothing can reach."""
        self._status = Status.CLOSED

    def submit(self, x: int) -> Money:
        if self._now() > self._deadline:      # the injected clock, actually read
            self.close()
        if self._status is Status.CLOSED:
            raise ValueError("closed")
        return Money(100) if self._rule.applies(x) else Money(0)


clock = iter([10, 20, 30])
c = Core(rule=Threshold(limit=5), now=lambda: next(clock), deadline=25)
assert c.submit(7) == Money(100)              # t=10, open
assert c.submit(1) == Money(0)                # t=20, open
try:                                          # t=30, past the deadline
    c.submit(7)
    raise SystemExit("should not reach here")
except ValueError:
    pass
assert c.status is Status.CLOSED              # the expiry test the clock buys
assert Money(100) + Money(50) == Money(150)
print("core ok")

Three details in that code that a first reading skates past:

That skeleton is domain-free on purpose: every worked chapter in this track is that shape with the names filled in. In its eighty-odd lines it demonstrates five things worth demonstrating.

  1. An enum for a closed set.
  2. A frozen value object whose constructor enforces the promise its docstring makes.
  3. One abstract rule with one implementation.
  4. An injected clock that is actually read on the one real method, so the expiry path is testable.
  5. Asserts that state behaviour, rather than comments that describe it.

The clock and the close() transition are the two pieces most skeletons omit. Omit either one and Status.CLOSED becomes a state nothing in the class can reach.

To see what “filled in” means, here is each layer of the skeleton next to the parking-lot class that plays its part in 04. Nothing about the shape changes; only the names do.

In the skeletonIn the parking lotWhy it is that layer
Status enumVehicleSize (motorcycle, compact, large)Assumption B: a closed set of kinds
Money frozen value objectthe fee, as an integer count of centsNo identity, compared by value, never a float
Rule abstract classFeeModelAssumption A: the thing you bet would change
Threshold implementationHourlyFeeThe one implementation you actually write
Core with submit()ParkingLot with park()Assumption C: the class that owns the invariant
The injected nowthe clock handed to ParkingLotThe non-human actor from step 1, injected so tests can move it

Narrate while typing

Silence while coding is the same defect as silence while whiteboarding. (The design interview playbook has its own list of what not to say; this chapter’s is below.)

One sentence per block is enough. The shape is what I am writing, then why:

“I am making Money a frozen dataclass with integer cents, because float money is a bug and because value equality is what I want when I assert on it.”


Step 5 — Extend (5 min)

This is the block the design was for. The interviewer has a change prepared; you should get there first, with a three-sentence answer and a file count that makes the answer checkable.

The drill for each scenario is three sentences, always the same shape:

  1. What is new — “one new class, EvFeeModel.”
  2. What is untouched, and why — “ParkingLot, Level, Spot and Ticket are untouched, because none of them ever asks what kind of fee this is.”
  3. What it costs — “reading the pricing flow now means opening the registry and the model, and a typo in the registry key is a runtime failure rather than a compile error.”

The registry in that third sentence is the lookup table introduced back in step 0, here mapping a fee name to the implementation to use.

The cost named there is real, and worth understanding rather than reciting. A mistyped registry key is not caught until the line runs, whereas calling a method that does not exist on a concrete class is caught earlier. You traded a compile-time error for a runtime one, and you should say so.

Then give the diff as file counts, because that is the only metric this round has. Each row is one requirement change, priced twice: once against the design this chapter tells you to build, once against the design you get by default.

ChangeDesign with the interfaceDesign with a switch statement
Add a fee rule1 new file, 1 registry lineEdit the fee method, re-test everything that calls it
Add a vehicle size1 enum member, 1 line in the fit ruleEdit every if size == chain, and you will miss one
Add a per-site overrideNew composite strategy, 0 editsNested conditionals, the method stops fitting on a screen

A composite strategy in the last row is one strategy object that holds others and combines their answers — a site override wrapping the base fee rule — which is how a second dimension of variation is added without multiplying classes.

If your honest answer to a scenario is “that requires editing five files,” say so. A candidate who diagnoses their own design’s weakness scores above one who claims every change is easy, because the first one has clearly maintained something and the second one has not.


All six steps on one problem

Each step above was demonstrated on the parking lot separately. Run in clock order on that one problem, they sound like this — nothing new, just the whole forty-five minutes end to end. 04 is this same walkthrough at full length, with the code.

Minute 0-5, step 0. Ask A: “which of these rules do you expect to change after launch?” The answer is fees. Ask C: “what must never be wrong?” One vehicle per spot. Declare the rest: three vehicle sizes, one lot with one-or-more levels, in memory, single process, concurrency in scope, no payment and no auth. Write the five-row card in the corner of the editor.

Minute 5-10, step 1. Verbs, with preconditions: a driver parks (given a free spot that fits), a driver pays and exits (given an unpaid ticket), an attendant closes a spot (given it is free), a display board reads free counts, and a clock expires reservations. The clock and the board are the two rows candidates forget.

Minute 10-20, step 2. Triage the nouns. ParkingLot, Level, Spot, Ticket and Vehicle are classes. Size is demoted to an enum. Fee is not a thing at all, it is a rule. Colour and plate are attributes. Draw the boxes and put cardinalities on every line: ParkingLot *-- Level because deleting the lot deletes its levels, Level *-- Spot for the same reason.

Minute 20-25, step 3. Announce the ranking: how a vehicle is matched to a spot, how the fee is computed, and what happens when two entrances race for the last spot. The first two are rules you expect to change, so both are strategies. The third is a correctness problem, so you show the interleaving and then the lock. Everything else is bookkeeping — say that too.

Minute 25-40, step 4. Type in layer order: the VehicleSize enum, the fee as integer cents, the FeeModel interface with HourlyFee behind it, then ParkingLot.park() holding the lock across find-and-claim, then asserts. Inject the clock. Narrate one sentence per block.

Minute 40-45, step 5. Volunteer the extension before it is asked: “add EV charging bays.” Say it in the three-sentence shape. New: enum members for the EV kind, a row in the fit rule, and one fee model. Untouched: Level, Spot and Ticket, because none of them ever asks what kind of fee this is. Cost: the pricing flow now spans two files, and a missing registry key fails at exit time rather than at compile time.

That is the round. If you can say those six paragraphs about a problem you have never seen, you can run this framework out loud.


The four failure modes

Running the steps is half the skill; the other half is not producing one of the four designs that fail this round, listed here in the order interviewers see them. Each one is a rule from the SOLID list broken in a way that shows up as a symptom you can catch in yourself while you are drawing.

SOLID is a five-item checklist for class design. One line each is enough here; 03 derives all five from running code.

The four failure modes below map onto that list: the first is S broken, the second is S broken the other way, the third is O and I anticipated too eagerly, and the fourth is O invoked with no change to justify it.

1. The God object

One class holds every responsibility: it assigns spots, computes fees, prints tickets, and talks to the payment gateway.

The tells:

The fix is not “split it into three files.” It is to ask, per method, what does this method need to know about?

In the parking lot, that question sorts cleanly. Methods that need the spot grid stay on ParkingLot. Methods that need only a duration and a rate leave together, and take their data with them — that departing group is the fee model.

That question is cohesion made operational — how well the things inside one class belong together — and the split is right when each resulting class has one reason to change.

2. The anemic domain model

The opposite failure, and the more common one among people who write a lot of services: classes are bags of public fields, and all logic lives in a Service that reaches into them. Anemic here means the objects hold data but own no decisions, so the rules that protect that data live somewhere else entirely.

The code below is the anemic version, and it runs. Order holds a status and nothing else; OrderService.cancel holds the rule “a shipped order cannot be cancelled”. Watch the last four lines: the guarded path correctly rejects the cancel, and then a plain assignment does the exact same thing with nothing to stop it.

from dataclasses import dataclass, field


@dataclass
class Order:                      # anemic: no behaviour, no protection
    status: str = "new"
    lines: list = field(default_factory=list)


class OrderService:
    def cancel(self, order: Order) -> None:
        if order.status == "shipped":
            raise ValueError("cannot cancel a shipped order")
        order.status = "cancelled"


o = Order(status="shipped")
try:
    OrderService().cancel(o)                    # the guarded path rejects it
    raise SystemExit("unreachable")
except ValueError:
    pass

o.status = "cancelled"                          # ...and nothing guards this one
assert o.status == "cancelled"                  # invariant broken, no error raised
print("anemic model: the rule was bypassed by a plain assignment")

The rule “a shipped order cannot be cancelled” lives in the service, so it is enforced only for callers who happen to use the service. Every new call site is a new chance to bypass it, and there is no compiler check that they did not. The last two lines prove it: a plain assignment walks straight past the guard, and the invariant is now false with nothing raised.

The rich version is next. Two changes: the status becomes an enum instead of a string, and cancel moves onto Order itself, next to the field it protects. The @property gives readers order.status with no setter behind it.

from dataclasses import dataclass, field
from enum import Enum


class OrderStatus(Enum):
    NEW = "new"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"


@dataclass
class Order:
    _status: OrderStatus = OrderStatus.NEW
    lines: list = field(default_factory=list)

    @property
    def status(self) -> OrderStatus:
        return self._status

    def cancel(self) -> None:
        if self._status is OrderStatus.SHIPPED:
            raise ValueError("cannot cancel a shipped order")
        self._status = OrderStatus.CANCELLED


o = Order(_status=OrderStatus.SHIPPED)
try:
    o.cancel()
    raise SystemExit("should not reach here")
except ValueError:
    pass
assert o.status is OrderStatus.SHIPPED          # one owner; see the probe below for the limit
print("rich model: the transition is guarded where the state lives")

The second version renames the field to _status to mark it internal and exposes a read-only @property called status.

The transition rule and the state it guards now live in the same class, so there is exactly one place the rule is written. That is a different claim from “the field cannot be assigned from outside”, which would be false. Be precise about which one you are making, because the difference is what an interviewer will push on.

Python enforces nothing here. o.status = ... does raise, because a property with no setter has none — but that is the only route the property closes.

The block below proves it. probe runs each attempt and prints BLOCKED if it raised and OPEN if it did not. Six routes to the field are tried; exactly one is blocked. Note that the third route, the constructor, is the one the demo line in the previous block already used, and the sixth puts a string where an enum member belongs.

import dataclasses


def probe(label, attempt) -> None:
    try:
        attempt()
        print(f"OPEN     {label}")
    except Exception as exc:
        print(f"BLOCKED  {label}  ({type(exc).__name__})")


probe("o.status = ...",                lambda: setattr(o, "status", OrderStatus.CANCELLED))
probe("o._status = ...",               lambda: setattr(o, "_status", OrderStatus.CANCELLED))
probe("Order(_status=SHIPPED)",        lambda: Order(_status=OrderStatus.SHIPPED))
probe("dataclasses.replace(o, ...)",   lambda: dataclasses.replace(o, _status=OrderStatus.NEW))
probe("o.__dict__['_status'] = ...",   lambda: o.__dict__.update(_status=OrderStatus.NEW))
probe("Order(_status='banana')",       lambda: Order(_status="banana"))

# Only the first is blocked, and the last one is not even a member of the enum.
assert Order(_status="banana").status == "banana"

Three reasons the other five stay open:

What the read-only property actually buys is not a locked door. It is a single reviewable place where the rule lives, plus a bypass that visibly looks like a bypass. o._status = ... in a diff is a reviewer’s flag in a way that o.status = ... on the anemic version never was.

That is a real improvement over the anemic model, where the guard lived in a different class from the data and there was no naming signal at all. It is not the same as making the field private.

You can harden it further — a __setattr__ override, __slots__, object.__setattr__ inside a validating constructor — but a forty-five-minute answer should not carry that machinery, and it still would not close the constructor.

Say the limit out loud instead:

“The property is the one place the rule is written, and _status is convention, not enforcement. If I needed real enforcement I would validate in __post_init__ and make the field private by name-mangling, and I would still not call it airtight.”

A candidate who knows the limit of their own enforcement scores above one who claims the field is private, because the second claim is checkable in one line and it is wrong.

This is also the whole content of “tell, don’t ask” — call a method that does the work rather than reading an object’s fields and deciding on its behalf — stated as a consequence rather than as a slogan.

3. Premature interfaces

IVehicle, IParkingLot, ISpotRepository, each with exactly one implementation, written before anything needed to vary. The leading I is a naming convention for “this is an interface”, and three of them in a design with no second implementation is the tell.

The cost is real and specific, and it is two costs, not one.

An abstraction extracted from two real cases fits both. One invented from zero cases fits neither.

The rule is the step-3 test: name the second implementation, or do not write the interface. Apply it out loud to your own design at least once during the round.

4. Patterns for their own sake

The Visitor nobody needed. The Factory that wraps a constructor. The Builder for a three-field object. The Observer with one listener that is always present.

Two of those need a definition to see the joke. Visitor adds a new operation across a fixed set of classes without editing them, and it earns its weight only when that set really is fixed. Builder assembles an object through a chain of calls, which pays off at ten optional fields and not at three.

The tell: you can describe the pattern but not the requirement change it is for.

The fix is to reverse the direction — state the change first, then the pattern. “Fees change quarterly, so pricing is a strategy” scores. “I would use the Strategy pattern for fees” does not. They are the same design; only one of them shows you know why.

The strongest single move available in this step is the refusal:

“I am not making the spot-assignment order pluggable. There is exactly one policy today — nearest free spot on the lowest level — and nobody has suggested a second. If one appears it is the same shape as the fee strategy and it goes in the same place. An interface I do not need is a signature I will guess wrong.”


When you are behind

Pacing failures are recoverable if you triage on the clock rather than on hope: one checkpoint that matters, a fixed order to cut in, and three things that are never cut.

The flowchart has one diamond and three exits. The diamond is a question you ask yourself at minute 25; the three arrows out of it are the three honest answers. Two of the three lead to the same cut list, and all of them end at the same never-cut list at the bottom.

flowchart TD
    T{"Minute 25:<br/>where are you?"} -->|"still on the diagram"| P1["Emergency: state the objects<br/>in one sentence each, start typing"]
    T -->|"just started coding"| P2["On track"]
    T -->|"no interfaces yet"| P3["Write the one strategy,<br/>hardcode the rest"]

    P1 --> CUT["Cut order:<br/>1 secondary classes<br/>2 the second strategy<br/>3 persistence and stubs<br/>4 the full diagram"]
    P3 --> CUT
    CUT --> KEEP["Never cut:<br/>the core method that runs<br/>one named axis of change<br/>one extension scenario"]

The three branches, spelled out:

The cut order comes next. Cut from the top of this table down, and stop as soon as you are back on the clock. Each row is cheap to lose for the reason given, provided you say the sentence in the right-hand column instead of silently omitting the thing.

Cut firstWhy it is cheap to lose
1. Secondary classesTicket can be a dataclass with three fields and no methods for now. Say so
2. The second strategy implementationOne implementation plus “the EV one is the same shape as Threshold — a constructor and one method — and goes here” gets the same credit
3. Repositories and stubsOne sentence about where they plug in
4. Diagram completenessFour boxes with correct cardinalities beat twelve boxes with none

Never cut: the one core method that actually executes, one named axis of change, and one extension scenario answered concretely. Those three are the round.


Phrases that signal seniority

These are the sentences that make an interviewer stop taking notes and start agreeing. Each one is a claim with a mechanism behind it, which is why it lands.

The left column is lines to actually say; the right column is what the interviewer writes down when you say them.

Say thisBecause it shows
“Which of these rules do you expect to change after launch?”You put interfaces where change is, not where the textbook says
“That noun is an enum, not a class - it has no behaviour of its own.”You triage instead of transcribing
“There is a class hiding in that verb: the reservation.”You find objects the prompt did not name
“Composition here, because deleting the level deletes its spots.”Your diagram makes a claim about lifetime, not about fields
“I am not adding an interface there - I cannot name a second implementation.”Restraint, which is scored
“That change is one new class and zero edits, and it costs me one more indirection.”You price your own patterns
“The invariant is one vehicle per spot, so ParkingLot owns the assignment and the lock.”You know that ownership and concurrency are the same question
“This rule lives on the entity, so the rule is written once instead of once per call site.”You know why anemic models rot
“Let me inject the clock so the expiry test is possible.”You have written tests for time-dependent code
“If they add a third dimension of variation, strategies multiply and I would switch to composition.”You know where your own choice stops working

The entity in the eighth row is the object that owns both a piece of state and the rules protecting it — the rich Order above, rather than the anemic one.

Phrases that hurt

The same content delivered the wrong way round. Each row pairs a sentence that reads as inventory rather than as design with the version that scores. In most rows the two sentences describe the same design — only one of them shows you know why.

AvoidWhySay instead
“I would use Strategy, Factory and Observer.”Patterns as an inventory“One strategy, for fees, because they change quarterly”
“I will add getters and setters.”A public field with extra steps“It is a frozen dataclass; nobody needs to mutate it”
“Make Vehicle the base class of Car, Truck, Motorcycle.”Inheritance for a data difference“Size is an enum field, because the behaviour is identical”
“We would add a lock.”The lock is the easy half“Two entrances read the same free spot, both assign; the lock goes around read-and-claim”
“It is easy to extend.”Unfalsifiable“Adding EV is one enum member and one rule class; here is the diff”
“Everything is an object.”Not a design“Rules are objects; sizes are enums; amounts are values”
“I will use a Singleton for the lot.”Global state in a costume“One instance, constructed in main and passed in. 03
“I will model the tables first.”The database is not the domain“Objects first; persistence is a repository behind an interface”

Getters and setters in the second row are one-line methods that only read or write a single field; a class made of them exposes its data as surely as a public field would, while looking like it does not.


Cheat sheet

Everything above compressed to the move and the reason behind it, in clock order, for the ten minutes before the interview. If a row does not make sense on its own, the section it came from is above.

MomentThe moveThe test behind it
Minute 0Scope out persistence, auth, distribution, unless askedAnything not scored is time stolen from code
First question“What changes after launch?”The answer places every interface you will write
Second question“What must never be wrong?”That is the invariant, and its owner takes the lock
The assumption cardFive rows: varies, closed, invariant, counts, boundaryThree of them decide the classes, not a method body
Use casesVerbs with preconditions, including non-human actorsA missed operation is a missed class
NounsIdentity, state, behaviour - all three or it is not a classMost nouns are enums, values, or fields
The promotionLook for the verb that grew stateReservation, Payment, Assignment
Arrows*-- dies with the whole, o-- survives it, --> just referencesComposition is a claim about deletion
CardinalitiesOn both ends, always0..1 is a null check; 1..* is a constructor guard
Any interfaceName the second implementation firstCannot name it, do not write it
Minute 25Stop drawing, start typingStep 4 is 15 of the 45 minutes
Code orderEnums, values, interface, core class, assertsEvery layer runs before the next exists
Minute 40Volunteer an extension, with the file diffNew files vs edited files is the only metric here
Any patternState the change first, the pattern second“Fees change quarterly, so strategy”

Next: 03 — OOP Fundamentals — the vocabulary this framework assumes, derived through the consequences that make it worth knowing: what breaks without encapsulation, the Liskov violation that fails an assert, SOLID as five violations and their fixes, and the honest answer about Singleton. Then 04 — Parking Lot System runs all six steps end to end.