Lesson 21 of 31
Design a Parking Lot
The parking lot is the most common low-level design prompt, because it contains every ingredient of the interview framework in a small space: a closed set of choices (vehicle sizes), a behavior that varies (fees), a resource that can run out (spots), and a lifecycle (enter, park, leave, pay). This article works the problem through in the framework's order and ends with a complete program that compiles and runs.
Requirements
Functional requirements
- The lot has several levels. Each level has spots of three kinds: small, regular and large.
- Vehicles are motorcycles, cars or trucks. A motorcycle fits any spot, a car fits a regular or large spot, a truck fits only a large spot.
- On entry the lot picks a spot, marks it occupied and issues a ticket. If no fitting spot is free, entry is refused.
- On exit the caller presents the ticket. The lot frees the spot and returns a receipt with the fee.
- The fee depends on the vehicle kind and the time parked. Rules will change, so fee calculation must be replaceable.
- A vehicle that is already inside cannot enter again.
Rules and assumptions
- Time is counted in whole minutes and comes from an injected clock, so tests do not depend on the wall clock.
- Money is an integer number of cents in a
long. Floating point never represents money. - Out of scope: payment processing, gates and cameras, persistence, reservations. Concurrency is discussed at the end.
Entities and relationships
Underline the nouns: lot, level, spot, vehicle, ticket, fee, clock. Then sort them.
| Noun | Kind of thing | Java form |
|---|---|---|
| Vehicle kind | one of a fixed set | enum VehicleKind |
| Spot kind | one of a fixed set, ordered by size | enum SpotKind |
| Spot, Level | data with an invariant (occupied or free) | small mutable classes, owned in a tree |
| Ticket | data, looked up later | record Ticket stored in a TreeMap<TicketId, Ticket> |
| Fee rule | behavior that varies | interface PricingStrategy |
| Choosing a spot | behavior that may vary | interface SpotFinder |
| Time source | dependency to fake in tests | interface MinuteClock |
| Amount, IDs | plain values | records Money, TicketId, SpotId |
The lot owns levels, levels own spots, and the lot owns the tickets. A ticket refers to its spot by SpotId and a spot refers to its ticket by TicketId. Both are plain IDs, so there is no reference cycle to keep in step: the lot is the only object that changes either side. See object graphs and references.
classDiagram
class ParkingLot {
-levels: List~Level~
-tickets: TreeMap~TicketId, Ticket~
+park(plate, kind) TicketId
+leave(ticket) Receipt
+freeSpots(kind) int
}
class Level {
-spots: List~Spot~
}
class Spot {
+kind: SpotKind
-occupant: TicketId
}
class Ticket {
<<record>>
+plate: String
+vehicle: VehicleKind
+spot: SpotId
+entered: long
}
class SpotFinder {
<<interface>>
+find(levels, vehicle) Optional~SpotId~
}
class PricingStrategy {
<<interface>>
+fee(vehicle, minutes) Money
}
class MinuteClock {
<<interface>>
+now() long
}
ParkingLot "1" *-- "many" Level
Level "1" *-- "many" Spot
ParkingLot "1" *-- "many" Ticket
ParkingLot o-- SpotFinder
ParkingLot o-- PricingStrategy
ParkingLot o-- MinuteClock
Ticket ..> Spot : SpotId
PricingStrategy <|.. Hourly
PricingStrategy <|.. Capped
SpotFinder <|.. SmallestFit
MinuteClock <|.. FakeClockAPI
Write the signatures first. They show which operations can fail and which change state.
public final class ParkingLot {
public TicketId park(String plate, VehicleKind vehicle); // throws LotException
public Receipt leave(TicketId ticket); // throws LotException
public int freeSpots(SpotKind kind);
}
sealed interface LotError {
record Full(VehicleKind vehicle) implements LotError {}
record AlreadyParked(String plate) implements LotError {}
record UnknownTicket(TicketId ticket) implements LotError {}
}park and leave change state and can be refused. A refusal is an expected business outcome, not a programming error, so the lot reports it with a LotException that carries a sealed LotError. The exception is unchecked, so signatures stay short, and one handler at the gate can switch over the error and cover every case without a default. Reading the number of free spots cannot fail. The caller holds a TicketId, not a Ticket: a ticket is the lot's record, and the caller cannot alter it because Ticket is an immutable record.
Design decisions
Enums for the closed sets. Vehicle kinds and spot kinds are fixed by the requirements. A switch expression over an enum without a default branch must cover every constant, so adding a BUS constant later makes the compiler list every such switch that must decide what a bus needs. The fit rule lives in one method, SpotKind.fits.
An interface for the fee rule. "Rules will change" is the signal for a strategy (see SOLID in Java for when an interface is worth its cost). PricingStrategy takes the vehicle kind and the minutes parked and returns Money. Two implementations are shown: Hourly (rounds up to whole hours, with a grace period) and Capped, which wraps another strategy and limits the fee per day. Capped is a decorator, so rules combine without a class per combination.
An interface for the spot finder. Which spot to hand out is a policy: nearest to the entrance, smallest that fits, spread across levels. The default, SmallestFit, prefers the smallest spot kind that fits and then the lowest level, so large spots stay free for trucks. It is an interface so a different policy can be plugged in without touching the lot.
An interface for the clock. The lot never reads real time. It asks a MinuteClock, and the test or demo supplies a FakeClock that can be advanced by hand. This is the technique described in testability and dependency injection.
IDs instead of object references between spots and tickets. Tickets are stored by TicketId in a TreeMap, which also gives deterministic iteration. A spot stores the TicketId of its occupant, or null while it is free. The null stays private to Spot, which exposes isFree(), so no caller handles it.
Validating at the boundary. park rejects a plate that is already inside, so a data error cannot create two tickets for one vehicle.
The vehicle and spot model
The fit rule is the heart of the domain, so start with it. This program checks the whole table.
import java.util.Arrays;
import java.util.List;
enum VehicleKind { MOTORCYCLE, CAR, TRUCK }
enum SpotKind {
SMALL, REGULAR, LARGE;
/** Constants are declared from the smallest spot to the largest. */
boolean fits(VehicleKind vehicle) {
return switch (vehicle) {
case MOTORCYCLE -> true;
case CAR -> this != SMALL;
case TRUCK -> this == LARGE;
};
}
}
public class Main {
public static void main(String[] args) {
for (VehicleKind vehicle : VehicleKind.values()) {
List<SpotKind> fitting = Arrays.stream(SpotKind.values())
.filter(spot -> spot.fits(vehicle))
.toList();
System.out.println(vehicle + " fits " + fitting);
}
}
}MOTORCYCLE fits [SMALL, REGULAR, LARGE]
CAR fits [REGULAR, LARGE]
TRUCK fits [LARGE]values() returns the constants in declaration order, which is why SpotKind lists them from smallest to largest. The switch expression has no default, so adding a vehicle kind is a compile error here until the new case is written. The this != SMALL and this == LARGE tests encode the table. If a new spot kind is added, those two lines are the ones to review, because a negative test such as != SMALL silently accepts the new kind.
The pricing strategy
The fee rule is the part the interviewer is most likely to change, so it sits behind an interface. Hourly rounds up to whole hours after a grace period. Capped wraps any other strategy and limits the fee per started day, so rules compose without a type per combination. With a 15 minute grace period, 16 minutes costs one hour and 61 minutes costs two. Write such boundaries into the requirements early, since they are where interviewers probe. Both strategies appear in the full implementation below.
Full implementation
The full program combines the model, the finder, the pricing strategy, the clock and the lot. It builds a small lot, parks several vehicles, hits each failure path, advances the fake clock and collects fees. Types that are not Main are top-level package-private types in the same file, which is what the checker needs for a one-file program.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.TreeMap;
// ---- values ----
record Money(long cents) {
Money {
if (cents < 0) throw new IllegalArgumentException("negative money: " + cents);
}
static final Money ZERO = new Money(0);
Money times(long factor) { return new Money(Math.multiplyExact(cents, factor)); }
Money plus(Money other) { return new Money(Math.addExact(cents, other.cents)); }
Money min(Money other) { return cents <= other.cents ? this : other; }
@Override public String toString() { return "$%d.%02d".formatted(cents / 100, cents % 100); }
}
record TicketId(int value) implements Comparable<TicketId> {
@Override public int compareTo(TicketId other) { return Integer.compare(value, other.value); }
}
record SpotId(int level, int index) {
@Override public String toString() { return "L%d-S%d".formatted(level, index); }
}
enum VehicleKind { MOTORCYCLE, CAR, TRUCK }
enum SpotKind {
SMALL, REGULAR, LARGE;
boolean fits(VehicleKind vehicle) {
return switch (vehicle) {
case MOTORCYCLE -> true;
case CAR -> this != SMALL;
case TRUCK -> this == LARGE;
};
}
}
// ---- errors ----
sealed interface LotError {
record Full(VehicleKind vehicle) implements LotError {}
record AlreadyParked(String plate) implements LotError {}
record UnknownTicket(TicketId ticket) implements LotError {}
}
final class LotException extends RuntimeException {
private final LotError error;
LotException(LotError error) {
super(error.toString());
this.error = error;
}
LotError error() { return error; }
}
// ---- collaborators ----
interface MinuteClock {
long now();
}
final class FakeClock implements MinuteClock {
private long minutes;
void advance(long by) { minutes += by; }
@Override public long now() { return minutes; }
}
@FunctionalInterface
interface PricingStrategy {
Money fee(VehicleKind vehicle, long minutes);
}
record Hourly(long graceMinutes, Money motorcycle, Money car, Money truck) implements PricingStrategy {
@Override
public Money fee(VehicleKind vehicle, long minutes) {
if (minutes <= graceMinutes) return Money.ZERO;
Money rate = switch (vehicle) {
case MOTORCYCLE -> motorcycle;
case CAR -> car;
case TRUCK -> truck;
};
return rate.times(Math.ceilDiv(minutes, 60L));
}
}
record Capped(PricingStrategy inner, Money cap) implements PricingStrategy {
private static final long DAY = 24 * 60;
@Override
public Money fee(VehicleKind vehicle, long minutes) {
Money rest = inner.fee(vehicle, minutes % DAY).min(cap);
return cap.times(minutes / DAY).plus(rest);
}
}
// ---- spots and levels ----
final class Spot {
private final SpotKind kind;
private TicketId occupant; // null while the spot is free; never exposed
Spot(SpotKind kind) { this.kind = kind; }
SpotKind kind() { return kind; }
boolean isFree() { return occupant == null; }
void occupy(TicketId ticket) { occupant = ticket; }
void release() { occupant = null; }
}
record Bay(SpotKind kind, int count) {}
final class Level {
private final List<Spot> spots = new ArrayList<>();
Level(Bay... layout) {
for (Bay bay : layout) {
for (int i = 0; i < bay.count(); i++) spots.add(new Spot(bay.kind()));
}
}
Spot spot(int index) { return spots.get(index); }
List<Spot> spots() { return List.copyOf(spots); }
OptionalInt firstFree(SpotKind kind) {
for (int i = 0; i < spots.size(); i++) {
if (spots.get(i).kind() == kind && spots.get(i).isFree()) return OptionalInt.of(i);
}
return OptionalInt.empty();
}
}
@FunctionalInterface
interface SpotFinder {
Optional<SpotId> find(List<Level> levels, VehicleKind vehicle);
}
/** Smallest spot kind that fits, then the lowest level, then the lowest index. */
final class SmallestFit implements SpotFinder {
@Override
public Optional<SpotId> find(List<Level> levels, VehicleKind vehicle) {
for (SpotKind kind : SpotKind.values()) {
if (!kind.fits(vehicle)) continue;
for (int levelNo = 0; levelNo < levels.size(); levelNo++) {
OptionalInt free = levels.get(levelNo).firstFree(kind);
if (free.isPresent()) return Optional.of(new SpotId(levelNo, free.getAsInt()));
}
}
return Optional.empty();
}
}
// ---- tickets and the lot ----
record Ticket(String plate, VehicleKind vehicle, SpotId spot, long entered) {}
record Receipt(TicketId ticket, SpotId spot, long minutes, Money fee) {}
final class ParkingLot {
private final List<Level> levels;
private final Map<TicketId, Ticket> tickets = new TreeMap<>();
private int nextTicket = 1;
private final SpotFinder finder;
private final PricingStrategy pricing;
private final MinuteClock clock;
ParkingLot(List<Level> levels, SpotFinder finder, PricingStrategy pricing, MinuteClock clock) {
this.levels = List.copyOf(levels);
this.finder = finder;
this.pricing = pricing;
this.clock = clock;
}
TicketId park(String plate, VehicleKind vehicle) {
if (tickets.values().stream().anyMatch(ticket -> ticket.plate().equals(plate))) {
throw new LotException(new LotError.AlreadyParked(plate));
}
SpotId spot = finder.find(levels, vehicle)
.orElseThrow(() -> new LotException(new LotError.Full(vehicle)));
var id = new TicketId(nextTicket++);
levels.get(spot.level()).spot(spot.index()).occupy(id);
tickets.put(id, new Ticket(plate, vehicle, spot, clock.now()));
return id;
}
Receipt leave(TicketId id) {
Ticket ticket = tickets.remove(id);
if (ticket == null) throw new LotException(new LotError.UnknownTicket(id));
levels.get(ticket.spot().level()).spot(ticket.spot().index()).release();
long minutes = clock.now() - ticket.entered();
return new Receipt(id, ticket.spot(), minutes, pricing.fee(ticket.vehicle(), minutes));
}
int freeSpots(SpotKind kind) {
return (int) levels.stream()
.flatMap(level -> level.spots().stream())
.filter(spot -> spot.kind() == kind && spot.isFree())
.count();
}
Optional<Ticket> ticket(TicketId id) { return Optional.ofNullable(tickets.get(id)); }
}
public class Main {
static void attempt(String label, java.util.function.Supplier<?> action) {
try {
System.out.println(label + ": " + action.get());
} catch (LotException e) {
System.out.println(label + ": refused " + e.error());
}
}
public static void main(String[] args) {
var clock = new FakeClock();
var levels = List.of(
new Level(new Bay(SpotKind.SMALL, 1), new Bay(SpotKind.REGULAR, 2), new Bay(SpotKind.LARGE, 1)),
new Level(new Bay(SpotKind.REGULAR, 2)));
var hourly = new Hourly(15, new Money(100), new Money(300), new Money(600));
var lot = new ParkingLot(levels, new SmallestFit(), new Capped(hourly, new Money(2500)), clock);
TicketId bike = lot.park("BIKE-1", VehicleKind.MOTORCYCLE);
TicketId carA = lot.park("CAR-A", VehicleKind.CAR);
TicketId carB = lot.park("CAR-B", VehicleKind.CAR);
TicketId carC = lot.park("CAR-C", VehicleKind.CAR);
TicketId truck = lot.park("TRUCK-1", VehicleKind.TRUCK);
var names = new String[] {"bike", "car A", "car B", "car C", "truck"};
var ids = new TicketId[] {bike, carA, carB, carC, truck};
for (int i = 0; i < ids.length; i++) {
System.out.println(names[i] + " -> ticket " + ids[i] + " at " + lot.ticket(ids[i]).orElseThrow().spot());
}
attempt("second truck", () -> lot.park("TRUCK-2", VehicleKind.TRUCK));
attempt("car A again", () -> lot.park("CAR-A", VehicleKind.CAR));
System.out.println("free regular spots: " + lot.freeSpots(SpotKind.REGULAR));
clock.advance(45);
Receipt receipt = lot.leave(carA);
System.out.println(receipt);
System.out.println("fee " + receipt.fee());
clock.advance(200);
for (TicketId id : List.of(truck, bike)) {
Receipt r = lot.leave(id);
System.out.println("ticket " + r.ticket().value() + " after " + r.minutes() + " min: " + r.fee());
}
attempt("truck leaves twice", () -> lot.leave(truck));
System.out.println("free large spots: " + lot.freeSpots(SpotKind.LARGE));
// The freed regular spot is reused by the next car.
TicketId again = lot.park("CAR-D", VehicleKind.CAR);
System.out.println("car D -> " + lot.ticket(again).orElseThrow().spot());
}
}bike -> ticket TicketId[value=1] at L0-S0
car A -> ticket TicketId[value=2] at L0-S1
car B -> ticket TicketId[value=3] at L0-S2
car C -> ticket TicketId[value=4] at L1-S0
truck -> ticket TicketId[value=5] at L0-S3
second truck: refused Full[vehicle=TRUCK]
car A again: refused AlreadyParked[plate=CAR-A]
free regular spots: 1
Receipt[ticket=TicketId[value=2], spot=L0-S1, minutes=45, fee=$3.00]
fee $3.00
ticket 5 after 245 min: $25.00
ticket 1 after 245 min: $5.00
truck leaves twice: refused UnknownTicket[ticket=TicketId[value=5]]
free large spots: 1
car D -> L0-S1Points to notice in the run. The bike takes the only small spot, cars fill regular spots on level 0 and then level 1, and the truck takes the only large spot. The second truck is refused with Full, and the second CAR-A with AlreadyParked, so each failure is a value the caller can match on. The second leave of the same ticket is refused with UnknownTicket, because the first leave removed the record.
The Capped decorator explains the two 245 minute fees. The truck pays 5 started hours at 600 cents, which is 3000 cents before the cap, and the cap lowers it to 2500. The bike pays 5 started hours at 100 cents. Capped applies the cap to each started day, so a stay of 25 hours pays one full day at the cap plus a capped fee for the remaining hour.
Two design points are easy to miss. leave removes the ticket before it computes the fee, so a ticket cannot be paid twice even if a later step fails. And park reserves the spot and records the ticket in one method, with no code path where a spot is marked occupied without a ticket.
Testing the lot
Because the clock is injected, a test can jump forward a day. These checks use plain assert statements, because the course uses no test library and the checker runs programs with assertions enabled. Each check is a one-line claim about the pricing rules and the money format.
record Money(long cents) {
@Override public String toString() { return "$%d.%02d".formatted(cents / 100, cents % 100); }
}
public class Main {
static Money hourlyFee(long rateCents, long grace, long minutes) {
return minutes <= grace ? new Money(0) : new Money(rateCents * Math.ceilDiv(minutes, 60L));
}
static void gracePeriodIsFree() {
assert hourlyFee(300, 15, 15).equals(new Money(0));
}
static void partialHoursRoundUp() {
assert hourlyFee(300, 15, 61).equals(new Money(600));
assert hourlyFee(300, 15, 16).equals(new Money(300));
}
static void moneyFormatsWithTwoDigits() {
assert new Money(2505).toString().equals("$25.05");
assert new Money(5).toString().equals("$0.05");
}
public static void main(String[] args) {
gracePeriodIsFree();
partialHoursRoundUp();
moneyFormatsWithTwoDigits();
System.out.println("3 checks passed");
}
}3 checks passedConcurrency
Real gates run in parallel. The simplest correct approach is one lock around the whole lot, because park reads and writes several structures that must stay consistent. This sketch shows the shape with a reduced lot that only counts free spots, and eight tasks that race for three spots. Each task runs on a virtual thread, and synchronized makes the check and the decrement one step.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
final class CountingLot {
private int free;
CountingLot(int free) { this.free = free; }
synchronized boolean tryPark() {
if (free == 0) return false;
free--;
return true;
}
synchronized int free() { return free; }
}
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
var lot = new CountingLot(3);
int admitted = 0;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Boolean>> results = new ArrayList<>();
for (int i = 0; i < 8; i++) results.add(executor.submit(lot::tryPark));
for (Future<Boolean> result : results) {
if (result.get()) admitted++;
}
}
System.out.println("admitted " + admitted + " of 8 for 3 spots");
System.out.println("free now: " + lot.free());
}
}admitted 3 of 8 for 3 spots
free now: 0The check and the decrement happen under one lock, so exactly three tasks win no matter how they interleave. The full lot gets the same treatment: make park, leave and freeSpots synchronized, or hold a ReentrantLock in the lot. Everything the lot holds must then be safe to read from any thread. Money, Ticket and the strategy records are immutable, so they are. A real MinuteClock must be safe to call from several threads, while the single-threaded FakeClock above is not meant for that. See thread-safe components for lock granularity choices such as one lock per level.
Extensions
| Follow-up | Where the change goes |
|---|---|
| Add a bus vehicle or an electric-vehicle spot kind | New enum constant. The compiler lists each switch expression to update, and SpotKind.fits gets a new case. |
| Weekend or peak-hour rates | A new PricingStrategy. It needs the entry time, so change the interface to take the entry and exit minutes, or wrap the clock in a calendar. |
| Monthly pass holders park free | A Pass wrapper strategy that returns Money.ZERO when the plate has a pass, or a ticket variant PassTicket in a sealed Ticket hierarchy. |
| Reserve a spot ahead of time | A reserved state on the spot. The occupant becomes a sealed interface with Free, Reserved and Occupied records. |
| Show free spots on a display per level | An observer on park and leave events. See event bus and observers. |
| Find my car by plate | A Map<String, TicketId> index, updated in park and leave. It also makes the duplicate-plate check constant time. |
| Lost ticket | A leaveByPlate path plus a flat penalty fee from the pricing strategy. |
| Several entry gates in parallel | One lock around the lot, or a lock per level with the finder scanning levels in a fixed order to avoid deadlock. |
The duplicate-plate check in park scans every ticket. That is fine for hundreds of spots and worth naming as a trade-off. The plate index in the table removes the scan at the cost of keeping two structures consistent, which is exactly the kind of invariant that belongs inside the lot and nowhere else. See encapsulation and invariants.
Summary
- Sort the nouns: closed sets become enums (
VehicleKind,SpotKind), varying behavior becomes interfaces (PricingStrategy,SpotFinder,MinuteClock), plain values become records (Money,TicketId,SpotId). - The lot owns levels and tickets in a tree. Tickets and spots refer to each other by ID, so there are no reference cycles.
- Put the vehicle-to-spot fit rule in one method so a new kind is a single-point change.
- Compose fee rules with a decorator such as
Cappedinstead of writing a class per combination. - Inject a fake clock in minutes, so demos and tests are deterministic.
- Report a full lot, a duplicate vehicle and an unknown ticket with a
LotExceptionthat carries a sealedLotError, and makeleaveremove the ticket record before it prices the stay. - For concurrent gates, start with one lock around the lot and refine only if contention is measured.
