A Rookie's Guide to LLD: The Parking Lot and the Pattern Cheat Sheet
How to approach any low-level design interview: turn requirements into classes, methods, and state machines — the parking lot classic, fully built — plus a cheat sheet for picking design patterns.
"Design a parking lot." It's the LLD rite of passage — every interview loop has one, every rookie dreads it, and almost everyone attacks it the same wrong way: by reciting design patterns at it, hoping one sticks. Singleton! Factory! Abstract Factory…?
Here's the calmer truth: the parking lot is extracted, not invented — the same nouns-and-verbs recipe that builds an HLD answer builds this one, just at a different zoom level. And patterns? They come last, picked by one diagnostic question, not recited. By the end of this guide you'll have the full lot built — classes, state machine, pricing — and a cheat sheet that tells you which pattern a problem is asking for before the interviewer does.
This is the zoomed-in twin of A Rookie's Guide to HLD, where the same recipe designs Zomato at the systems level. Read them in either order — the recipe is the point.
Same recipe, different zoom
At HLD altitude, nouns became tables and verbs became APIs. Zoom into a single process and the mapping just changes clothes:
So we already know how to start — and it's not with a pattern.
Step 1 — Plain sentences first. Always.
- A driver can park a vehicle and receive a ticket.
- A driver can pay for the ticket and then exit.
- A lot has floors; each floor has slots of different sizes.
- A vehicle fits in a slot of its size or bigger.
- The lot can report how many slots are free.
Thirty seconds of writing, and the entire design is now hiding in five sentences.
Those five are the functional requirements — what the lot must do. Before touching a class, jot the four non-functional ones too, because they quietly decide your whole design: it must be thread-safe (two gates, one lot), fast on the hot path (parking can't scan everything), extensible (pricing will change), and testable (you can't wait an hour to prove the fee). Keep those four in the corner of the whiteboard — every step below is secretly answering one of them. Watch.
Step 2 — Nouns → classes (and one hidden noun)
Circle them: ParkingLot, Floor, Slot, Vehicle, Ticket.
Four of those are obvious. The fifth is the interesting one — in the HLD guide, the order/menu-item relationship turned out to be a hidden table called order_items. The parking lot has the same hidden noun: a Ticket is nothing but the relationship between a vehicle and a slot, stretched over time, with a lifecycle of its own. Once you see tickets, bookings, memberships, and sessions as "relationships promoted to classes," half of LLD stops being mysterious.
A taste of how little code the cast needs:
public record Vehicle(String number, VehicleSize size) {}
public enum VehicleSize {
BIKE(1), CAR(2), TRUCK(3);
private final int rank;
VehicleSize(int rank) {
this.rank = rank;
}
/** A vehicle fits any slot of its size or bigger. */
public boolean fitsIn(VehicleSize slotSize) {
return rank <= slotSize.rank;
}
}One deliberate choice worth saying out loud in the interview: no Bike extends Vehicle subclass tree. The vehicles don't behave differently — they only differ by a value — so an enum field beats a class hierarchy. Subclass for different behavior, use a field for different data.
Step 3 — Verbs → methods, but ask WHO owns each verb
The verbs: park, pay, exit, report free slots. The rookie mistake isn't missing them — it's hanging them on the wrong class (vehicle.park()?). The test: the verb belongs to whoever has enough information to do the job. Finding a free slot requires seeing all floors — so park() belongs to the lot:
public synchronized Optional<Ticket> park(Vehicle vehicle) { … }
public synchronized long pay(String ticketId) { … }
public synchronized void exit(String ticketId) { … }
public synchronized long freeSlots() { … }And the "fits in its size or bigger" sentence becomes the slot search — smallest fitting slot first, so a bike doesn't squat in the truck space:
Optional<Slot> findSlotFor(Vehicle vehicle) {
return slots.stream() // kept sorted smallest-first
.filter(slot -> slot.canTake(vehicle))
.findFirst(); // smallest fitting slot wins
}Pick each data structure for a reason you can name. The lot keeps tickets in a Map<String, Ticket> because pay and exit look a ticket up by id and want it in O(1), not by scanning a list. Each floor keeps its slots in a list sorted smallest-first, so "smallest fitting slot" is the first match rather than a full sort on every park. "A map for O(1) lookup, sorted-once for cheap best-fit" is exactly the sentence that answers the performance requirement out loud.
Step 4 — The status becomes a state machine
At HLD zoom, the order's status column was a lifecycle. Here, the ticket's status is the same thing — except now you enforce the arrows, in code:
void markPaid() {
if (status != Status.ACTIVE) {
throw new IllegalStateException("only an ACTIVE ticket can be paid, was " + status);
}
status = Status.PAID;
}
void markExited() {
if (status != Status.PAID) {
throw new IllegalStateException("pay before you leave — ticket is " + status);
}
status = Status.EXITED;
}Boring-looking code, big interview signal: illegal transitions throw, so "exit without paying" is impossible by construction rather than by hoping every caller remembers the rule.
One concurrency beat before moving on: two cars, one remaining slot, two entry
gates. Without the synchronized on park(), both find the same free slot
and both get tickets — the classic check-then-act race. It's the exact bug we
dissect (and load-test) in Multithreading,
Explained.
Step 5 — NOW we talk patterns
Notice everything we built so far used zero named patterns — just nouns, verbs, and a state machine. That's the right order. A pattern earns its place only when you spot something varying that callers shouldn't care about.
And the parking lot has exactly one: pricing. Hourly today; the business will want flat evening rates and weekend pricing tomorrow. The thing that varies gets an interface; the lot stays oblivious:
public interface PricingStrategy {
long feeFor(VehicleSize size, Duration parked);
}
public final class HourlyPricing implements PricingStrategy {
private static final Map<VehicleSize, Long> RATE_PER_HOUR =
Map.of(VehicleSize.BIKE, 10L, VehicleSize.CAR, 20L, VehicleSize.TRUCK, 30L);
@Override
public long feeFor(VehicleSize size, Duration parked) {
long hours = Math.max(1, (parked.toMinutes() + 59) / 60); // ceil, minimum one hour
return hours * RATE_PER_HOUR.get(size);
}
}That's the Strategy pattern — and you didn't recite it, you derived it from one question: "what varies?" That question is the whole skill, so here it is as a map:
The cheat sheet: what the problem smells like → what to reach for
| The problem smells like… | Reach for | In the parking lot |
|---|---|---|
| one job, several interchangeable ways to do it | Strategy | hourly vs flat vs weekend pricing |
| behavior changes as the object moves through life | State | ticket: ACTIVE → PAID → EXITED |
| building the object is messy or has variants | Builder/Factory | constructing a lot: floors, slot mixes |
| exactly one must exist, globally reachable | Singleton | the lot itself (prefer injection in real apps) |
| many things must hear about a change | Observer | display boards updating when a slot frees |
| add an ability without a subclass explosion | Decorator | an EV-charging slot = slot + charger |
| a foreign interface doesn't match yours | Adapter | the third-party payment SDK |
| nothing actually varies | plain code | most of the lot, honestly |
Read the last row twice — it's the one that separates candidates. Naming a
pattern where nothing varies is a red flag, not a bonus point. Our ticket's
three states above? Two if checks. The full State pattern (one class per
state) earns its keep at five-plus states with different behavior each —
saying that is worth more than using it.
Say the trade-offs out loud
The last thing that separates a good answer is naming what each choice cost, and which requirement it bought. One lock on the whole lot keeps it correct and trivially thread-safe, but serializes every gate — fine here, and the moment it isn't, you shard the lock per floor. Injecting a Clock instead of calling Instant.now() looks like a needless parameter, until you realize it's what makes the fee testable without waiting an hour — a tiny cost paying directly for the testability requirement. Every trade-off you mention should end with the non-functional requirement it protects; that habit is the whole game in a worked example like the thread pool, where the edges are the trade-offs.
The complete implementation
Everything assembled — the cast, the law, the strategy, and the lot that conducts them:
package dev.fiveyear.parking;
public enum VehicleSize {
BIKE(1), CAR(2), TRUCK(3);
private final int rank;
VehicleSize(int rank) {
this.rank = rank;
}
/** A vehicle fits any slot of its size or bigger. */
public boolean fitsIn(VehicleSize slotSize) {
return rank <= slotSize.rank;
}
}
public record Vehicle(String number, VehicleSize size) {}package dev.fiveyear.parking;
/** One physical space; its size names the largest vehicle it accepts. */
public final class Slot {
private final String id;
private final VehicleSize size;
private Ticket ticket; // null = free
Slot(String id, VehicleSize size) {
this.id = id;
this.size = size;
}
boolean canTake(Vehicle vehicle) {
return ticket == null && vehicle.size().fitsIn(size);
}
boolean isFree() {
return ticket == null;
}
VehicleSize size() {
return size;
}
String id() {
return id;
}
void occupy(Ticket t) {
this.ticket = t;
}
void release() {
this.ticket = null;
}
}package dev.fiveyear.parking;
import java.time.Instant;
/** The hidden noun: one vehicle in one slot, for a span of time, with a lifecycle. */
public final class Ticket {
public enum Status { ACTIVE, PAID, EXITED }
private final String id;
private final Vehicle vehicle;
private final Slot slot;
private final Instant entryAt;
private Status status = Status.ACTIVE;
Ticket(String id, Vehicle vehicle, Slot slot, Instant entryAt) {
this.id = id;
this.vehicle = vehicle;
this.slot = slot;
this.entryAt = entryAt;
}
void markPaid() {
if (status != Status.ACTIVE) {
throw new IllegalStateException("only an ACTIVE ticket can be paid, was " + status);
}
status = Status.PAID;
}
void markExited() {
if (status != Status.PAID) {
throw new IllegalStateException("pay before you leave — ticket is " + status);
}
status = Status.EXITED;
}
public String id() {
return id;
}
public Vehicle vehicle() {
return vehicle;
}
Slot slot() {
return slot;
}
Instant entryAt() {
return entryAt;
}
public Status status() {
return status;
}
}package dev.fiveyear.parking;
import java.time.Duration;
import java.util.Map;
public interface PricingStrategy {
long feeFor(VehicleSize size, Duration parked);
}
public final class HourlyPricing implements PricingStrategy {
private static final Map<VehicleSize, Long> RATE_PER_HOUR =
Map.of(VehicleSize.BIKE, 10L, VehicleSize.CAR, 20L, VehicleSize.TRUCK, 30L);
@Override
public long feeFor(VehicleSize size, Duration parked) {
long hours = Math.max(1, (parked.toMinutes() + 59) / 60); // ceil, minimum one hour
return hours * RATE_PER_HOUR.get(size);
}
}package dev.fiveyear.parking;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public final class Floor {
private final String id;
private final List<Slot> slots;
Floor(String id, List<Slot> slots) {
this.id = id;
this.slots = new ArrayList<>(slots);
this.slots.sort(Comparator.comparing(Slot::size)); // smallest first
}
Optional<Slot> findSlotFor(Vehicle vehicle) {
return slots.stream()
.filter(slot -> slot.canTake(vehicle))
.findFirst(); // smallest fitting slot wins
}
long freeSlots() {
return slots.stream().filter(Slot::isFree).count();
}
}package dev.fiveyear.parking;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class ParkingLot {
private final List<Floor> floors;
private final PricingStrategy pricing;
private final Clock clock;
private final Map<String, Ticket> tickets = new HashMap<>();
private int nextTicketNo = 1;
public ParkingLot(List<Floor> floors, PricingStrategy pricing, Clock clock) {
this.floors = List.copyOf(floors);
this.pricing = pricing;
this.clock = clock;
}
/** One car, one slot — atomically, so two gates can't hand out the same last slot. */
public synchronized Optional<Ticket> park(Vehicle vehicle) {
for (Floor floor : floors) {
Optional<Slot> slot = floor.findSlotFor(vehicle);
if (slot.isPresent()) {
Ticket ticket = new Ticket("T-" + nextTicketNo++, vehicle, slot.get(), clock.instant());
slot.get().occupy(ticket);
tickets.put(ticket.id(), ticket);
return Optional.of(ticket);
}
}
return Optional.empty(); // lot full — the caller decides what "full" looks like
}
/** Computes the fee and marks the ticket PAID. */
public synchronized long pay(String ticketId) {
Ticket ticket = ticketOrThrow(ticketId);
long fee = pricing.feeFor(
ticket.vehicle().size(), Duration.between(ticket.entryAt(), clock.instant()));
ticket.markPaid();
return fee;
}
/** Opens the gate: retires the ticket and frees the slot. */
public synchronized void exit(String ticketId) {
Ticket ticket = ticketOrThrow(ticketId);
ticket.markExited();
ticket.slot().release();
tickets.remove(ticketId);
}
public synchronized long freeSlots() {
return floors.stream().mapToLong(Floor::freeSlots).sum();
}
private Ticket ticketOrThrow(String ticketId) {
Ticket ticket = tickets.get(ticketId);
if (ticket == null) {
throw new IllegalArgumentException("unknown ticket: " + ticketId);
}
return ticket;
}
}And a day at the lot:
Floor ground = new Floor("G", List.of(
new Slot("G-1", VehicleSize.BIKE),
new Slot("G-2", VehicleSize.CAR),
new Slot("G-3", VehicleSize.TRUCK)));
ParkingLot lot = new ParkingLot(List.of(ground), new HourlyPricing(), Clock.systemUTC());
Ticket bike = lot.park(new Vehicle("KA-01-1111", VehicleSize.BIKE)).orElseThrow();
// bike got G-1 — the smallest slot that fits, even though G-2 and G-3 were free
lot.park(new Vehicle("KA-01-2222", VehicleSize.CAR)); // takes G-2
lot.park(new Vehicle("KA-01-3333", VehicleSize.TRUCK)); // takes G-3
lot.park(new Vehicle("KA-01-4444", VehicleSize.BIKE)); // Optional.empty — lot full
lot.exit(bike.id()); // throws IllegalStateException — the ticket is ACTIVE, pay first!
long fee = lot.pay(bike.id()); // 90 minutes parked → 2 hours × 10 = 20
lot.exit(bike.id()); // gate opens; G-1 is free againWhere to go from here
The recipe, pocket-sized: sentences (then the four NFRs) → nouns (watch for the hidden one) → the right data structure for each, named → verbs on their rightful owners → the status becomes a state machine → patterns only where something varies → trade-offs, each one naming the requirement it protects.
- Run it yourself on the other LLD classics — an elevator system, a vending machine, BookMyShow seat locking. Each has exactly one hidden noun and one thing that varies; finding them is the interview.
- See the recipe under production pressure — the same per-key state, races, and registries, in the rate limiter and the LRU cache.
- Zoom back out to the systems version of the recipe: A Rookie's Guide to HLD.
Next time someone says "design a parking lot," you won't be reciting patterns at the whiteboard. You'll be circling five nouns, promoting one relationship to a class, and asking the only pattern question that matters: what varies?