Implement LLD for Parking Lot: Code Walkthrough
The Domain Model: Vehicles, Spots, and Tickets
This is the code companion to the Parking Lot System Design post.
π― System Scenario & Requirements
System Goal
This is the code companion to the Parking Lot System Design post.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- 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).
- Vehicle Hierarchy Parking Spot Hierarchy π ParkingSpot Lifecycle States The state diagram shows the complete two-state lifecycle every ParkingSpot instance goes through: it enters FREE when addSpot() places it into theβ¦
Constraints & Assumptions
β‘ Execution Stages & Decision Trace
Stage 1 β 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.
Deep Dive
Scenario context
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).
Vehicle Hierarchy
public enum VehicleType { BIKE, CAR, TRUCK }
public abstract class Vehicle {β¦
This walkthrough focuses on **The Domain Model: Vehicles, Spots, and Tickets**.
Before making a design choice, clarify:
- Vehicle Hierarchy Parking Spot Hierarchy π ParkingSpot Lifecycle States The state diagram shows the complete two-state lifecycle every ParkingSpot instance goes through: it enters FREE when addSpot() places it into theβ¦
- 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β¦
- The FREE β [ ] edge represents lot decommissioning β a rare but necessary transition that prevents the heap from holding references to removed spots.
**Concepts in play:** tree-data-structures, low-level-design-systems, jvm-internals-and-garbage-collection, design-patterns-gof
## Stage 1 β 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.
- - findNearest() β O(1) peek, O(log n) remove.
- - freeSpot() β O(log n) re-insert.
*Applies [**tree-data-structures**](/concept/tree-data-structures) to the decision above.*
```java
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)"]
Observable outcome: - freeSpot() β O(log n) re-insert.
Stage 2 β 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.
- 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β¦
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]
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
Observable outcome: 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β¦
Stage 3 β Deep Dive: ParkingLot β Singleton + Multi-Floor Search
- 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.
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
}
}
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)
Observable outcome: This is intentional: locking the entire lot for every park operation would serialize all gates.
Stage 4 β Ticket and Fee Strategy Pattern
- 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 .
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;
}
}
Observable outcome: 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 .
Stage 5 β 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.
- 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.
floor.findAndAssign(vehicle); // caller never touches the heap directly
floor.releaseSpot(spot); // re-insertion is the floor's responsibility
// 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);
Observable outcome: External callers cannot poll the heap, reorder it, or add a spot to the wrong vehicle-type bucket.
Stage 6 β SOLID Principles Applied in Code
- 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.
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; }
}
@FunctionalInterface
public interface FeeStrategy {
double calculate(Ticket ticket); // one method, no baggage
}
Observable outcome: The floor's single responsibility: maintain an ordered set of available spots per vehicle type.
End-to-end outcome
Vehicle Hierarchy Parking Spot Hierarchy π ParkingSpot Lifecycle States 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.
Alternatives considered
Decision Guide: When to Use Which Pattern
Not every problem needs every pattern.
Failure and degraded modes
Trade-offs & Failure Modes: Design Patterns Applied
- Detection: Trade-offs & Failure Modes: Design Patterns Applied
- User or operator impact: Trade-offs & Failure Modes: Design Patterns Applied
