Skip to content

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.

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 O(1)O(1) 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.

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

parking_lot.py
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.75

Three boundaries in that skeleton are the answer, and each one is a sentence you should say:

  • ParkingSpot.fits lives 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 touch ParkingLot.
  • PricingPolicy is abstract, so monthly passes, validated parking and surge pricing are new subclasses rather than if branches inside leave. This is the extension point, named before the follow-up arrives.
  • ParkingLot holds the ticket map, because issuing and redeeming a ticket is a lot-level concern. A Ticket class would be reasonable too — worth saying you considered it and left it out for scope.

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:

DecisionAlternative rejectedWhat it buys
ParkingSpot.fits(vehicle)ParkingLot reads spot.size and spot.vehicleThe spot’s internals stay private; adding a size touches one class
Separate PricingPolicyif size == LARGE: 3.5 inside leaveA monthly pass is a new class, not an edit to the lot
Size as an Enum with valuesStrings "large"vehicle.size.value <= spot.size.value gives the fits-in-bigger rule for free
Ticket map on ParkingLotA Ticket object holding a spot referenceLess machinery for the same behaviour, at the cost of a weaker audit trail
park returns None when fullRaises 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:

ApproachClasses / fields needed
A subclass per combination3 x 4 x 2 = 24
One field per dimension3
Add a fifth dimension with 5 valuessubclasses 120, fields 4

Verified. Inheritance multiplies across independent dimensions; composition adds.

sketch Inheritance multiplies, composition adds p5.js
Three independent dimensions -- 3 vehicle sizes, 4 fuel types, 2 ownership models. A subclass per combination is the product; a field per dimension is the count. The grid draws one box per subclass you would have to write, and the counter beside it is the composition alternative. Watch what a fourth dimension of five values does to each. Nothing here is hardcoded: both numbers are computed from the dimension list.

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.

Some prompts — elevator, vending machine, order lifecycle — are really about states and transitions, and the cleanest answer is an explicit table:

FromEventTo
idlerequestmoving
movingarrivedoors_open
doors_opentimeoutidle
doors_openobstructiondoors_open
moving / idleemergencystopped

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.

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.

OperationSkeleton aboveBetter, and how
park (find a spot)O(n)O(n) scan of all spotsBucket free spots by size: O(1)O(1) pop from a per-size list
leave (redeem a ticket)O(1)O(1) dict lookup
“Is the lot full?”O(n)O(n)Maintain a per-size free counter: O(1)O(1)
“How many free large spots?”O(n)O(n)Same counter
Elevator “which lift should answer?”O(lifts)O(\text{lifts}) per requestFine — the count is tiny and bounded

Two things worth saying out loud:

  • The O(n)O(n) 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 O(n)O(n) 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: ParkingLot gains free: dict[Size, list[ParkingSpot]], and park becomes a pop. Notice this only works because ParkingSpot already owns fits — the boundary made the optimisation cheap.
PromptThe shape it really isThe extension point to name first
Parking lotResource allocation from a poolPricingPolicy, and the spot-selection strategy
Elevator systemState machine per lift + a dispatcherThe scheduling policy (nearest, direction-aware)
Vending machineState machine + inventoryPayment methods
Deck of cardsValue objects + a shuffle strategyThe shuffle, and what a “hand” means per game
Library / rentalCatalogue + loans with due datesThe lending rules per item type
Chess / tic-tac-toeBoard + per-piece move rulesMove validation per piece — the classic good use of inheritance
ATMState machine + transaction logAuthentication, and the cash-dispensing algorithm
Ride hailingMatching two pools + trip lifecycleThe matching strategy
Logger / rate limiterNot OOD — a data-structure design
  • 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. ParkingManager that 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_moving plus doors_are_open can 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 None or a result object. Raising forces every caller into a try for 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. Two Cards with the same rank and suit should compare equal; without it, card in hand silently 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.
sketch Two booleans can spell a lift moving with its doors open p5.js
is_moving and doors_are_open are two independent flags, so the type admits four combinations -- and one of them is physically impossible. The bug is not that the code sets it; the bug is that the type ALLOWS it, so every method has to defend against a state that should not exist. An explicit state has exactly the legal values and no illegal one to check for. Encode the constraint in the type, not in comments.

Drill 2 — count the inheritance explosion

Section titled “Drill 2 — count the inheritance explosion”
They askWhat they’re checkingThe answer
“Now add motorbikes.”Whether your design has an axis for itIf 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 pointA 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 reasonedA 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 instinctNo — 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 boundO(n)O(n) scan in the simple version, which is fine at hundreds of spots. The follow-up fix is a per-size free list, making it O(1)O(1) — and it is cheap precisely because fits already lives on the spot
“What happens when the lot is full?”Error modellingReturn 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 levelThe 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 machineAn 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 instinctPer 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-critiqueVolunteer 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 boundaryNo — 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
pch.quizTag pch.quizDefaultTitle
  1. What is the first thing to do when the prompt is "design a parking lot"?

    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.

  2. Should `is_available` live on ParkingSpot or ParkingLot?

    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.

  3. Modelling 3 vehicle sizes x 4 fuel types x 2 ownership models by subclassing. How many classes?

    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.

  4. Why prefer an explicit state table over booleans like `is_moving` and `doors_are_open`?

    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.

  5. An event arrives that has no transition from the current state — `arrive` while `idle`. What should happen?

    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.

  6. `ParkingLot.park` returns None when the lot is full. Why not raise?

    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.

  7. The lot finds a spot with an O(n) scan. Should you optimise it before being asked?

    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.

  8. Two vehicles arrive at the same instant. What is the honest 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.

  9. Which prompt is NOT a low-level design question?

    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.

  • 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. PricingPolicy earns inheritance; LargeVehicle does 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. O(n)O(n) spot search is fine at hundreds of spots; the per-size free list is the follow-up, and it is cheap because fits lives 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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading