Before writing a single line of code in an LLD interview, spend 3β5 minutes clarifying requirements with the interviewer. These are the core functional requirements for a parking lot system:
- Support multiple vehicle types: Motorcycles, Cars, Trucks
- Support multiple levels / floors in the parking lot
- Assign the smallest available spot that fits the vehicle
- Issue a unique ticket on entry; record entry timestamp
- Calculate parking fee based on duration (pluggable pricing strategy)
- Free the spot when a vehicle exits using a valid ticket
- Thread-safe operations (concurrent arrivals & exits)
- Singleton ParkingLot β one instance per process
The first step in any LLD problem is to identify the nouns (entities/classes) and verbs (methods/behaviours). Ask yourself: what are the real-world objects in this system?
| Class / Interface | Type | Responsibility |
|---|---|---|
| VehicleType | Enum | MOTORCYCLE Β· CAR Β· TRUCK |
| SpotSize | Enum | SMALL Β· MEDIUM Β· LARGE |
| Vehicle | Abstract Class | Base for Motorcycle, Car, Truck |
| ParkingSpot | Class | Holds a vehicle; knows its size |
| ParkingLevel | Class | A floor with a list of spots |
| ParkingTicket | Class | Entry record with timestamps |
| PricingStrategy | Interface | calculate(ticket) β fare |
| ParkingLot | Singleton | Orchestrates levels & tickets |
ParkingLot (Singleton)
βββ levels: List<ParkingLevel>
β βββ spots: List<ParkingSpot>
β βββ parkedVehicle: Vehicle?
βββ activeTickets: Map<ticketId, ParkingTicket>
β βββ vehicle: Vehicle
β βββ spot: ParkingSpot
βββ pricingStrategy: PricingStrategy
βββ HourlyPricing
βββ FlatRatePricing
βββ TieredPricing
Vehicle (Abstract)
βββ Motorcycle β requires SpotSize.SMALL
βββ Car β requires SpotSize.MEDIUM
βββ Truck β requires SpotSize.LARGEUnderstanding the happy path and edge cases before coding is critical. The diagram below shows the complete lifecycle of a vehicle β from arriving to exiting and paying.
Happy Path
Vehicle arrives β spot found β ticket issued β vehicle parks β exits β pays β spot freed.
Lot Full Edge Case
If no level has a fitting available spot, throw IllegalStateException / RuntimeError.
Invalid Ticket
If ticketId is not found or status is not ACTIVE, exit() rejects the request.
Concurrent Arrivals
park() and exit() are synchronized β only one thread assigns/frees a spot at a time.
In an LLD interview, drawing a UML class diagram on the whiteboard before coding demonstrates structured thinking. Key relationships to highlight: composition (ParkingLot owns Levels owns Spots), inheritance (Vehicle hierarchy), and the Strategy interface.
Solid lines = association/composition Β |Β Dashed lines = dependency Β |Β Hollow triangles = inheritance/implementation
Enums prevent magic strings and make comparisons type-safe. Note that SpotSizeis declared in ascending order (SMALL β MEDIUM β LARGE) β this ordering is later exploited by the canFit() method using ordinal comparison.
// ββ Enums βββββββββββββββββββββββββββββββββββββββββββββββββββββ
public enum VehicleType { MOTORCYCLE, CAR, TRUCK }
public enum SpotSize { SMALL, MEDIUM, LARGE }
public enum TicketStatus { ACTIVE, PAID, LOST }Open/Closed Principle
Vehicle class forces every subclass to declare which SpotSize it needs. Adding a new vehicle type (e.g., ElectricBus) requires zero changes to the parking logic β the class is open for extension, closed for modification.Each concrete subclass only overrides getRequiredSpotSize(). The licensePlate and type fields are inherited β no duplication. This is the Template Method pattern in its simplest form.
// ββ Vehicle hierarchy βββββββββββββββββββββββββββββββββββββββββ
public abstract class Vehicle {
protected final String licensePlate;
protected final VehicleType type;
public Vehicle(String licensePlate, VehicleType type) {
this.licensePlate = licensePlate;
this.type = type;
}
public abstract SpotSize getRequiredSpotSize();
public String getLicensePlate() { return licensePlate; }
public VehicleType getType() { return type; }
}
public class Motorcycle extends Vehicle {
public Motorcycle(String plate) { super(plate, VehicleType.MOTORCYCLE); }
@Override public SpotSize getRequiredSpotSize() { return SpotSize.SMALL; }
}
public class Car extends Vehicle {
public Car(String plate) { super(plate, VehicleType.CAR); }
@Override public SpotSize getRequiredSpotSize() { return SpotSize.MEDIUM; }
}
public class Truck extends Vehicle {
public Truck(String plate) { super(plate, VehicleType.TRUCK); }
@Override public SpotSize getRequiredSpotSize() { return SpotSize.LARGE; }
}Each spot knows its size and whether it's occupied. The key insight is in canFit() β it uses enum ordinal comparison so a larger spot can accommodate a smaller vehicle when the exact-size spot is unavailable. For example, a Car can park in a LARGE spot if no MEDIUM spots remain.
// ββ ParkingSpot ββββββββββββββββββββββββββββββββββββββββββββββββ
public class ParkingSpot {
private final String spotId; // e.g. "L1-A-01"
private final SpotSize size;
private Vehicle parkedVehicle;
public ParkingSpot(String spotId, SpotSize size) {
this.spotId = spotId;
this.size = size;
}
public boolean isAvailable() { return parkedVehicle == null; }
public boolean canFit(Vehicle v) {
return isAvailable() && size.ordinal() >= v.getRequiredSpotSize().ordinal();
}
public void park(Vehicle v) { this.parkedVehicle = v; }
public void vacate() { this.parkedVehicle = null; }
public String getSpotId() { return spotId; }
public SpotSize getSize() { return size; }
public Vehicle getVehicle() { return parkedVehicle; }
}"L1-M-03" (Level-Size-Number) makes debugging much easier than using numeric IDs alone. Mention this to an interviewer to show attention to real-world usability.The ticket is the core receipt/contract between the parking lot and the vehicle owner. It ties together the vehicle, the assigned spot, and timestamps. The state machine is simple:ACTIVE βPAID (or LOST for edge cases).
ticketIdUUID β globally unique, used as the lookup key
entryTimeImmutable β set at creation, never changes
exitTimeSet only when pay() is called
statusACTIVE / PAID / LOST β prevents double-exit
// ββ ParkingTicket ββββββββββββββββββββββββββββββββββββββββββββββ
import java.time.Instant;
import java.util.UUID;
public class ParkingTicket {
private final String ticketId;
private final Vehicle vehicle;
private final ParkingSpot spot;
private final Instant entryTime;
private Instant exitTime;
private TicketStatus status;
private double amountPaid;
public ParkingTicket(Vehicle vehicle, ParkingSpot spot) {
this.ticketId = UUID.randomUUID().toString();
this.vehicle = vehicle;
this.spot = spot;
this.entryTime = Instant.now();
this.status = TicketStatus.ACTIVE;
}
public void pay(double amount) {
this.exitTime = Instant.now();
this.amountPaid = amount;
this.status = TicketStatus.PAID;
}
public long getDurationMinutes() {
Instant end = (exitTime != null) ? exitTime : Instant.now();
return java.time.Duration.between(entryTime, end).toMinutes();
}
// getters β¦
public String getTicketId() { return ticketId; }
public Vehicle getVehicle() { return vehicle; }
public ParkingSpot getSpot() { return spot; }
public TicketStatus getStatus() { return status; }
public Instant getEntryTime() { return entryTime; }
}Strategy Pattern
PricingStrategy interface decouples pricing logic from ParkingLot. Swap strategies at runtime β e.g., happy-hour flat rate during off-peak hours β without modifying ParkingLot. This is the Strategy Design Pattern.We provide three concrete strategies below. HourlyPricing rounds up to the next hour (common in real lots). FlatRatePricing charges a fixed amount regardless of duration (useful for event parking or daily passes). TieredPricing uses a lower rate for the first 2 hours and a higher rate after β a common real-world model to encourage turnover.
// ββ Pricing Strategy (Strategy Pattern) βββββββββββββββββββββββ
public interface PricingStrategy {
double calculate(ParkingTicket ticket);
}
public class HourlyPricing implements PricingStrategy {
private final double ratePerHour;
public HourlyPricing(double ratePerHour) {
this.ratePerHour = ratePerHour;
}
@Override
public double calculate(ParkingTicket ticket) {
double hours = ticket.getDurationMinutes() / 60.0;
return Math.ceil(hours) * ratePerHour; // round up to next hour
}
}
public class FlatRatePricing implements PricingStrategy {
private final double flatRate;
public FlatRatePricing(double flatRate) { this.flatRate = flatRate; }
@Override
public double calculate(ParkingTicket ticket) { return flatRate; }
}
// Bonus: Tiered pricing (first 2 hrs cheap, then rises)
public class TieredPricing implements PricingStrategy {
private final double firstTwoHoursRate; // e.g. βΉ10/hr
private final double afterTwoHoursRate; // e.g. βΉ20/hr
public TieredPricing(double first, double after) {
this.firstTwoHoursRate = first;
this.afterTwoHoursRate = after;
}
@Override
public double calculate(ParkingTicket ticket) {
double hours = ticket.getDurationMinutes() / 60.0;
if (hours <= 2) return Math.ceil(hours) * firstTwoHoursRate;
return (2 * firstTwoHoursRate) + Math.ceil(hours - 2) * afterTwoHoursRate;
}
}Single Responsibility Principle
ParkingLevel only manages its collection of spots. It knows nothing about tickets, pricing, or the overall lot. This keeps each class focused and easy to test in isolation.The level is a simple container that iterates its spots to find the first one that fits a vehicle. The bonus method availableBySize() is useful for a display board showing "Level 2: 3 small, 7 medium, 0 large available" β a common follow-up question.
// ββ ParkingLevel βββββββββββββββββββββββββββββββββββββββββββββββ
import java.util.*;
public class ParkingLevel {
private final int levelNumber;
private final List<ParkingSpot> spots;
public ParkingLevel(int level, int small, int medium, int large) {
this.levelNumber = level;
this.spots = new ArrayList<>();
int idx = 1;
for (int i = 0; i < small; i++) spots.add(new ParkingSpot("L" + level + "-S-" + idx++, SpotSize.SMALL));
for (int i = 0; i < medium; i++) spots.add(new ParkingSpot("L" + level + "-M-" + idx++, SpotSize.MEDIUM));
for (int i = 0; i < large; i++) spots.add(new ParkingSpot("L" + level + "-L-" + idx++, SpotSize.LARGE));
}
/** Returns first available spot that fits the vehicle, or null. */
public ParkingSpot findSpot(Vehicle vehicle) {
return spots.stream()
.filter(s -> s.canFit(vehicle))
.findFirst()
.orElse(null);
}
public long availableCount() {
return spots.stream().filter(ParkingSpot::isAvailable).count();
}
public Map<SpotSize, Long> availableBySize() {
Map<SpotSize, Long> counts = new EnumMap<>(SpotSize.class);
for (SpotSize sz : SpotSize.values())
counts.put(sz, spots.stream().filter(s -> s.getSize() == sz && s.isAvailable()).count());
return counts;
}
public int getLevelNumber() { return levelNumber; }
}Thread Safety Warning
park() and exit() are synchronized (Java) / use a threading lock (Python) to prevent race conditions when multiple cars arrive or exit simultaneously. Without synchronization, two threads could assign the same spot!The Singleton pattern ensures exactly one ParkingLot instance exists per process. We use double-checked locking for thread-safe lazy initialization β the outer null-check avoids acquiring the lock on every call, while the inner check prevents a race where two threads both pass the outer check simultaneously.
// ββ ParkingLot (Singleton) βββββββββββββββββββββββββββββββββββββ
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class ParkingLot {
private static volatile ParkingLot instance;
private final String name;
private final List<ParkingLevel> levels;
private final Map<String, ParkingTicket> activeTickets; // ticketId -> ticket
private PricingStrategy pricingStrategy;
private ParkingLot(String name) {
this.name = name;
this.levels = new ArrayList<>();
this.activeTickets = new ConcurrentHashMap<>();
this.pricingStrategy = new HourlyPricing(20.0); // βΉ20/hr default
}
// Double-checked locking for thread-safe lazy init
public static ParkingLot getInstance(String name) {
if (instance == null) {
synchronized (ParkingLot.class) {
if (instance == null) instance = new ParkingLot(name);
}
}
return instance;
}
public void addLevel(ParkingLevel level) { levels.add(level); }
public void setPricingStrategy(PricingStrategy s) { this.pricingStrategy = s; }
/** Park a vehicle. Returns a ticket or throws if lot is full. */
public synchronized ParkingTicket park(Vehicle vehicle) {
for (ParkingLevel level : levels) {
ParkingSpot spot = level.findSpot(vehicle);
if (spot != null) {
spot.park(vehicle);
ParkingTicket ticket = new ParkingTicket(vehicle, spot);
activeTickets.put(ticket.getTicketId(), ticket);
System.out.printf("Parked %s at %s (Ticket: %s)%n",
vehicle.getLicensePlate(), spot.getSpotId(), ticket.getTicketId());
return ticket;
}
}
throw new IllegalStateException("Parking lot is full!");
}
/** Exit using a ticket. Calculates fare and frees the spot. */
public synchronized double exit(String ticketId) {
ParkingTicket ticket = activeTickets.get(ticketId);
if (ticket == null) throw new IllegalArgumentException("Invalid ticket: " + ticketId);
if (ticket.getStatus() != TicketStatus.ACTIVE)
throw new IllegalStateException("Ticket already processed.");
double fare = pricingStrategy.calculate(ticket);
ticket.pay(fare);
ticket.getSpot().vacate();
activeTickets.remove(ticketId);
System.out.printf("Vehicle %s exited. Duration: %d min. Fare: βΉ%.2f%n",
ticket.getVehicle().getLicensePlate(), ticket.getDurationMinutes(), fare);
return fare;
}
public long totalAvailableSpots() {
return levels.stream().mapToLong(ParkingLevel::availableCount).sum();
}
}The demo wires everything together: creates the lot, adds two levels, parks three vehicles of different types, then exits one and prints the fare.
// ββ Demo βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
public class Main {
public static void main(String[] args) throws InterruptedException {
// Set up the lot
ParkingLot lot = ParkingLot.getInstance("City Center Mall");
lot.addLevel(new ParkingLevel(1, 10, 20, 5)); // Level 1
lot.addLevel(new ParkingLevel(2, 5, 15, 3)); // Level 2
lot.setPricingStrategy(new HourlyPricing(20.0));
// Park vehicles
Vehicle myCar = new Car("KA-01-AB-1234");
Vehicle myBike = new Motorcycle("KA-02-CD-5678");
Vehicle myTruck = new Truck("MH-01-EF-9999");
ParkingTicket t1 = lot.park(myCar);
ParkingTicket t2 = lot.park(myBike);
ParkingTicket t3 = lot.park(myTruck);
System.out.println("Available spots: " + lot.totalAvailableSpots());
// Simulate 90 minutes passing
Thread.sleep(1000); // in tests use mocked clocks
// Exit and pay
double fare = lot.exit(t1.getTicketId());
System.out.println("Paid: βΉ" + fare);
}
}Expected output:
Parked KA-01-AB-1234 at L1-M-1 (Ticket: abc-123...)
Parked KA-02-CD-5678 at L1-S-1 (Ticket: def-456...)
Parked MH-01-EF-9999 at L1-L-1 (Ticket: ghi-789...)
Available spots: 50
Vehicle KA-01-AB-1234 exited. Duration: 0 min. Fare: βΉ20.00
Concurrency is a common follow-up. Let's examine the specific race conditions we're preventing:
Race Condition: Double-Assignment
Without synchronization: Thread A calls findSpot() and gets Spot-5. Before Thread A calls spot.park(), Thread B also calls findSpot(), also gets Spot-5 (still available), and parks. Now two vehicles are assigned the same spot.
β Fix: Wrapping park() in synchronized/lock ensures findSpot + spot.park is an atomic operation.
Race Condition: Double-Exit
Without the status check, two threads could both call exit(ticketId) with the same ticket, both calculate fares, both vacate the spot β resulting in double-charge and corrupted state.
β Fix: Checking status == ACTIVE and removing from activeTickets inside the lock prevents this.
Singleton Initialization Race
Without double-checked locking, two threads could both see instance == null and both create a ParkingLot, resulting in two separate instances with independent state.
β Fix: volatile (Java) + synchronized inner block, or threading.Lock (Python) guarantees single creation.
Interviewers rarely ask for formal complexity analysis in LLD rounds, but being able to reason about performance shows maturity. Here's the analysis for our key operations:
| Operation | Time | Notes |
|---|---|---|
| park(vehicle) | O(L Γ S) | L=levels, S=spots per level; scans until a fit is found |
| exit(ticketId) | O(1) | HashMap lookup by ticketId |
| availableCount() | O(S) | Linear scan of all spots on a level |
| totalAvailableSpots() | O(L Γ S) | Sum across all levels |
| findSpot(vehicle) | O(S) | First-fit scan, short-circuits on first match |
park() faster, maintain per-level LinkedHashSet or priority queue of available spots grouped by size. This would reduce findSpot to O(1) amortized, at the cost of O(L Γ S) additional memory.16. Key Design Decisions
Strategy Pattern for Pricing
Pricing logic is extracted into a PricingStrategy interface, making it trivial to swap hourly, daily, flat-rate, or surge pricing without touching the core ParkingLot class.
Singleton for ParkingLot
Only one ParkingLot instance should exist per process. A double-checked locking singleton (with volatile in Java, or threading.Lock in Python) ensures thread-safe lazy initialisation.
SpotSize ordinal comparison
SpotSize enums are ordered SMALL < MEDIUM < LARGE. canFit() uses ordinal comparison so a Car can park in a LARGE spot when MEDIUM is unavailable β a sensible real-world fallback.
First-fit vs Best-fit spot selection
We use first-fit (first available spot that fits). Best-fit (smallest fitting spot) minimises wasted space but is O(n) scan with sorting. Choose based on business priority.
Use these to demonstrate proactive thinking in an interview. Don't implement all of them β just discuss the approach and how it fits into the existing design.
EV Charging Spots
Subclass ParkingSpot into ChargingSpot with a chargerSpeed field. Add CHARGING to SpotSize or handle via a spot tag/capability set.
Reserved / VIP Spots
Add a boolean reserved flag and a reservedFor: String field on ParkingSpot. The findSpot() logic skips reserved spots for regular vehicles.
Display Board
Add an Observer/Publisher on ParkingLevel that emits events when availability changes. The display board subscribes and re-renders.
Monthly Pass
Create a PassHolder entity. Override exit() to check if the vehicle has a valid pass; if so, skip pricing calculation entirely.
Distributed Multi-Lot
Extract ParkingLot to a service layer backed by a shared database (e.g., Redis for spot state) with optimistic locking for thread safety across nodes.
Entry/Exit Gates
Add a Gate class (EntryGate / ExitGate) that wraps park() / exit(). Each gate has a gateId and can be monitored independently (useful for access control logs).
17. Common Follow-up Interview Questions
- How would you handle electric vehicles needing charging spots?
π‘ Subclass ParkingSpot β ChargingSpot, add capability filtering in findSpot()
- How do you implement reserved / VIP parking spots?
π‘ Add a reserved flag on ParkingSpot, skip in standard findSpot() flow
- How would you add a display board showing available spots per floor?
π‘ Observer pattern β ParkingLevel emits events, DisplayBoard subscribes
- How would you scale this to a distributed system (multiple lots across a city)?
π‘ Stateless service layer + shared Redis for spot state + distributed locking (Redlock)
- How do you prevent two threads from assigning the same spot simultaneously?
π‘ synchronized block around findSpot + park as an atomic unit
- How would you support monthly pass holders?
π‘ PassHolder entity, override fare logic to return 0 for valid pass vehicles
- What if the pricing strategy needs to vary by vehicle type?
π‘ Strategy.calculate() already receives the full ticket (which contains the vehicle) β extend pricing logic there
- How would you handle lost tickets?
π‘ Add a LOST status + override exit to charge a flat lost-ticket fee, then vacate the spot