A low-level design interview asks you to turn a short problem statement (“design a parking lot”) into classes, relationships and working code, and to defend your choices. Interviewers are not grading the number of classes; they watch whether you clarify requirements, pick sensible entities, use patterns only where they pay off, and notice concurrency. This post solves the classic parking lot in five steps, in plain Java 25 with no framework, and every claim is backed by output from a real run.
| Step | What you do | What the interviewer checks |
|---|---|---|
| 1. Requirements | Ask, list what is in and out of scope | You do not start coding blindly |
| 2. Entities | Nouns become types: vehicle, spot, floor, ticket | Small immutable types, one rule in one place |
| 3. Core flow | park() and exit() on one aggregate | Correct, simple, testable |
| 4. Patterns | Strategy for allocation and pricing, Observer for the display board | Patterns solve a stated need |
| 5. Concurrency and extensions | Two cars, one spot; EV spots, reservations | You think beyond the happy path |
Here is the result in ten lines. A car enters, stays 2 hours 10 minutes, and pays; three kinds of failure are reported with clear messages (real output of Demo.java):
car stayed 130 min, fee = 60
second MH03-TRK : MH03-TRK is already inside the lot
another truck : no free spot for TRUCK MH04-TRK
reused ticket : unknown or already used ticket T-2
Versions used here. JDK 25.0.4.1 (LTS), also compiled and run with --release 21 on OpenJDK 21 with identical output; JUnit Jupiter 6.1.3 for tests only; Maven 3.9.11. No runtime dependencies. Code: lld-parking-lot in java-interview-lab. Checked 22 October 2026.
Step 1: Clarify the requirements first
Spend the first two minutes asking questions. These are the answers this solution assumes; say them out loud in an interview so the interviewer can correct you.
Functional requirements (in scope)
- Several floors, each with spots of three sizes: small, medium, large.
- Three vehicle types: motorcycle (fits any spot), car (medium or large), truck (large only).
- Entering gives a ticket; leaving with the ticket gives a receipt with the fee.
- Fee: per-hour rate by vehicle type, partial hours round up, with a free grace period.
- The same plate cannot be inside twice; a ticket works once.
- An entrance display board shows free spots by size.
Non-functional requirements and explicit non-goals
- Thread-safe: many gates can call
park()at once; no spot is ever given to two cars. - Testable: time comes from an injected
Clock, so tests never sleep. - Pricing and allocation rules must be replaceable without editing the lot.
- Not in scope here: payments, persistence, reservations, EV charging. They appear under “Extensions” below, because interviewers ask.
Step 2: Turn the nouns into small types
Facts that never change after creation are records or enums. The only mutable thing in the whole model is whether a spot is occupied.
public enum VehicleType {
MOTORCYCLE(SpotSize.SMALL), CAR(SpotSize.MEDIUM), TRUCK(SpotSize.LARGE);
private final SpotSize minimumSpot;
VehicleType(SpotSize minimumSpot) { this.minimumSpot = minimumSpot; }
public SpotSize minimumSpot() { return minimumSpot; }
}
public enum SpotSize {
SMALL, MEDIUM, LARGE;
/** A spot can hold a vehicle when it is at least as big as the vehicle's minimum spot. */
public boolean canHold(VehicleType type) { return this.ordinal() >= type.minimumSpot().ordinal(); }
}
public record Vehicle(String plate, VehicleType type) { /* compact constructor rejects null or blank plate */ }
public record Ticket(String id, Vehicle vehicle, String spotId, Instant entry) {}
public record Receipt(Ticket ticket, Instant exit, Duration duration, BigDecimal fee) {}
Notice that “does this vehicle fit this spot?” lives in exactly one method, SpotSize.canHold. Because the enum is ordered small to large, the rule is a single comparison, and adding a size means adding one constant.
Why enums and records instead of a class hierarchy (Car extends Vehicle)?
A subclass per vehicle would only differ by one data value, the minimum spot size. Data that varies belongs in a field, not in a type. A hierarchy would also tempt you to scatterinstanceof checks. Records give immutability, equals and hashCode for free, which makes tickets and vehicles safe to share between threads. See Java records.
Step 3: The one aggregate, ParkingLot
A single class owns the flow. Two details make it correct under concurrency without a global lock. First, the spot is claimed with one compare-and-set:
public final class ParkingSpot {
private final AtomicReference<Vehicle> occupant = new AtomicReference<>();
// id, floor, size ...
/** Atomically claims the spot. Returns false if another thread got there first or the vehicle does not fit. */
public boolean tryOccupy(Vehicle v) {
return size.canHold(v.type()) && occupant.compareAndSet(null, v);
}
public void release() { occupant.set(null); }
}
Second, park() and exit() use concurrent collections as their guards: a concurrent set stops the same plate entering twice, and removing a ticket from a ConcurrentHashMap is what makes it single-use.
public Ticket park(Vehicle vehicle) {
if (!platesInside.add(vehicle.plate())) throw new ParkingException.AlreadyParked(vehicle.plate());
for (ParkingSpot spot : allocation.candidates(floors, vehicle)) {
if (spot.tryOccupy(vehicle)) { // lost a race? try the next candidate
Ticket t = new Ticket("T-" + ticketSequence.incrementAndGet(), vehicle, spot.id(), clock.instant());
activeTickets.put(t.id(), t);
publish();
return t;
}
}
platesInside.remove(vehicle.plate()); // undo the plate reservation
throw new ParkingException.LotFull(vehicle);
}
public Receipt exit(String ticketId) {
Ticket t = activeTickets.remove(ticketId); // atomic: only one caller can get the ticket
if (t == null) throw new ParkingException.UnknownTicket(ticketId);
Instant now = clock.instant();
Duration stayed = Duration.between(t.entry(), now);
var receipt = new Receipt(t, now, stayed, pricing.fee(t.vehicle(), stayed));
spotsById.get(t.spotId()).release();
platesInside.remove(t.vehicle().plate());
publish();
return receipt;
}
The strategy only proposes candidate spots in order; it never claims one. Claiming stays in ParkingSpot.tryOccupy, so a strategy bug can never double-book. If a candidate is taken by another thread in the meantime, the loop simply tries the next. Note the undo of the plate reservation when the lot is full: without it, a rejected truck would be locked out forever (test failedParkDoesNotLeaveThePlateReserved covers this).
Why not just make park() synchronized?
It would be correct and simpler, and for one entrance it is a perfectly good answer. The cost is that every gate waits on every other, including the slow ones that compute candidate lists. The compare-and-set design keeps the critical section down to one atomic operation per spot. In an interview, say both: start with the simple lock, then explain what you would change if profiling showed contention. Background on atomics and the memory model: the Java memory model explained.
Step 4: Patterns, only where the requirements ask for them
Two rules were stated as “must be replaceable”: where to put a vehicle and how to charge it. Those are two Strategy interfaces. The display board is an Observer. Nothing else needs a pattern, and saying that is itself a good interview answer.
public interface AllocationStrategy {
List<ParkingSpot> candidates(List<ParkingFloor> floors, Vehicle vehicle);
}
/** Smallest spot that fits, nearest first among equals. Keeps large spots free for trucks. */
public final class BestFitStrategy implements AllocationStrategy {
public List<ParkingSpot> candidates(List<ParkingFloor> floors, Vehicle vehicle) {
return floors.stream()
.flatMap(f -> f.spots().stream())
.filter(ParkingSpot::isFree)
.filter(s -> s.size().canHold(vehicle.type()))
.sorted(Comparator.comparing(ParkingSpot::size).thenComparingInt(ParkingSpot::floor))
.toList();
}
}
public interface PricingStrategy { BigDecimal fee(Vehicle vehicle, Duration stayed); }
public final class HourlyPricing implements PricingStrategy {
public BigDecimal fee(Vehicle vehicle, Duration stayed) {
if (stayed.compareTo(grace) <= 0) return BigDecimal.ZERO;
long minutes = stayed.toMinutes() + (stayed.toSecondsPart() > 0 ? 1 : 0);
long hours = Math.max(1, (minutes + 59) / 60);
return ratePerHour.get(vehicle.type()).multiply(BigDecimal.valueOf(hours));
}
}
@FunctionalInterface
public interface AvailabilityListener { void changed(Map<SpotSize, Long> freeBySize); }
The same motorcycle is placed differently by the two allocation strategies, which is the point of making them replaceable (real output):
NearestFirstStrategy puts a motorcycle in F0-1
BestFitStrategy puts a motorcycle in F1-1
With a large spot on floor 0 and a small one on floor 1, nearest-first wastes the large spot on a motorcycle; best-fit keeps it for a truck. The hourly fee for the 130-minute stay above is 3 started hours × 20 = 60, because partial hours round up.
Step 5: Walk through the real run
This is the first section of Demo.java: entering three vehicles, the display board updating after every change, and a car leaving. The clock is moved by hand, so “2 hours later” takes no time.
== 1. entering and leaving ==
board: {SMALL=1, MEDIUM=2, LARGE=1}
board: {SMALL=1, MEDIUM=1, LARGE=1}
board: {SMALL=1, MEDIUM=1, LARGE=0}
bike -> F0-1, car -> F0-2, truck -> F0-3
board: {SMALL=1, MEDIUM=2, LARGE=0}
car stayed 130 min, fee = 60
== 2. failures ==
second MH03-TRK : MH03-TRK is already inside the lot
another truck : no free spot for TRUCK MH04-TRK
reused ticket : unknown or already used ticket T-2
Read it as a check of the design. The bike takes a small spot (F0-1), not a bigger one, because best-fit chose the smallest that fits. Every successful change prints one board line, so the Observer fires exactly once per change. The three failures each come from a different ParkingException subtype, so callers can handle them separately.
Step 6: Prove the concurrency claim
“It is thread-safe” is only worth saying if a test would fail otherwise. Two tests release many threads at the same instant with a CountDownLatch:
- 64 threads, 10 spots: exactly 10 get a ticket, exactly 54 get
LotFull, and the 10 spot ids are all different. - 16 threads, the same plate: exactly one is admitted and three spots stay free.
PASS truckIsRejectedWhenOnlyMediumSpotsAreFree
PASS listenersSeeEveryChange
PASS samePlateCannotBeInsideTwice
PASS bestFitKeepsLargeSpotsFreeForTrucks
PASS concurrentDuplicatePlateAdmitsExactlyOne
PASS failedParkDoesNotLeaveThePlateReserved
PASS nearestFirstTakesTheFirstFreeSpotThatFits
PASS feeRoundsPartialHoursUpAndHasAGracePeriod
PASS concurrentParkingNeverDoubleBooksASpot
PASS exitFreesTheSpotAndTheTicketIsSingleUse
10 tests, 0 failures
A concurrency test that passes once proves little, so the repository also keeps the generated report in output/02-test-results.txt. To go deeper on the building blocks used here, see Top 40 concurrency interview questions and Top 40 collections questions (ConcurrentHashMap and sets).
Step 7: Extensions the interviewer will ask about
How do you add EV charging spots?
Add aSpotKind (regular, EV) or a capability set on ParkingSpot, and make Vehicle carry a flag. canHold becomes a method on the spot that checks size and capability, and BestFitStrategy sorts non-EV spots first for non-EV cars. Pricing gets a charging surcharge as another PricingStrategy composed with the hourly one. No change to ParkingLot itself.
How do you support reservations?
Introduce aReservation with a spot, a plate and an expiry. A reserved spot is not free for ordinary allocation until the reservation expires or is used. Store AtomicReference<State> (free, reserved, occupied) instead of just the vehicle so the compare-and-set still decides the winner.
What if there are several servers or the app restarts?
In-memory state is lost on restart and cannot be shared. Persist spots, tickets and plates in a database. ReplacetryOccupy with an atomic update such as UPDATE spot SET plate = ? WHERE id = ? AND plate IS NULL (zero rows changed means another gate won), and the plate guard with a unique constraint. This is the same compare-and-set idea, enforced by the database.
How would you take payment?
Keepexit() pure (compute the receipt) and add a separate PaymentService that takes the receipt. Release the spot only after payment succeeds, or release it and track an unpaid receipt, depending on the business rule. Use BigDecimal for money, as the pricing class already does.
Is the display board count always exact under heavy load?
No.freeBySize() reads spots one by one while others change, so the snapshot is eventually consistent, which is fine for a sign. Correctness never depends on it, because allocation uses tryOccupy. If you need an exact count, keep an AtomicInteger per size updated in park() and exit().
Going deeper. Every design choice above maps to a principle from SOLID principles in Java: Strategy is Open/Closed, injecting the strategies and the clock is Dependency Inversion. Series: GoF design patterns in Java. Source: ParkingLot.java, ParkingLotTest.java.
Common interview questions
1. Which design patterns would you use in a parking lot, and why?
Strategy for allocation and pricing because both rules change independently; Observer for the display board. Mention that you would not add Factory or Singleton without a concrete need.2. How do you stop two cars getting the same spot?
Claim the spot with an atomic compare-and-set (AtomicReference.compareAndSet(null, vehicle)); the loser tries the next candidate. A synchronized method also works for a single process.
3. Why inject a Clock?
So tests can move time by hand instead of sleeping, and so a 2-hour-10-minute stay is reproducible. Calling Instant.now() inside the lot would make the fee untestable.
4. How would you model the fee rules if they get complicated (night rates, daily cap)?
Keep thePricingStrategy interface and compose strategies: a capped strategy wrapping an hourly one (Decorator). Never put rate logic in ParkingLot.
5. Which classes would you test first?
HourlyPricing (grace period and rounding, pure function), then ParkingLot flows, then the two concurrency tests. Pure rules first, shared state last.
Common mistakes
- Starting to draw classes before asking a single requirement question.
- A
Car,Bike,Truckhierarchy that differs only by one constant. - Letting the allocation strategy claim the spot, so a buggy strategy can double-book.
- Checking
isFree()and then occupying in two steps. That is a race; the check and the claim must be one atomic operation. - Forgetting to undo a reservation (the plate set) when the lot turns out to be full.
- Calling
Instant.now()directly, which makes fees untestable. - Adding patterns the requirements did not ask for.
Should you build this much in a 45-minute interview? No. Cover requirements, entities,park()andexit()in order, and describe the strategies and concurrency verbally. The repository is here so you can see the complete version once and practise the parts.
No Comments yet!