Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Parking Lot: spot assignment, patterns and the concurrency question
The model is on the board: floors that own their availability, a ticket that records the session, and pricing behind an interface. The last twenty minutes test whether it works and whether it was built for change — the spot-assignment code where the design shows, the patterns and why, the extension questions, and what happens when two gates reach the last free spot at once.
Spot assignment, in code
This is the method worth writing. It touches both non-functional requirements — the lookup speed and the double-assignment risk — so it is where a good model produces short code and a bad one produces a mess.
The naive version, and why it fails
Start here, because it is what most first attempts produce:
1public Ticket park(Vehicle vehicle) {2 for (Floor floor : floors) {3 for (ParkingSpot spot : floor.getSpots()) { // scans everything4 if (!spot.isOccupied() && spot.getSize().fits(vehicle.getSize())) {5 spot.assign(vehicle);6 return new Ticket(nextId(), vehicle, spot, clock.now());7 }8 }9 }10 throw new NoSpotAvailableException(vehicle.getSize());11}Two problems, both scored.
It scans. A 2,000-spot lot that is nearly full walks almost every spot on every entry. That violates requirement N1.
It is not safe under two threads. Between isOccupied() returning false and assign() running, another gate's thread can assign the same spot. Both cars are told to park in bay C-114. The extensions at the end of this lesson fix this properly.
The availability index
Keep the free spots of each size in a queue per floor, and the lookup becomes a poll.
1public class Floor {2 private final int floorNumber;3 private final Map<SpotSize, Deque<ParkingSpot>> free = new EnumMap<>(SpotSize.class);45 public Optional<ParkingSpot> claimFree(SpotSize size) {6 Deque<ParkingSpot> queue = free.get(size);7 ParkingSpot spot = (queue == null) ? null : queue.pollFirst();8 return Optional.ofNullable(spot);9 }1011 public void release(ParkingSpot spot) {12 free.get(spot.getSize()).addLast(spot);13 }1415 public int countFree(SpotSize size) { return free.get(size).size(); }16}claimFree removes the spot from the free queue as it returns it, so the same spot cannot be handed out twice by two calls. That single design choice — claim, do not peek — removes most of the concurrency problem before any lock is added.
Size fitting, without an if-else ladder
A car should take a medium spot, and a large one if no medium is free. Encode the ordering in the enum rather than in branches:
1public enum SpotSize {2 SMALL(1), MEDIUM(2), LARGE(3);3 private final int rank;4 SpotSize(int rank) { this.rank = rank; }5 public boolean fits(VehicleSize v) { return this.rank >= v.rank(); }6 public static List<SpotSize> fittingSizes(VehicleSize v) { // smallest first7 return Arrays.stream(values()).filter(s -> s.fits(v)).toList();8 }9}Now the allocation reads as intent:
1public class SpotAllocator {2 private final List<Floor> floors;34 public ParkingSpot allocate(VehicleSize size) {5 for (SpotSize candidate : SpotSize.fittingSizes(size)) { // smallest fitting first6 for (Floor floor : floors) {7 Optional<ParkingSpot> spot = floor.claimFree(candidate);8 if (spot.isPresent()) return spot.get();9 }10 }11 throw new NoSpotAvailableException(size);12 }13}Loop order matters and is worth saying out loud: size outer, floor inner means a car takes a medium spot on floor five before a large spot on floor one, preserving large spots for trucks. Reversing the loops fills the lot floor by floor and strands trucks. That is a one-line policy decision with a real operational consequence, and naming it is a scoring moment.
The park and unpark flow
1public Ticket park(Vehicle vehicle) {2 ParkingSpot spot = allocator.allocate(vehicle.getSize());3 spot.assign(vehicle);4 Ticket ticket = new Ticket(idGenerator.next(), vehicle, spot, clock.now());5 activeTickets.put(ticket.getId(), ticket);6 return ticket;7}89public Money unpark(String ticketId) {10 Ticket ticket = activeTickets.remove(ticketId);11 if (ticket == null) throw new UnknownTicketException(ticketId);12 ticket.markExit(clock.now());13 Money fee = pricingStrategy.computeFee(ticket);14 ticket.getSpot().release();15 ticket.getSpot().getFloor().release(ticket.getSpot());16 return fee;17}Note clock is injected rather than System.currentTimeMillis() — the tip from Phase 5: coding the core, and what to skip, and the thing that makes fee calculation testable without waiting an hour.
Patterns applied
Two patterns belong in this design. Both are justified against writing an if-else, honestly — including the case where the if-else would have been fine.
Strategy, for pricing
The mess it removes. Without it, the fee calculation lives inside ParkingLot or Ticket and grows a branch per rule:
1Money computeFee(Ticket t) {2 long minutes = t.durationMinutes();3 if (minutes <= 30) return Money.rupees(20); // flat first half-hour4 if (isWeekend(t.getEntryTime())) return Money.rupees(150); // weekend flat rate5 if (t.hasMonthlyPass()) return Money.ZERO; // pass holders6 long hours = (long) Math.ceil(minutes / 60.0);7 if (t.getSpot().getSize() == SMALL) return Money.rupees(20 * hours);8 if (t.getSpot().getSize() == MEDIUM) return Money.rupees(50 * hours);9 return Money.rupees(100 * hours);10}Four rules, one method, and each new rule is an edit to a method that already handles money. hasMonthlyPass() has also leaked a subscription concept onto Ticket.
The repair.
1public interface PricingStrategy { Money computeFee(Ticket ticket); }23public class HourlyPricing implements PricingStrategy {4 private final Map<SpotSize, Money> ratePerHour;5 private final Money firstHalfHourFlat;67 public Money computeFee(Ticket ticket) {8 long minutes = ticket.durationMinutes();9 if (minutes <= 30) return firstHalfHourFlat;10 long hours = (long) Math.ceil(minutes / 60.0);11 return ratePerHour.get(ticket.getSpot().getSize()).times(hours);12 }13}14// WeekendFlatPricing, MonthlyPassPricing implement the same one method.The justification, said out loud: "Pricing is the thing most likely to change in this system. The lot's structure will not change for years; the rate card changes every quarter and promotions change monthly. So pricing goes behind an interface and a new rule is a new class rather than an edit to a method that already handles money."
That sentence is the point of the pattern. The classes are the easy part.
Factory, for creating vehicles and spots
The mess it removes. Vehicle creation happens at every entry gate, and the mapping from an input type to a class is duplicated at each one.
1public final class VehicleFactory {2 public static Vehicle create(VehicleType type, String plate) {3 return switch (type) {4 case MOTORCYCLE -> new Motorcycle(plate);5 case CAR -> new Car(plate);6 case TRUCK -> new Truck(plate);7 };8 }9}The honest justification. A static factory method is worth it here because gates are plural: four entry gates each turning a scanned vehicle type into an object. Without it, adding a van type means finding four switch statements.
And the honest limit. This is a static factory method, not an Abstract Factory. There is no family of related products, no second factory implementation, and no runtime choice of factory. Say so: "A static method is enough here — an abstract factory would add a class and buy nothing, because there is only one product family." That sentence, from When not to use a pattern, earns the same credit as using the pattern.
The patterns to leave out
| Pattern candidates reach for | Why not here |
|---|---|
Singleton for ParkingLot | Global mutable state; makes two lots or two tests impossible. Create one at startup and pass it in |
| Observer on spot occupancy | Nothing needs to react. Add it if a display board becomes a requirement |
State on ParkingSpot | Two states (free, occupied) with no per-state behaviour. A boolean is correct |
Builder for Ticket | Four constructor arguments, all required. A constructor is correct |
Saying "no State here — the spot has two states and no behaviour that differs between them, so a boolean is honest" is a stronger answer than adding two state classes.
Extensions
The last five minutes. The interviewer asks one or two of these, and your answer is graded on whether the model absorbs the change or has to be rebuilt.
1. Electric-vehicle charging spots
The trap is ElectricCar extends Car. That is a second dimension of variation (Inheritance, and its limits) — an electric car is a car and needs charging — and inheritance can only express one.
The clean answer:
1public class ParkingSpot {2 private final SpotSize size;3 private final Set<SpotFeature> features; // CHARGING, COVERED, ACCESSIBLE, VALET4}5public class Vehicle {6 private final Set<SpotFeature> required; // empty for most vehicles7}Allocation gains one filter: a spot must fit the size and carry every required feature. This handles covered parking, accessible bays, and valet spots with no further classes — which is the sign you picked the right axis.
2. Reservations
A reservation is a claim on a spot for a future window, so claimFree must not hand out a spot that is reserved for the time you would occupy it. Two honest options:
- Reserve a specific spot. Simple, and wasteful — the spot sits empty until the holder arrives, and if they never do you have lost it.
- Reserve a size, not a spot. Keep a count of reserved spots per size per window, and let
claimFreerefuse to drop free capacity below that count. More code, far better occupancy.
Recommend the second, and say why: a lot's economics are occupancy, and holding named bays empty is how you lose 5–10% of them on a busy day.
3. Multiple lots
Say plainly that this crosses into distributed system design. Within this model, extract a ParkingLotService that holds many lots and routes by location; the lot's internals are unchanged. The moment lots live on different machines, availability becomes a shared-state problem and the answer is a database or a coordination service, not a class.
4. The concurrency question — raise it yourself
This problem always ends here. Raise it before the interviewer does; candidates who do read as senior.
"One thing I want to name before we finish: with four entry gates, two threads can reach the last medium spot at the same moment. Let me walk through how I'd prevent that."
Three options, honestly compared:
A. One lock over the whole lot. Correct and trivially explainable. Every entry serialises, which at a few cars a minute is completely fine. Cost: unnecessary contention, and it blocks unrelated floors.
B. A lock per floor per size — lock only the queue you are polling.
1public Optional<ParkingSpot> claimFree(SpotSize size) {2 Deque<ParkingSpot> queue = free.get(size);3 synchronized (queue) {4 return Optional.ofNullable(queue.pollFirst());5 }6}Two gates looking for different sizes, or on different floors, never block each other.
C. A concurrent queue and no explicit lock. Use ConcurrentLinkedDeque and rely on pollFirst() being atomic — one caller gets the spot, the other gets null and tries the next floor. No lock at all.
Recommendation: C for the claim, with option A around the ticket-plus-spot update if the two must be consistent. The reasoning to say out loud: the atomic poll is exactly the operation we need, contention is low, and the claim-do-not-peek design from the spot-assignment code above already removed the check-then-act race. Reach for a coarse lock only if more than one thing must change together.