Back to LLD Concepts
LLD Tutorial
Medium

Parking Lot System Design

Design a multi-level parking lot that supports different vehicle types, auto-assigns spots, issues tickets, and calculates fees β€” a classic LLD interview question. Complete with UML diagrams, flow charts, and annotated code in Java & Python.

OOPStrategy PatternSingletonThread SafetyFactory MethodSOLID Principles

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
πŸ’‘ Interview tip: Also ask about non-functional requirements β€” scale (1 lot vs. a city network?), persistence (in-memory vs. DB?), and payment integration (in-scope or out-of-scope?). These shape your design significantly.

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 / InterfaceTypeResponsibility
VehicleTypeEnumMOTORCYCLE Β· CAR Β· TRUCK
SpotSizeEnumSMALL Β· MEDIUM Β· LARGE
VehicleAbstract ClassBase for Motorcycle, Car, Truck
ParkingSpotClassHolds a vehicle; knows its size
ParkingLevelClassA floor with a list of spots
ParkingTicketClassEntry record with timestamps
PricingStrategyInterfacecalculate(ticket) β†’ fare
ParkingLotSingletonOrchestrates 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.LARGE

Understanding 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.

Vehicle Entry & Exit FlowVehicle ArrivesSpaceAvailable?NoLot Full!YesFind Best-Fit Spot(level by level, first-fit)Assign Spotspot.park(vehicle)Issue TicketUUID + entryTime recordedVehicle Parks βœ“Exit FlowVehicle Wants to ExitValidate Ticketstatus == ACTIVE?Calculate DurationexitTime βˆ’ entryTimeApply Pricing StrategyHourlyPricing / FlatRate…ticket.pay(fare)status β†’ PAIDspot.vacate()parkedVehicle = nullVehicle Exits βœ“β”€β”€β”€β”€ Entry ────────────────────── Exit ────

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.

UML Class Diagram β€” Parking Lot SystemΒ«singletonΒ»ParkingLot- name: String- levels: List<Level>- activeTickets: Map- pricingStrategy+ park(vehicle): Ticket+ exit(ticketId): double+ totalAvailable(): longParkingLevel- levelNumber: int- spots: List<Spot>+ findSpot(v): Spot?+ availableCount(): intParkingSpot- spotId: String- size: SpotSize- parkedVehicle: Vehicle?+ canFit(v): boolean+ park(v)+ vacate()ParkingTicket- ticketId: UUID- vehicle: Vehicle- spot: ParkingSpot- entryTime: Instant- status: TicketStatus+ pay(amount)+ getDurationMinutes()Β«abstractΒ»Vehicle# licensePlate: String# type: VehicleType+ getRequiredSpotSize()*Motorcycle+ getRequired...Car+ getRequired...Truck+ getRequired...Β«interfaceΒ»PricingStrategy+ calculate(ticket): doubleHourlyPricing- ratePerHour+ calculate(t)FlatRatePricing- flatRate+ calculate(t)1..*0..*uses1..*hasInheritance / ImplementationAssociation / CompositionSingleton / AbstractInterfaceConcrete Class

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

The abstract 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.

SpotSize Compatibility MatrixSMALL SpotMEDIUM SpotLARGE SpotMotorcycleCarTruckβœ“ fitsβœ“ fitsβœ“ fitsβœ— too bigβœ“ fitsβœ“ fitsβœ— too bigβœ— too bigβœ“ fits
// ── 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; }
}
Note on spot IDs: Using a structured ID like "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).

ticketId

UUID β€” globally unique, used as the lookup key

entryTime

Immutable β€” set at creation, never changes

exitTime

Set only when pay() is called

status

ACTIVE / 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

The 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.
Strategy Pattern β€” Pluggable PricingParkingLot- pricingStrategy+ exit(ticketId) β†’ strategy.calculate(t)delegatesΒ«interfaceΒ»PricingStrategy+ calculate(ticket): doubleHourlyPricing- ratePerHour: β‚Ή20ceil(hrs) Γ— ratee.g. 90min β†’ β‚Ή40FlatRatePricing- flatRate: β‚Ή100always β‚Ή100e.g. daily passTieredPricing- firstRate / afterRate2hr cheap, then ↑e.g. peak pricing

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

A 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!
Singleton β€” Thread-Safe Double-Checked LockingThread 1Thread 2πŸ”’ synchronizedOnly 1 thread entersParkingLot_instance (shared)βœ“ created onceThread 2 gets same instance

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:

OperationTimeNotes
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
Optimization opportunity: To makepark() 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