The OOD Round: How to Approach It
“Design a parking lot.” “Design an elevator system.” “Design a deck of cards.” These are not algorithm questions and they are not system design questions — there is no complexity to optimise and no load balancer to draw. They are low-level design: can you turn an ambiguous sentence into classes with clear responsibilities, and then defend the boundaries you chose?
The round is standard at Bloomberg and common at Amazon, and it is the one most candidates prepare for least — partly because there is no LeetCode for it, which is also why this phase has no problem ladder.
The cue
Section titled “The cue”When it is not this. If the prompt names a data structure or an operation with a cost target — “design an LRU cache”, “design a hit counter with memory” — that is Phase 14, and the answer is a data-structure composition rather than a class hierarchy. If it mentions services, queues, replication or scale, it is high-level system design, which this course does not cover. The tell is the unit of the answer: classes here, structures in Phase 14, boxes and arrows in system design.
The four moves
Section titled “The four moves”Every OOD answer is the same sequence. It is worth having it memorised because the round is short and the failure mode is starting to code.
1. Clarify the scope, then say it back. The prompt is deliberately under-specified. Two or three questions, then a one-sentence statement of what you are building:
“So: a single-site lot, multiple vehicle sizes, hourly pricing, one entrance. I will not handle reservations or multiple sites unless you want them.”
That sentence is doing real work — it puts a fence around the design so you can be complete inside it rather than vague across everything.
2. Name the nouns. List the entities out loud before any structure. For a parking lot:
Vehicle, ParkingSpot, Level, ParkingLot, Ticket, PricingPolicy. Most of these come
straight from the prompt, and naming them is what stops you inventing a ParkingManager that
does everything.
3. Give each noun one responsibility. This is the graded step. ParkingSpot knows whether
it is free; ParkingLot knows how to find a free one; PricingPolicy knows what to
charge. If you cannot say what a class is responsible for in one clause, it is the wrong class.
4. Name the extension point. The follow-up is always “now add X”. Say where X goes before being asked: “pricing is a separate policy object precisely so a monthly-pass rule can be swapped in without touching the lot.”
A worked skeleton
Section titled “A worked skeleton”from abc import ABC, abstractmethod
from enum import Enum
class Size(Enum):
MOTORBIKE = 1
COMPACT = 2
LARGE = 3
class Vehicle:
"""Knows what it is, not where it goes."""
def __init__(self, plate: str, size: Size):
self.plate = plate
self.size = size
class ParkingSpot:
"""Knows whether IT is free. Not how to find a free one."""
def __init__(self, spot_id: str, size: Size):
self.spot_id = spot_id
self.size = size
self.vehicle: Vehicle | None = None
def fits(self, vehicle: Vehicle) -> bool:
return self.vehicle is None and vehicle.size.value <= self.size.value
def park(self, vehicle: Vehicle) -> None:
if not self.fits(vehicle):
raise ValueError(f"{vehicle.plate} does not fit in {self.spot_id}")
self.vehicle = vehicle
def vacate(self) -> Vehicle | None:
leaving, self.vehicle = self.vehicle, None
return leaving
class PricingPolicy(ABC):
"""The extension point. Swap the rule without touching the lot."""
@abstractmethod
def fee(self, hours: float, size: Size) -> float: ...
class HourlyPricing(PricingPolicy):
RATES = {Size.MOTORBIKE: 1.0, Size.COMPACT: 2.0, Size.LARGE: 3.5}
def fee(self, hours: float, size: Size) -> float:
return self.RATES[size] * max(1.0, hours) # minimum one hour
class ParkingLot:
"""Knows how to FIND a spot and issue a ticket. Delegates the rest."""
def __init__(self, spots: list[ParkingSpot], pricing: PricingPolicy):
self.spots = spots
self.pricing = pricing
self.tickets: dict[str, ParkingSpot] = {}
def park(self, vehicle: Vehicle) -> str | None:
spot = next((s for s in self.spots if s.fits(vehicle)), None)
if spot is None:
return None # lot full for this size
spot.park(vehicle)
self.tickets[vehicle.plate] = spot
return vehicle.plate
def leave(self, plate: str, hours: float) -> float:
spot = self.tickets.pop(plate)
vehicle = spot.vacate()
return self.pricing.fee(hours, vehicle.size)
lot = ParkingLot(
[ParkingSpot("A1", Size.COMPACT), ParkingSpot("A2", Size.LARGE)],
HourlyPricing(),
)
bike = Vehicle("BIKE-1", Size.MOTORBIKE)
truck = Vehicle("TRUCK-1", Size.LARGE)
print([lot.park(bike), lot.park(truck), lot.park(Vehicle("X", Size.LARGE))])
print(lot.leave("TRUCK-1", 2.5))
# expect ['BIKE-1', 'TRUCK-1', None]
# expect 8.75Three boundaries in that skeleton are the answer, and each one is a sentence you should say:
ParkingSpot.fitslives on the spot, not the lot. A spot is the only thing that knows its own size and occupancy, so asking it is cheaper and means adding a new size does not touchParkingLot.PricingPolicyis abstract, so monthly passes, validated parking and surge pricing are new subclasses rather thanifbranches insideleave. This is the extension point, named before the follow-up arrives.ParkingLotholds the ticket map, because issuing and redeeming a ticket is a lot-level concern. ATicketclass would be reasonable too — worth saying you considered it and left it out for scope.
Dry run
Section titled “Dry run”Walking the design decisions, not the code
Section titled “Walking the design decisions, not the code”The useful trace in an OOD round is the decision sequence. Take the parking lot and follow what each choice buys:
| Decision | Alternative rejected | What it buys |
|---|---|---|
ParkingSpot.fits(vehicle) | ParkingLot reads spot.size and spot.vehicle | The spot’s internals stay private; adding a size touches one class |
Separate PricingPolicy | if size == LARGE: 3.5 inside leave | A monthly pass is a new class, not an edit to the lot |
Size as an Enum with values | Strings "large" | vehicle.size.value <= spot.size.value gives the fits-in-bigger rule for free |
Ticket map on ParkingLot | A Ticket object holding a spot reference | Less machinery for the same behaviour, at the cost of a weaker audit trail |
park returns None when full | Raises an exception | “Lot full” is an expected outcome, not an error — an exception would force callers into try blocks for a normal case |
Row 3 is the one worth noticing. Making Size an ordered enum means “a motorbike fits in
a large spot” is expressed as 1 <= 3 rather than a lookup table of which sizes accept which.
Traced against the skeleton: lot.park(bike) succeeds into the compact spot A1, because
MOTORBIKE.value is 1 and COMPACT.value is 2.
That is also a design bug you should name yourself: the bike takes the compact spot,
so park(truck) gets A2 and the second large vehicle gets None even though a
motorbike-sized space would have done. Real lots allocate smallest-fitting-first. Verified:
the three calls return ['BIKE-1', 'TRUCK-1', None].
Saying “my next(...) takes the first fitting spot, which wastes large spots on small
vehicles — I would sort candidates by size ascending” is worth more than a design with no
known flaws, because it demonstrates you can evaluate your own boundaries.
Why inheritance is the wrong axis, counted
Section titled “Why inheritance is the wrong axis, counted”The tempting move is a subclass per kind of thing. Count what that costs when a design has independent dimensions — say 3 vehicle sizes, 4 fuel types and 2 ownership models:
| Approach | Classes / fields needed |
|---|---|
| A subclass per combination | 3 x 4 x 2 = 24 |
| One field per dimension | 3 |
| Add a fifth dimension with 5 values | subclasses 120, fields 4 |
Verified. Inheritance multiplies across independent dimensions; composition adds.
ElectricLargeLeasedVehicle is the smell, and the fix is Vehicle(size, fuel, ownership).
The rule of thumb that survives interview pressure: inherit only to say “is a kind of”, and
only when the subtypes genuinely differ in behaviour. PricingPolicy earns its
inheritance because each subclass computes a fee differently. LargeVehicle does not, because
a large vehicle behaves exactly like a small one and only differs in a value.
A state machine, when the noun has modes
Section titled “A state machine, when the noun has modes”Some prompts — elevator, vending machine, order lifecycle — are really about states and transitions, and the cleanest answer is an explicit table:
| From | Event | To |
|---|---|---|
idle | request | moving |
moving | arrive | doors_open |
doors_open | timeout | idle |
doors_open | obstruction | doors_open |
moving / idle | emergency | stopped |
Traced: ["request", "arrive", "timeout"] gives
idle -> moving -> doors_open -> idle, and an obstruction repeats doors_open rather than
falling through. Feeding an impossible event — arrive while idle — leaves the state
unchanged rather than crashing, which is the deliberate choice: an unexpected event in a
physical system is a no-op to be logged, not an exception that halts the lift.
A table beats a pile of booleans (is_moving, doors_are_open) because the table cannot
represent moving and doors_open simultaneously. Booleans can, and that is exactly the
bug class you are being asked to avoid.
Complexity
Section titled “Complexity”OOD rounds are not graded on asymptotics, but a competent design states the cost of its main operations — and a bad boundary usually shows up as a bad bound.
| Operation | Skeleton above | Better, and how |
|---|---|---|
park (find a spot) | scan of all spots | Bucket free spots by size: pop from a per-size list |
leave (redeem a ticket) | dict lookup | — |
| “Is the lot full?” | Maintain a per-size free counter: | |
| “How many free large spots?” | Same counter | |
| Elevator “which lift should answer?” | per request | Fine — the count is tiny and bounded |
Two things worth saying out loud:
- The scan is usually acceptable and you should say why: a lot has hundreds of spots, not millions, and the request rate is human-scale. Optimising it before being asked is the wrong instinct in a design round — but knowing it is and naming the fix is the right one.
- A per-size free list is the standard follow-up, and it is a design change rather than an
algorithm change:
ParkingLotgainsfree: dict[Size, list[ParkingSpot]], andparkbecomes a pop. Notice this only works becauseParkingSpotalready ownsfits— the boundary made the optimisation cheap.
The variant map
Section titled “The variant map”| Prompt | The shape it really is | The extension point to name first |
|---|---|---|
| Parking lot | Resource allocation from a pool | PricingPolicy, and the spot-selection strategy |
| Elevator system | State machine per lift + a dispatcher | The scheduling policy (nearest, direction-aware) |
| Vending machine | State machine + inventory | Payment methods |
| Deck of cards | Value objects + a shuffle strategy | The shuffle, and what a “hand” means per game |
| Library / rental | Catalogue + loans with due dates | The lending rules per item type |
| Chess / tic-tac-toe | Board + per-piece move rules | Move validation per piece — the classic good use of inheritance |
| ATM | State machine + transaction log | Authentication, and the cash-dispensing algorithm |
| Ride hailing | Matching two pools + trip lifecycle | The matching strategy |
| Logger / rate limiter | Not OOD — a data-structure design | — |
Pitfalls
Section titled “Pitfalls”- Starting to code. The first three minutes are clarification and entity naming. Writing a class before saying the scope back is the single most common failure, and it is visible immediately.
- A god class.
ParkingManagerthat finds spots, prices, issues tickets and logs. If you cannot state a class’s responsibility in one clause, split it. - Inheriting for data rather than behaviour.
LargeVehicle(Vehicle)adds no behaviour — it is a field. Counted above: three independent dimensions become 24 subclasses and 3 fields. - Booleans instead of a state.
is_movingplusdoors_are_opencan represent a lift moving with its doors open. An explicit state cannot, which is the point. - No extension point. The follow-up is always “now add X”. A design with every rule inlined forces an edit to the core class, and the interviewer will make you do it.
- Over-abstracting instead. Five interfaces for a two-entity problem reads as poor judgement just as clearly as a god class. Abstract the axis the follow-up will push on, and say that is why.
- Exceptions for expected outcomes. “Lot full” is a normal result — return
Noneor a result object. Raising forces every caller into atryfor the common case. - Ignoring the physical constraints. A lift cannot move with its doors open; a spot cannot hold two cars. Encode those in the type, not in comments.
- No
__eq__/__hash__on value objects. TwoCards with the same rank and suit should compare equal; without it,card in handsilently fails.@dataclass(frozen=True)gives both. - Designing for scale nobody asked about. Sharding the parking lot is system design. Stay at the class level unless the interviewer moves you.
- Not naming your own flaw. The first-fitting-spot bug above is worth volunteering. A design you can critique reads as stronger than one you present as perfect.
Try it yourself
Section titled “Try it yourself”Drill 1 — who owns each method?
Section titled “Drill 1 — who owns each method?”Drill 2 — count the inheritance explosion
Section titled “Drill 2 — count the inheritance explosion”Drill 3 — an explicit state machine
Section titled “Drill 3 — an explicit state machine”Interview follow-ups
Section titled “Interview follow-ups”| They ask | What they’re checking | The answer |
|---|---|---|
| “Now add motorbikes.” | Whether your design has an axis for it | If Size is already an enum and fits compares values, this is one enum member and no other change. Say that — a follow-up you absorb without editing a class is the best possible outcome |
| “Now add monthly passes.” | The extension point | A new PricingPolicy subclass. This is why pricing was separated in the first place, and saying so closes the loop on a decision you made earlier |
“Why is fits on the spot and not the lot?” | Whether the boundary was reasoned | A spot is the only thing that knows its own size and occupancy. Putting it on the lot means the lot reads spot internals, so adding a size changes two classes instead of one |
| “Would you use inheritance for vehicle sizes?” | The composition instinct | No — size is data, not behaviour. Three independent dimensions become 24 subclasses and 3 fields. Inherit only when subtypes behave differently, like PricingPolicy |
| “How does the lot find a spot, and what does it cost?” | Whether you know your own bound | scan in the simple version, which is fine at hundreds of spots. The follow-up fix is a per-size free list, making it — and it is cheap precisely because fits already lives on the spot |
| “What happens when the lot is full?” | Error modelling | Return None or a result object. “Full” is an expected outcome, so an exception would force every caller into a try for the normal case |
| “Two cars arrive at the same instant.” | Concurrency, at the class level | The find-then-park sequence is a race. Either a lock around allocation, or an atomic compare-and-set on the spot. Naming the race unprompted is the signal; a full solution is not expected |
| “Model the elevator instead.” | Whether you reach for a state machine | An explicit (state, event) -> state table per lift, plus a dispatcher choosing which lift answers. Booleans cannot represent the illegal combinations; a state table cannot express them at all |
| “How would you test this?” | Engineering instinct | Per class: a spot rejects an oversized vehicle, pricing charges a one-hour minimum, the lot returns None when full, and the state machine ignores an impossible event. Naming the boundaries is what makes a design testable |
| “What would you change if you did it again?” | Self-critique | Volunteer the real flaw: first-fitting-spot allocation wastes large spots on small vehicles, so candidates should be sorted by size ascending. A design you can criticise reads stronger than one presented as perfect |
| “Is this system design?” | Whether you know the boundary | No — that is services, queues and replication. This is classes and responsibilities. If the interviewer starts asking about multiple sites and consistency, they have moved rounds |
Self-check
Section titled “Self-check”-
What is the first thing to do when the prompt is "design a parking lot"?
The prompt is deliberately under-specified, and stating the fence puts you in a position to be *complete* inside it rather than vague across everything. Naming entities comes second — and it comes out better once the scope is fixed. Writing code first is the most visible failure in this round.
pch.quizShowAnswer
B — Ask two or three scoping questions, then say the scope back in one sentence — The prompt is deliberately under-specified, and stating the fence puts you in a position to be *complete* inside it rather than vague across everything. Naming entities comes second — and it comes out better once the scope is fixed. Writing code first is the most visible failure in this round.
-
Should `is_available` live on ParkingSpot or ParkingLot?
The test is whether a class can answer the question from its own state. A spot can; the lot would have to reach into `spot.size` and `spot.vehicle`, which couples them. Note `find_spot_for` genuinely does belong to the lot — searching a collection is the collection owner's job. Those two methods sound similar and belong to different classes.
pch.quizShowAnswer
B — ParkingSpot — it is the only thing that knows its own size and occupancy, so adding a new size touches one class instead of two — The test is whether a class can answer the question from its own state. A spot can; the lot would have to reach into `spot.size` and `spot.vehicle`, which couples them. Note `find_spot_for` genuinely does belong to the lot — searching a collection is the collection owner's job. Those two methods sound similar and belong to different classes.
-
Modelling 3 vehicle sizes x 4 fuel types x 2 ownership models by subclassing. How many classes?
3 x 4 x 2 = 24, and adding a fourth dimension of 5 values makes it 120 against 4 fields. That multiply-versus-add gap is the whole argument for composition. The usable rule: inherit only when subtypes differ in *behaviour*. PricingPolicy earns it because each subclass computes differently; LargeVehicle does not, because size is a value.
pch.quizShowAnswer
B — 24, because inheritance multiplies across independent dimensions; composition needs 3 fields — 3 x 4 x 2 = 24, and adding a fourth dimension of 5 values makes it 120 against 4 fields. That multiply-versus-add gap is the whole argument for composition. The usable rule: inherit only when subtypes differ in *behaviour*. PricingPolicy earns it because each subclass computes differently; LargeVehicle does not, because size is a value.
-
Why prefer an explicit state table over booleans like `is_moving` and `doors_are_open`?
Making the illegal state unrepresentable is the point, and it is the bug class the question exists to test. Two booleans give four combinations, at least one of which must never happen — so every method has to defend against it. One state variable makes that defence unnecessary rather than merely easier.
pch.quizShowAnswer
B — Booleans can represent illegal combinations — moving with the doors open — while a single state cannot express that at all — Making the illegal state unrepresentable is the point, and it is the bug class the question exists to test. Two booleans give four combinations, at least one of which must never happen — so every method has to defend against it. One state variable makes that defence unnecessary rather than merely easier.
-
An event arrives that has no transition from the current state — `arrive` while `idle`. What should happen?
Traced: run(['arrive', 'timeout']) gives ['idle', 'idle', 'idle']. A lift receiving a spurious sensor reading should log it and carry on, not halt with the doors shut. Resetting is worse than either — it would discard legitimate state because of noise. An explicit error state is defensible for genuine faults, which is a different thing from an unexpected event.
pch.quizShowAnswer
B — Stay in the current state; an unexpected event in a physical system is a no-op to be logged, not a crash — Traced: run(['arrive', 'timeout']) gives ['idle', 'idle', 'idle']. A lift receiving a spurious sensor reading should log it and carry on, not halt with the doors shut. Resetting is worse than either — it would discard legitimate state because of noise. An explicit error state is defensible for genuine faults, which is a different thing from an unexpected event.
-
`ParkingLot.park` returns None when the lot is full. Why not raise?
The distinction is between an outcome the caller must handle routinely and a violation of an invariant. A full lot is business-as-usual; parking a vehicle in a spot that does not fit is a genuine bug, which is why `ParkingSpot.park` *does* raise. Getting that split right is a real signal in this round.
pch.quizShowAnswer
B — "Full" is an expected outcome, not an error — raising forces every caller into a try block for the normal case — The distinction is between an outcome the caller must handle routinely and a violation of an invariant. A full lot is business-as-usual; parking a vehicle in a spot that does not fit is a genuine bug, which is why `ParkingSpot.park` *does* raise. Getting that split right is a real signal in this round.
-
The lot finds a spot with an O(n) scan. Should you optimise it before being asked?
A lot has hundreds of spots and human-scale request rates, so the scan is genuinely fine — optimising unprompted is the algorithm-round instinct misapplied. What is graded is knowing it is O(n) and where the change would go. Note the fix is a *design* change, not an algorithmic one, and the earlier boundary decision is what makes it a small one.
pch.quizShowAnswer
B — No, but know the bound and name the fix: a per-size free list makes it O(1), and it is cheap precisely because `fits` already lives on the spot — A lot has hundreds of spots and human-scale request rates, so the scan is genuinely fine — optimising unprompted is the algorithm-round instinct misapplied. What is graded is knowing it is O(n) and where the change would go. Note the fix is a *design* change, not an algorithmic one, and the earlier boundary decision is what makes it a small one.
-
Two vehicles arrive at the same instant. What is the honest answer?
The gap between checking `fits` and calling `park` is where two callers can both succeed on one spot. Volunteering that unprompted is worth more than a complete solution, because it shows you read your own sequence rather than only its parts. It is not out of scope — but a full concurrency design is, and saying so is part of the answer.
pch.quizShowAnswer
B — Find-then-park is a race: two threads can see the same free spot. Either lock the allocation or compare-and-set on the spot — and naming the race is the signal, not solving it fully — The gap between checking `fits` and calling `park` is where two callers can both succeed on one spot. Volunteering that unprompted is worth more than a complete solution, because it shows you read your own sequence rather than only its parts. It is not out of scope — but a full concurrency design is, and saying so is part of the answer.
-
Which prompt is NOT a low-level design question?
The memory target gives it away: that is a data-structure design problem (LC 362, covered in Phase 14) where the answer is bucketed storage rather than a class hierarchy. The unit of the answer is the tell — classes for OOD, structures for Phase 14, boxes and arrows for system design.
pch.quizShowAnswer
B — Design a hit counter that reports hits in the last 300 seconds in O(1) memory — The memory target gives it away: that is a data-structure design problem (LC 362, covered in Phase 14) where the answer is bucketed storage rather than a class hierarchy. The unit of the answer is the tell — classes for OOD, structures for Phase 14, boxes and arrows for system design.
Recall card
Section titled “Recall card”- OOD prompts are nouns, not tasks. No input, no output, no complexity target — and the follow-ups ask you to extend, not to go faster.
- Four moves, in order: clarify and say the scope back · name the nouns · give each one responsibility · name the extension point before being asked.
- If you cannot state a class’s responsibility in one clause, it is the wrong class.
- A spot knows if it is free; the lot knows how to find one. Those sound alike and belong to different classes.
- Inherit for behaviour, compose for data. 3 independent dimensions = 24 subclasses vs 3
fields; four dimensions = 120 vs 4.
PricingPolicyearns inheritance;LargeVehicledoes not. - Modes mean a state table, not booleans — two booleans can represent “moving with doors open” and a single state cannot.
- An impossible event is a no-op, logged, not raised. A lift must not halt on a bad sensor.
- Expected outcomes return; invariant violations raise. “Lot full” returns
None; parking a truck in a bike space raises. - State your operation costs and leave them alone. spot search is fine at hundreds of
spots; the per-size free list is the follow-up, and it is cheap because
fitslives on the spot. - Volunteer your own flaw. First-fitting allocation wastes large spots — sort candidates smallest-first. Self-critique reads stronger than a design presented as perfect.
- Three shapes cover almost everything: resource allocation · state machine · catalogue plus transactions.
@dataclass(frozen=True)for value objects, so__eq__and__hash__come for free.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading