Implement LLD for Parking Lot: Code Walkthrough
Theory is good, code is better. We implement the Parking Lot system in Java, focusing on the Singleton, Factory, and Strategy patterns.
Abstract AlgorithmsAI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
TLDR: This is the code companion to the Parking Lot System Design post. We implement the core classes (
ParkingLot,ParkingSpot,Ticket) in Java, apply the Singleton, Factory, and Strategy patterns, and use a Min-Heap to find the nearest available spot in O(log n).
๐ TLDR: Design-to-Code โ What We Are Building
A parking lot LLD interview question trips up most candidates not because of the OOP โ but because interviewers expect you to spot the concurrency problem: two cars booking the same spot simultaneously. This post walks through the exact design that handles it.
Requirements we implement:
- Support multiple vehicle types:
CAR,TRUCK,BIKE. - Multiple floors, each with typed spots.
- A
Ticketissued on entry; freed on exit with fee calculated. - Find the nearest available spot for the vehicle's type.
- Thread-safe: multiple entry gates run concurrently.
๐ข The Domain Model: Vehicles, Spots, and Tickets
Vehicle Hierarchy
public enum VehicleType { BIKE, CAR, TRUCK }
public abstract class Vehicle {
private final String licensePlate;
private final VehicleType type;
protected Vehicle(String licensePlate, VehicleType type) {
this.licensePlate = licensePlate;
this.type = type;
}
public VehicleType getType() { return type; }
public String getLicensePlate() { return licensePlate; }
}
public class Car extends Vehicle { public Car(String plate) { super(plate, VehicleType.CAR); } }
public class Truck extends Vehicle { public Truck(String plate) { super(plate, VehicleType.TRUCK); } }
public class Bike extends Vehicle { public Bike(String plate) { super(plate, VehicleType.BIKE); } }
Parking Spot Hierarchy
public abstract class ParkingSpot {
private final int id;
private volatile boolean isFree = true;
private final VehicleType type;
private Vehicle parkedVehicle;
protected ParkingSpot(int id, VehicleType type) {
this.id = id;
this.type = type;
}
public synchronized boolean assign(Vehicle v) {
if (!isFree) return false;
this.parkedVehicle = v;
this.isFree = false;
return true;
}
public synchronized void free() {
this.parkedVehicle = null;
this.isFree = true;
}
public boolean isFree() { return isFree; }
public int getId() { return id; }
public VehicleType getType() { return type; }
// Subclasses declare which vehicle types they accept
public abstract boolean canFit(VehicleType type);
}
public class BikeSpot extends ParkingSpot {
public BikeSpot(int id) { super(id, VehicleType.BIKE); }
@Override public boolean canFit(VehicleType type) { return type == VehicleType.BIKE; }
}
public class CompactSpot extends ParkingSpot {
public CompactSpot(int id) { super(id, VehicleType.CAR); }
@Override public boolean canFit(VehicleType type) { return type == VehicleType.CAR; }
}
public class LargeSpot extends ParkingSpot {
public LargeSpot(int id) { super(id, VehicleType.TRUCK); }
// A large spot fits both trucks AND cars โ real-world nuance expressed via the override
@Override public boolean canFit(VehicleType type) {
return type == VehicleType.CAR || type == VehicleType.TRUCK;
}
}
๐ ParkingSpot Lifecycle States
stateDiagram-v2
[*] --> FREE : spot created and added to heap
FREE --> OCCUPIED : assign(vehicle) synchronized
OCCUPIED --> FREE : free() spot re-inserted into heap
FREE --> [*] : lot decommissioned
The state diagram shows the complete two-state lifecycle every ParkingSpot instance goes through: it enters FREE when addSpot() places it into the floor's Min-Heap, transitions to OCCUPIED when assign(vehicle) succeeds inside a synchronized block, and returns to FREE when free() is called on exit โ at which point the spot is immediately re-inserted into the heap for the next arrival. The synchronized keyword on both assign() and free() is what makes this state machine thread-safe: two threads competing to take the same free spot will serialize at the monitor, so only one will see isFree == true and proceed. The FREE โ [*] edge represents lot decommissioning โ a rare but necessary transition that prevents the heap from holding references to removed spots.
โ๏ธ ParkingFloor: Managing Spots via a Min-Heap
Each floor maintains a Min-Heap per vehicle type. The heap is ordered by spot ID โ the smallest ID = nearest to the entrance.
Why Min-Heap?
findNearest()โO(1)peek,O(log n)remove.freeSpot()โO(log n)re-insert.- Outperforms linear scan
O(n)at scale.
import java.util.PriorityQueue;
import java.util.Map;
import java.util.HashMap;
public class ParkingFloor {
private final int floorNumber;
private final Map<VehicleType, PriorityQueue<ParkingSpot>> availableSpots;
public ParkingFloor(int floorNumber) {
this.floorNumber = floorNumber;
availableSpots = new HashMap<>();
for (VehicleType type : VehicleType.values()) {
availableSpots.put(type,
new PriorityQueue<>((a, b) -> a.getId() - b.getId()));
}
}
public synchronized ParkingSpot findAndAssign(Vehicle v) {
PriorityQueue<ParkingSpot> heap = availableSpots.get(v.getType());
if (heap.isEmpty()) return null;
ParkingSpot spot = heap.poll(); // O(log n)
spot.assign(v);
return spot;
}
public synchronized void releaseSpot(ParkingSpot spot) {
spot.free();
availableSpots.get(spot.getType()).offer(spot); // O(log n)
}
public void addSpot(ParkingSpot spot) {
availableSpots.get(spot.getType()).offer(spot);
}
}
flowchart TD
Vehicle[Vehicle.getType() = CAR] --> Floor[ParkingFloor.findAndAssign()]
Floor --> Heap[MinHeap[CompactSpot] peek Spot #3]
Heap -->|poll O(log n)| Spot[Spot #3: assign(vehicle)]
Spot --> Ticket[Issue Ticket(spotId=3, entryTime=now)]
This flowchart traces the critical path inside a single findAndAssign() call for a CAR: ParkingFloor looks up the CompactSpot Min-Heap for the CAR type, peeks at the root to confirm availability, then polls the root node โ the spot with the lowest ID โ in O(log n) as the heap re-heapifies. Spot #3 is the specific example shown: it is the nearest compact spot to the entrance because spot IDs are assigned in order of physical proximity, so the smallest ID in the heap always maps to the closest available position. Crucially, poll() removes the spot from the heap atomically within the synchronized block, so any concurrent findAndAssign() call on the same floor is guaranteed to see a heap that no longer contains Spot #3 โ preventing a double-booking race without any spot-level locking at this stage.
๐ System Flow: Vehicle Entry to Exit
When a vehicle arrives at the gate, the request flows through three layers before a spot is assigned and a ticket issued. Understanding this top-down call chain makes it clear why synchronization lives at the floor level rather than the lot level.
flowchart TD
A[Vehicle arrives at gate] --> B[Gate calls ParkingLot.park(vehicle)]
B --> C[Iterate ParkingFloor list]
C --> D{Floor has available spot for vehicle type?}
D -->|No| C
D -->|Yes| E[floor.findAndAssign(vehicle) Min-Heap poll O log n]
E --> F[spot.assign(vehicle) synchronized]
F --> G[Issue Ticket vehicle + spot + floor + entryTime]
G --> H[Vehicle parks]
H --> I[Vehicle exits: lot.exit(ticket)]
I --> J[floor.releaseSpot(spot) Min-Heap re-insert]
J --> K[FeeCalculator.calculate type + duration]
K --> L[Return fee, spot freed]
This end-to-end flowchart exposes two structural decisions that are easy to miss when reading the code alone. First, the D โ C back-edge represents the floor iteration loop in ParkingLot.park(): the lot scans floors in order and retries until it finds one with capacity, which means floor ordering matters โ a low-numbered floor will fill up faster than a high-numbered one. Second, node J (releaseSpot()) precedes K (FeeCalculator.calculate) in the exit path: the spot is returned to the heap before billing runs, so it is immediately available for new arrivals rather than being held until the fee transaction completes. This sequencing ensures that a slow fee calculation (e.g., calling an external payment gateway) never blocks spot availability โ a non-obvious concurrency benefit of separating the allocation concern from the pricing concern.
The exit path mirrors entry in reverse: the spot is re-inserted into the floor's Min-Heap before the fee is calculated, so the spot becomes available to new arrivals immediately rather than after billing completes.
๐ park(Vehicle): Entry Sequence
sequenceDiagram
participant Gate as Entry Gate
participant PL as ParkingLot
participant PF as ParkingFloor
participant PS as ParkingSpot
participant T as Ticket
Gate->>PL: park(vehicle)
loop each floor
PL->>PF: findAndAssign(vehicle)
PF->>PF: heap.poll() O(log n)
PF->>PS: assign(vehicle) synchronized
PS-->>PF: true (spot assigned)
PF-->>PL: ParkingSpot reference
end
PL->>T: new Ticket(vehicle, spot, floor, now)
T-->>Gate: Ticket issued
The sequence diagram makes the two-phase commit inside findAndAssign() explicit in a way the flowchart cannot: first PF polls the heap (removing the spot from availability), then it calls PS.assign(vehicle) in a separate synchronized step โ both operations occurring within the floor-level synchronized block, ensuring no other thread can interleave a poll() on the same heap between these two steps. The loop each floor fragment clarifies that ParkingLot.park() delegates to each ParkingFloor in sequence and stops as soon as PF returns a non-null ParkingSpot; in the happy path shown, the first floor has a spot and the loop exits immediately. ParkingLot then constructs the Ticket as a pure value object โ no synchronization needed at this point โ and returns it to the gate, which keeps the critical section as short as possible.
๐ง Deep Dive: ParkingLot โ Singleton + Multi-Floor Search
import java.time.Instant;
import java.util.List;
import java.util.ArrayList;
public class ParkingLot {
private static volatile ParkingLot instance;
private final List<ParkingFloor> floors = new ArrayList<>();
private final FeeStrategy feeStrategy;
private ParkingLot(FeeStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public static ParkingLot getInstance(FeeStrategy feeStrategy) {
if (instance == null) {
synchronized (ParkingLot.class) {
if (instance == null) { // double-checked locking
instance = new ParkingLot(feeStrategy);
}
}
}
return instance;
}
public void addFloor(ParkingFloor floor) {
floors.add(floor);
}
public Ticket park(Vehicle v) {
for (ParkingFloor floor : floors) {
ParkingSpot spot = floor.findAndAssign(v);
if (spot != null) {
return new Ticket(v, spot, floor, Instant.now());
}
}
throw new RuntimeException("Parking lot full for type: " + v.getType());
}
public double exit(Ticket ticket) {
ticket.getFloor().releaseSpot(ticket.getSpot());
return feeStrategy.calculate(ticket); // โ delegates to injected strategy
}
}
Double-checked locking: The volatile keyword on instance prevents instruction reordering. Both null checks prevent creating two instances under race conditions at startup.
Internals: Thread Safety in ParkingLot
The park() method itself is not synchronized at the ParkingLot level โ synchronization happens at ParkingFloor.findAndAssign(). This is intentional: locking the entire lot for every park operation would serialize all gates. Instead:
ParkingFloor.findAndAssign()issynchronizedat the floor level, allowing concurrent access across different floors.ParkingSpot.assign()issynchronizedat the spot level, preventing two threads from assigning the same spot.volatile ParkingLot instanceuses the Java Memory Model's happens-before guarantee: any thread that reads a non-nullinstanceis guaranteed to see the fully constructedParkingLotobject.
This layered synchronization strategy โ lot-level for single instance, floor-level for search, spot-level for assignment โ minimizes contention while preventing double-booking.
Performance Analysis
| Operation | Data Structure | Time Complexity | Notes |
| Find nearest spot | Min-Heap per floor per type | O(log n) peek + O(log n) poll | n = available spots of that type on floor |
| Release spot | Min-Heap re-insert | O(log n) | Spot ID re-inserted into heap |
| Park (single floor) | Heap poll | O(log n) | |
| Park (worst case, k floors) | k ร O(log n) | O(k log n) | Scans floors until spot found |
| Linear scan (alternative) | Array | O(n) find, O(1) release | Much slower for large floors |
For a 5-floor lot with 100 spots per type per floor, Min-Heap gives O(log 100) โ 7 comparisons vs O(100) for linear scan. At 10,000 spots per floor, the gap is O(14) vs O(10,000).
Mathematical Model: Double-Checked Locking Correctness
The correctness of double-checked locking depends on the Java Memory Model (JMM):
T1 checks: instance == null โ true โ enters synchronized block
T1 checks: instance == null โ true โ creates instance โ exits synchronized block
T2 checks: instance == null โ false โ returns existing instance (guaranteed by volatile)
Without volatile: The JVM can reorder instructions. instance might be assigned a reference before the constructor finishes executing. T2 would read a non-null but partially-constructed object โ a subtle race condition. The volatile keyword introduces a memory barrier: all writes before the volatile write are visible to any thread that subsequently reads the volatile variable.
Proof: Java Language Specification ยง17.4.5 defines the happens-before relationship. volatile write โ happens-before โ volatile read. Therefore, T2's read of instance != null establishes that all of T1's constructor writes have completed.
โ๏ธ Ticket and Fee Strategy Pattern
public class Ticket {
private final Vehicle vehicle;
private final ParkingSpot spot;
private final ParkingFloor floor;
private final Instant entryTime;
public Ticket(Vehicle vehicle, ParkingSpot spot, ParkingFloor floor, Instant entryTime) {
this.vehicle = vehicle;
this.spot = spot;
this.floor = floor;
this.entryTime = entryTime;
}
// Getters...
}
public class FeeCalculator {
private static final Map<VehicleType, Double> RATE_PER_HOUR = Map.of(
VehicleType.BIKE, 1.0,
VehicleType.CAR, 2.0,
VehicleType.TRUCK, 3.5
);
public static double calculate(VehicleType type, long minutes) {
double hours = Math.ceil(minutes / 60.0); // round up to next hour
return RATE_PER_HOUR.getOrDefault(type, 2.0) * hours;
}
}
Using a Strategy pattern here (even as simple as a rate map) makes it easy to swap pricing logic (dynamic pricing, peak hours) without touching ParkingLot or Ticket.
๐งฑ OOP Pillars in the Implementation
Every OOP pillar is load-bearing here โ not decorative. The table below maps each pillar to the exact class or interface where it does work, followed by the code rationale.
| Pillar | Class / Interface | What It Buys Us |
| Encapsulation | ParkingFloor | Heap is private; findAndAssign() and releaseSpot() are the only doors in |
| Abstraction | FeeStrategy | exit() calls feeStrategy.calculate(ticket) โ pricing logic is invisible to the lot |
| Inheritance | BikeSpot, CompactSpot, LargeSpot | Each extends ParkingSpot, each overrides canFit(VehicleType) |
| Polymorphism | PriorityQueue<ParkingSpot> | Heap holds all spot subtypes; the comparator works regardless of concrete type |
Encapsulation: ParkingFloor Owns Its Heap
The Map<VehicleType, PriorityQueue<ParkingSpot>> inside ParkingFloor is private. External callers cannot poll the heap, reorder it, or add a spot to the wrong vehicle-type bucket. They call two synchronized methods and the floor handles the rest:
floor.findAndAssign(vehicle); // caller never touches the heap directly
floor.releaseSpot(spot); // re-insertion is the floor's responsibility
This boundary is what makes the floor's Min-Heap an implementation detail rather than a public contract. If you later swap the heap for a ConcurrentSkipListSet, no caller changes.
Abstraction: FeeStrategy Shields ParkingLot from Pricing Logic
ParkingLot.exit() calls feeStrategy.calculate(ticket). Whether the strategy applies hourly rates, flat fees, or peak surcharges is completely invisible to the lot. Replacing pricing is a one-line constructor change โ no if/switch chains buried in exit():
// Hourly pricing โ injected at startup
FeeStrategy hourly = ticket -> {
long hours = Math.max(1,
Duration.between(ticket.getEntryTime(), Instant.now()).toHours());
return hours * BASE_RATES.get(ticket.getVehicle().getType());
};
ParkingLot lot = ParkingLot.getInstance(hourly);
Inheritance: canFit Enforces Type Compatibility
The abstract canFit(VehicleType) method forces every subclass to declare which vehicles it accepts. The LargeSpot override expresses a real-world nuance that flat type equality cannot:
// LargeSpot can accommodate both trucks and cars โ not expressible without override
public class LargeSpot extends ParkingSpot {
public LargeSpot(int id) { super(id, VehicleType.TRUCK); }
@Override
public boolean canFit(VehicleType type) {
return type == VehicleType.CAR || type == VehicleType.TRUCK;
}
}
ParkingFloor.findAndAssign() can call spot.canFit(v.getType()) as a guard before assignment โ the check is polymorphic and requires no instanceof.
Polymorphism: The Heap Is Subtype-Agnostic
The PriorityQueue<ParkingSpot> comparator calls only getId() โ a method defined in the abstract base class with a final-field backing it:
new PriorityQueue<>((a, b) -> a.getId() - b.getId())
BikeSpot #3 and LargeSpot #7 are ordered identically. The heap is entirely unaware of concrete types. Add EvSpot, add MotorcycleSpot โ the heap ordering logic never changes.
๐ Complete Domain Class Diagram
classDiagram
class ParkingLot {
<>
-static ParkingLot instance
-List~ParkingFloor~ floors
-FeeStrategy feeStrategy
+park(Vehicle) Ticket
+exit(Ticket) double
+getInstance(FeeStrategy) ParkingLot
}
class ParkingFloor {
-int floorNumber
-Map~VehicleType,PriorityQueue~ availableSpots
+findAndAssign(Vehicle) ParkingSpot
+releaseSpot(ParkingSpot) void
+addSpot(ParkingSpot) void
}
class ParkingSpot {
<>
-int id
-boolean isFree
-VehicleType type
-Vehicle parkedVehicle
+assign(Vehicle) boolean
+free() void
+canFit(VehicleType) boolean*
}
class BikeSpot {
+canFit(VehicleType) boolean
}
class CompactSpot {
+canFit(VehicleType) boolean
}
class LargeSpot {
+canFit(VehicleType) boolean
}
class EvSpot {
+chargerType String
+canFit(VehicleType) boolean
}
class Vehicle {
<>
-String licensePlate
-VehicleType type
+getType() VehicleType
+getLicensePlate() String
}
class Ticket {
+Vehicle vehicle
+ParkingSpot spot
+int floorNumber
+Instant entryTime
}
class FeeStrategy {
<>
+calculate(Ticket) double
}
class PeakHourFeeStrategy {
+calculate(Ticket) double
}
class VehicleType {
<>
BIKE
CAR
TRUCK
}
ParkingLot "1" *-- "many" ParkingFloor : owns
ParkingFloor "1" *-- "many" ParkingSpot : manages
BikeSpot --|> ParkingSpot : extends
CompactSpot --|> ParkingSpot : extends
LargeSpot --|> ParkingSpot : extends
EvSpot --|> CompactSpot : extends
Vehicle <|-- Car
Vehicle <|-- Truck
Vehicle <|-- Bike
Ticket --> Vehicle : records
Ticket --> ParkingSpot : records
ParkingLot o-- FeeStrategy : uses (injected)
PeakHourFeeStrategy ..|> FeeStrategy : implements
Vehicle --> VehicleType : uses
The complete class diagram reveals two design decisions that are easy to miss in the code. First, EvSpot extends CompactSpot rather than ParkingSpot directly โ it inherits the CAR-type bucket assignment and the canFit() implementation, adding only chargerType without requiring any change to ParkingFloor's heap partition logic. Second, the relationship between ParkingLot and FeeStrategy is aggregation (o--), not composition (*--): the lot does not own the strategy's lifecycle โ the strategy is injected at construction time and can be swapped or garbage-collected independently, which is exactly what DIP requires. Reading the arrows for the spot hierarchy: *-- (filled diamond) means the parent controls the child's lifecycle; --|> (open arrowhead) means inheritance; ..|> (dashed line) means interface implementation โ three distinct relationship types visible at a glance.
โ SOLID Principles Applied in Code
| Principle | Violated By | Respected By |
| SRP | ParkingFloor calculating fees | ParkingFloor (heap ops) + ParkingLot (fee delegation) staying separate |
| OCP | Switch-casing vehicle types | EvSpot extends ParkingSpot โ no change to ParkingFloor |
| LSP | A spot subtype with a broken getId() | All subtypes inherit a final-field-backed getId() |
| ISP | A bloated PricingService with 6 methods | @FunctionalInterface FeeStrategy with one method |
| DIP | ParkingLot hard-wiring FeeCalculator | ParkingLot(FeeStrategy) โ lot depends on the abstraction |
SRP โ The floor knows spots; the lot knows fees. If ParkingFloor.findAndAssign() also invoked feeStrategy.calculate(), then changing pricing rules would require opening ParkingFloor. Two reasons to change = SRP violation. The floor's single responsibility: maintain an ordered set of available spots per vehicle type.
OCP โ New spot type, zero changes to ParkingFloor. Adding EV charging to the lot means one new class:
public class EvSpot extends ParkingSpot {
private final boolean hasCharger;
public EvSpot(int id, boolean hasCharger) {
super(id, VehicleType.CAR); // reuses a CAR-type heap slot
this.hasCharger = hasCharger;
}
@Override
public boolean canFit(VehicleType type) { return type == VehicleType.CAR; }
public boolean hasCharger() { return hasCharger; }
}
ParkingFloor accepts EvSpot in its CAR heap immediately โ the comparator only calls getId(), which EvSpot inherits unchanged.
LSP โ Every subtype is substitutable in the PriorityQueue. The heap invariant requires the comparator to be consistent and total. Every ParkingSpot subtype inherits getId() without override, so no subtype can return an inconsistent value. An EvSpot in a BikeSpot heap would be an LSP violation at the caller level (wrong type bucket) โ which is why ParkingFloor partitions its heaps by VehicleType.
ISP โ FeeStrategy carries exactly one method. The @FunctionalInterface annotation enforces this at compile time. A peak-hour implementation doesn't inherit an unused calculateMembership() method from a fatter interface:
@FunctionalInterface
public interface FeeStrategy {
double calculate(Ticket ticket); // one method, no baggage
}
DIP โ ParkingLot depends on the abstraction, not FeeCalculator. The updated ParkingLot constructor receives FeeStrategy โ a high-level abstraction. The concrete FeeCalculator is one possible implementation, but ParkingLot never imports it:
// At application bootstrap โ the only place that knows the concrete type
FeeStrategy hourlyStrategy = ticket -> { /* ... */ };
ParkingLot lot = ParkingLot.getInstance(hourlyStrategy);
The Singleton guarantees a single instance; DI guarantees the instance is not coupled to any specific pricing class.
๐ Interface Contracts: FeeStrategy and the Heap Comparator
Two interface contracts hold the design together. Neither is a full class hierarchy โ both are minimal, focused obligations.
FeeStrategy: The @FunctionalInterface Contract
@FunctionalInterface
public interface FeeStrategy {
double calculate(Ticket ticket);
}
// Hourly rate โ lambda, no extra class file
FeeStrategy hourly = ticket -> {
long hours = Math.max(1,
Duration.between(ticket.getEntryTime(), Instant.now()).toHours());
return hours * BASE_RATES.get(ticket.getVehicle().getType());
};
// Flat rate โ ignores duration entirely
FeeStrategy flat = ticket -> 50.0;
// Peak-hour surcharge โ strategy reads exit context from ticket
FeeStrategy peakHour = ticket -> {
long hours = Math.max(1,
Duration.between(ticket.getEntryTime(), Instant.now()).toHours());
boolean isPeak = LocalTime.now().isAfter(LocalTime.of(17, 0))
&& LocalTime.now().isBefore(LocalTime.of(19, 0));
double base = BASE_RATES.get(ticket.getVehicle().getType());
return base * hours * (isPeak ? 2.0 : 1.0);
};
@FunctionalInterface is the OOP tool that turns the Strategy Pattern from a three-class hierarchy (AbstractFeeStrategy โ HourlyFeeStrategy, FlatFeeStrategy) into a one-liner lambda. The annotation also serves as self-documenting API contract: anyone reading the interface immediately knows it is safely lambda-assignable.
Passing Ticket as the single parameter โ rather than (VehicleType, long) โ is intentional: Ticket is the value object that encapsulates all context (vehicle, spot, floor, entry time). Adding a new contextual field to Ticket never changes the FeeStrategy method signature.
The Heap Comparator: Implicit Contract for PriorityQueue\
// Defined once in ParkingFloor constructor; all subtypes comply automatically
Comparator<ParkingSpot> byId = (a, b) -> a.getId() - b.getId();
new PriorityQueue<>(byId)
Every ParkingSpot subtype participates in the heap ordering via this comparator. Because getId() is backed by a final int id field set in the abstract constructor, no subtype can return an inconsistent value.
| Interface / Contract | Kind | Consumer | Purpose |
FeeStrategy | @FunctionalInterface | ParkingLot.exit() | Swappable pricing without modifying the lot |
| Heap comparator | Comparator<ParkingSpot> lambda | ParkingFloor | Nearest-spot ordering across all subtypes |
canFit(VehicleType) | Abstract method on ParkingSpot | ParkingFloor.findAndAssign() | Type-safe assignment guard |
โ๏ธ Trade-offs & Failure Modes: Design Patterns Applied
| Pattern | Where Used | Why |
| Singleton | ParkingLot.getInstance() | Single source of truth for lot state |
| Min-Heap | ParkingFloor.availableSpots | O(log n) nearest-spot allocation |
| Strategy | FeeCalculator | Swap pricing logic independently |
| Factory Method | VehicleFactory.create(type, plate) | Decouple creation from usage |
| Template Method | ParkingSpot.assign() / free() | Common lifecycle in abstract base |
๐๏ธ Advanced Extension Points: Dynamic Pricing, Reservations, and EV Charging
The core design handles the standard case cleanly, but real parking systems need more. Here are three natural extension points โ each slots in without modifying existing classes.
Dynamic Pricing with Strategy Pattern:
Currently FeeCalculator uses flat rates. To support peak pricing (2ร rates from 5โ7 pm), extract a FeeStrategy interface:
public interface FeeStrategy {
double calculate(VehicleType type, long minutes, LocalTime exitTime);
}
public class PeakHourFeeStrategy implements FeeStrategy {
public double calculate(VehicleType type, long minutes, LocalTime exitTime) {
double hours = Math.ceil(minutes / 60.0);
double baseRate = BASE_RATES.get(type);
boolean isPeak = exitTime.isAfter(LocalTime.of(17, 0))
&& exitTime.isBefore(LocalTime.of(19, 0));
return baseRate * hours * (isPeak ? 2.0 : 1.0);
}
}
Inject FeeStrategy into ParkingLot's constructor. Now pricing rules are swappable without touching lot management logic โ a clean application of the Open/Closed Principle.
Reservation System: Add a ReservationService that pre-allocates spots for timed windows. The Min-Heap needs to be partitioned: a walk-in heap vs a reserved heap per vehicle type. On reservation, move the spot from the walk-in heap to the reserved heap. On reservation expiry, move it back.
EV Charging Spots: Add EVSpot extends CompactSpot with a chargerType field (Level 2, DC Fast Charge). The heap for the CAR type would separate EVSpot and regular CompactSpot using a custom comparator that prioritizes charger proximity when the requesting vehicle is flagged as electric. No changes to ParkingLot or ParkingFloor are needed โ only the comparator and spot subclass.
๐งช Testing the Critical Path
This example demonstrates a practical scenario. The code below shows the key implementation details you need to understand. Follow along to see how this works in practice.
@Test
void carParksThenExits() {
ParkingLot lot = ParkingLot.getInstance();
ParkingFloor floor = new ParkingFloor(1);
floor.addSpot(new CompactSpot(1));
floor.addSpot(new CompactSpot(2));
lot.addFloor(floor);
Vehicle car = new Car("ABC-123");
Ticket ticket = lot.park(car);
assertEquals(1, ticket.getSpot().getId()); // nearest spot
assertTrue(!ticket.getSpot().isFree());
double fee = lot.exit(ticket);
assertTrue(ticket.getSpot().isFree());
assertTrue(fee >= 0);
}
๐ Real-World Applications: Where This Design Applies
The core pattern โ typed resource allocation, nearest-first selection, concurrent access control, and pluggable pricing โ recurs across many industries. Recognizing it in a new problem lets you reuse the same structural decisions rather than redesigning from scratch.
| System | Analogous Pattern | Notes |
| Hospital room allocation | Min-Heap by room proximity to nursing station | Multi-floor search, typed rooms (ICU, general) |
| Airport gate assignment | Strategy for gate type (wide-body vs narrow-body) | Singleton gate manager, fee as turnaround time |
| Warehouse slot allocation | Min-Heap by pick path distance | Item type matching (cold storage, hazmat, standard) |
| Hotel room booking | Reservation system + availability heap | Type (king, queen, suite) with dynamic pricing |
| Cloud VM provisioning | Slot = VM type; floor = availability zone | Kubernetes scheduler uses similar bin-packing logic |
Any system requiring typed resource allocation with nearest-first assignment and concurrent access benefits from this structure. The Singleton manages global state and ensures a single source of truth; the Min-Heap provides efficient nearest-first selection without scanning every resource; the Strategy pattern decouples pricing or allocation policy logic so rules can change without touching the core allocation engine.
๐งญ Decision Guide: When to Use Which Pattern
Not every problem needs every pattern. The table below captures the tradeoffs so you can make an informed choice rather than applying patterns by default.
| Decision | Option A | Option B | Choose When |
| Spot search | Min-Heap O(log n) | Linear scan O(n) | Use Min-Heap when > 50 spots per floor; scan is fine for toy systems |
| Concurrency | Synchronized at floor level | Lock-free ConcurrentHashMap | Synchronized simpler; lock-free for > 1000 concurrent gates |
| Fee calculation | Strategy interface | Static map in FeeCalculator | Use Strategy when pricing rules change independently of lot logic |
| Singleton | Double-checked locking | Enum singleton | Use Enum singleton in modern Java (simpler, thread-safe, serialization-safe) |
| Persistence | In-memory only | JPA entity + DB | Add persistence layer when lot state must survive server restart |
The core principle: patterns solve specific forces โ concurrency, extensibility, single truth of state. Don't apply them universally; apply them where the force is present. A Min-Heap is wasted complexity on a 10-spot test lot. A Singleton is essential when the lot state must be globally consistent across gate threads.
๐ฏ OOP Interview Walk-Through: Presenting This Design on a Whiteboard
Interviewers don't just evaluate the design โ they evaluate the thought process. Here is the step-by-step narration that earns maximum signal.
Step 1 โ State the problem, identify the nouns.
"A parking lot has multiple floors. Vehicles of three types enter via gates. Each type maps to a compatible spot type. On entry, a ticket is issued. On exit, a fee is calculated."
Nouns โ classes: Vehicle, ParkingSpot, Ticket, ParkingFloor, ParkingLot.
Step 2 โ Identify relationships and draw the class diagram.
| Relationship | Type | Notes |
ParkingLot โ ParkingFloor | HAS-A (1 to many) | Lot owns its floors |
ParkingFloor โ ParkingSpot | HAS-A (manages many) | Floor owns its heap of spots |
ParkingLot โ FeeStrategy | USES (dependency) | Lot delegates fee calculation |
BikeSpot/CompactSpot/LargeSpot โ ParkingSpot | IS-A (extends) | Spot hierarchy |
Car/Truck/Bike โ Vehicle | IS-A (extends) | Vehicle hierarchy |
classDiagram
class ParkingLot {
<>
-List~ParkingFloor~ floors
-FeeStrategy feeStrategy
+park(Vehicle) Ticket
+exit(Ticket) double
}
class ParkingFloor {
-Map~VehicleType, PriorityQueue~ availableSpots
+findAndAssign(Vehicle) ParkingSpot
+releaseSpot(ParkingSpot) void
}
class ParkingSpot {
<>
+canFit(VehicleType) boolean
+assign(Vehicle) boolean
+free() void
}
class FeeStrategy {
<>
+calculate(Ticket) double
}
class Vehicle {
<>
+getType() VehicleType
}
ParkingLot "1" --> "*" ParkingFloor : has-a
ParkingFloor "1" --> "*" ParkingSpot : manages
ParkingLot --> FeeStrategy : uses
ParkingSpot <|-- BikeSpot
ParkingSpot <|-- CompactSpot
ParkingSpot <|-- LargeSpot
Vehicle <|-- Car
Vehicle <|-- Truck
Vehicle <|-- Bike
This whiteboard-scale diagram strips the full domain model to its five load-bearing classes and one interface โ exactly what you should draw in the first two minutes of an LLD interview, before mentioning Singleton, Min-Heap, or volatile. Reading top-down: ParkingLot aggregates ParkingFloor instances (HAS-A, 1-to-many), each floor aggregates ParkingSpot instances (HAS-A, 1-to-many), and ParkingSpot is the root of a three-subclass inheritance tree; ParkingLot depends on FeeStrategy via the interface, not a concrete class. The deliberate omission of EvSpot, Ticket, VehicleType, and PeakHourFeeStrategy is a communication skill: this diagram communicates the structural skeleton clearly, and you earn credibility by expanding it progressively when the interviewer asks "how would you handle EV charging?" or "where does pricing logic live?".
Step 3 โ Identify the varying part โ Strategy Pattern.
"Pricing changes independently of parking logic. I'll extract
FeeStrategywithcalculate(Ticket)and inject it intoParkingLot's constructor."
Step 4 โ Identify creation complexity โ Factory Pattern.
"Creating
Vehiclesubtypes from a string-typed gate request is a Factory.VehicleFactory.create(plate, type)keeps construction out of the controller."
Step 5 โ Identify shared global state โ Singleton Pattern.
"The lot is a single source of truth across all entry gate threads. Singleton with double-checked locking and
volatileโ one instance, thread-safe initialization, no synchronization cost after startup."
Step 6 โ Address concurrency proactively โ before the interviewer asks.
"Two gates can try to assign the same spot simultaneously. I synchronize at
ParkingFloor.findAndAssign()โ floor-level granularity, not lot-level. Different floors can serve different gates concurrently.ParkingSpot.assign()is also synchronized as a safety net against cross-floor races."
This is the sequence interviewers most commonly follow up on. Raising it first signals production-readiness thinking.
๐ ๏ธ Spring Boot in Action: Wrapping the Parking Lot as a Production REST Service
Spring Boot is an opinionated Java framework that auto-configures an embedded server, dependency injection, and web layer so you can stand up a REST API in minutes. Annotating the ParkingLotService with @Service wires it into the IoC container, and @RestController exposes it over HTTP with zero XML config.
How it solves the problem in this post: The ParkingLot Singleton and ParkingFloor allocation logic we built become a Spring-managed service bean. Spring's singleton scope guarantees one ParkingLotService instance JVM-wide โ reinforcing the Singleton pattern. @Transactional (with optimistic locking) can replace the raw synchronized blocks for database-backed persistence later.
// --- Service layer: wraps ParkingLot singleton inside Spring IoC ---
@Service
public class ParkingLotService {
private final ParkingLot lot = ParkingLot.getInstance(); // Singleton
/** Entry gate: park vehicle, return ticket JSON */
public Ticket park(String plate, VehicleType type) {
Vehicle vehicle = VehicleFactory.create(plate, type);
return lot.park(vehicle)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.CONFLICT, "No available spot for " + type));
}
/** Exit gate: free spot, return fee */
public double exit(String ticketId) {
return lot.exit(ticketId);
}
public ParkingLotStatus status() {
return lot.getStatus(); // available spots per floor
}
}
// --- REST controller: exposes HTTP endpoints ---
@RestController
@RequestMapping("/api/parking")
public class ParkingLotController {
private final ParkingLotService service;
public ParkingLotController(ParkingLotService service) {
this.service = service;
}
/** POST /api/parking/enter?plate=ABC123&type=CAR */
@PostMapping("/enter")
public ResponseEntity<Ticket> enter(
@RequestParam String plate,
@RequestParam VehicleType type) {
Ticket ticket = service.park(plate, type);
return ResponseEntity.status(HttpStatus.CREATED).body(ticket);
}
/** POST /api/parking/exit/{ticketId} โ returns fee */
@PostMapping("/exit/{ticketId}")
public ResponseEntity<Map<String, Object>> exit(
@PathVariable String ticketId) {
double fee = service.exit(ticketId);
return ResponseEntity.ok(Map.of("ticketId", ticketId, "fee", fee));
}
/** GET /api/parking/status โ available spots summary */
@GetMapping("/status")
public ResponseEntity<ParkingLotStatus> status() {
return ResponseEntity.ok(service.status());
}
}
Run with mvn spring-boot:run. The embedded Tomcat starts on port 8080. POST /api/parking/enter?plate=KA01AB1234&type=CAR returns a Ticket JSON body; POST /api/parking/exit/{id} returns the calculated fee. The synchronized methods in ParkingSpot.assign() ensure concurrent HTTP requests from multiple entry gates don't double-book a spot.
For a full deep-dive on Spring Boot REST API patterns and concurrent parking lot state management, a dedicated follow-up post is planned.
๐ Lessons from LLD Interviews and Production Code
These lessons come from patterns seen repeatedly in both interview whiteboard sessions and production Java codebases.
Lesson 1: Start with the domain model, not the patterns. In interviews, candidates who draw the class diagram first (Vehicle, Spot, Ticket, Floor, Lot) before mentioning Singleton consistently score higher than those who open with "I'll use a Singleton." The domain model reveals what needs a pattern; the pattern doesn't define the domain.
Lesson 2: Thread safety is a first-class requirement. The question "what happens if two cars try to park simultaneously?" is almost always asked.
synchronizedonfindAndAssign()combined withsynchronizedonassign()is the simplest correct answer. Lock-free structures are an optimization discussion โ mention them only after the correct baseline is established.Lesson 3: Min-Heap is the key insight. Interviewers reward candidates who identify that "find nearest available spot" is a priority queue problem, not a linear search problem. State the complexity explicitly: O(log n) vs O(n). The difference at 1000 spots is ~10 comparisons vs up to 1000. That single insight often separates good from great answers.
Lesson 4: Strategy over switch statements. A
FeeCalculatorwith a rate map is extensible โ add a new vehicle type, add an entry to the map. Aswitch (vehicleType)block insideexit()is a closed design: every new vehicle type requires modifying the method. That violates the Open/Closed Principle and is a common interview red flag.
๐ TLDR: Summary & Key Takeaways
- Encapsulation:
ParkingFloorhides itsPriorityQueueheap โ callers only seefindAndAssign()andreleaseSpot(). - Inheritance + canFit:
BikeSpot,CompactSpot,LargeSpotoverridecanFit(VehicleType)โ type safety withoutinstanceofchains. - Polymorphism:
PriorityQueue<ParkingSpot>orders all subtypes via one comparator;EvSpotslots in with zero heap changes. - Strategy (DIP):
ParkingLotacceptsFeeStrategyin its constructor โ pricing is swappable without touching lot logic. - Min-Heap per type per floor: O(log n) nearest-spot allocation vs O(n) scan โ the single insight that separates good from great interview answers.
- Double-checked locking Singleton:
volatile+ two null checks = thread-safe initialization with no per-call sync overhead. - SOLID checkpoint: SRP separates floor/lot concerns; OCP lets
EvSpotextend cleanly; ISP keepsFeeStrategya one-method contract; DIP wires the lot to the abstraction, not the implementation.
๐ Related Posts
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
RAG vs Fine-Tuning: When to Use Each (and When to Combine Them)
TLDR: RAG gives LLMs access to current knowledge at inference time; fine-tuning changes how they reason and write. Use RAG when your data changes. Use fine-tuning when you need consistent style, tone, or domain reasoning. Use both for production assi...
Fine-Tuning LLMs with LoRA and QLoRA: A Practical Deep-Dive
TLDR: LoRA freezes the base model and trains two tiny matrices per layer โ 0.1 % of parameters, 70 % less GPU memory, near-identical quality. QLoRA adds 4-bit NF4 quantization of the frozen base, enabling 70B fine-tuning on 2ร A100 80 GB instead of 8...
Build vs Buy: Deploying Your Own LLM vs Using ChatGPT, Gemini, and Claude APIs
TLDR: Use the API until you hit $10K/month or a hard data privacy requirement. Then add a semantic cache. Then evaluate hybrid routing. Self-hosting full model serving is only cost-effective at > 50M tokens/day with a dedicated MLOps team. The build ...
Watermarking and Late Data Handling in Spark Structured Streaming
TLDR: A watermark tells Spark Structured Streaming: "I will accept events up to N minutes late, and then I am done waiting." Spark tracks the maximum event time seen per partition, takes the global minimum across all partitions, subtracts the thresho...
