Design a Parking Lot: The Classic LLD Interview Problem, Reasoned Through

The parking lot problem is one of those interview questions that sounds almost embarrassingly simple. You have spots, you have cars, you assign cars to spots. It takes maybe ten minutes to sketch the “obvious” version, and then the interviewer starts adding requirements and you realize you built yourself into a corner.

The trap is jumping straight to implementation before thinking about the entities. If you model ParkingSpot as a class with subclasses CompactSpot, LargeSpot, and HandicappedSpot, you now have three places to add logic whenever the rules change. If you model spot type as an enum on a single ParkingSpot class, you have one. That choice sounds small, but it ripples through everything downstream: how you search for available spots, how you match vehicles to spots, how you calculate fees.

Let me reason through the whole design from scratch, making the trade-offs explicit at each step.

Requirements

Functional:

  • Multiple floors, each with multiple spots of varying types (compact, large, handicapped)
  • Vehicle types: motorcycle, car, truck. Spot eligibility depends on the pairing (e.g., cars only fit compact and large)
  • Issue a ticket when a vehicle enters, close the ticket on exit
  • Calculate fees on exit using a configurable pricing strategy (hourly, flat rate, premium)
  • Display boards on each floor show available spot counts per type in real time

Non-functional:

  • The system must handle concurrent entries without double-assigning a spot
  • Adding a new pricing model should not require touching the ticket or spot classes
  • The design must extend to hundreds of floors without structural changes

Core Entities

ParkingSpot owns a location (floor, spot ID), a type (enum), and a boolean for occupancy. It does not own vehicle logic. It does not calculate fees. It knows where it is and whether it is taken.

Vehicle carries a license plate and a vehicle type (also an enum). It does not know which spot it fits into. That matching logic belongs in a dedicated place so the rules can change without touching Vehicle.

SpotMatcher is a small helper that given a vehicle type returns the set of spot types it is allowed to occupy. Keeping this separate means the eligibility table lives in exactly one place.

ParkingFloor owns a collection of spots and answers one key question: given a vehicle type, find the nearest available spot. It also notifies the display board when occupancy changes.

ParkingLot is the top-level orchestrator. It owns the floors, the active tickets, and the pricing strategy. It exposes enter() and exit() and nothing else to external callers.

Ticket is a value object capturing the entry moment, the vehicle, and the assigned spot. On exit it gets stamped with the exit time, and the pricing strategy uses both timestamps to compute the fee.

PricingStrategy is an abstract base that takes a Ticket and returns a fee in dollars. Concrete implementations handle hourly, flat, and premium pricing.

DisplayBoard subscribes to occupancy events per floor and keeps a current count of free spots by type. This is the Observer pattern at work: the floor fires an event, the board updates itself, no tight coupling required.

ASCII Class Diagram

+--------------------+     +----------------------+
|   ParkingLot       |---->|  PricingStrategy     |
|--------------------|     |----------------------|
| - floors           |     | + calculate(ticket)  |
| - tickets          |     +----------+-----------+
| - strategy         |               |
| + enter(vehicle)   |     HourlyFee  FlatFee  PremiumFee
| + exit(ticket_id)  |
+--------------------+
         |
         | owns
         v
+--------------------+     +-------------------+
|   ParkingFloor     |---->|  DisplayBoard     |
|--------------------|     |-------------------|
| - floor_num        |     | + on_change(type, |
| - spots            |     |     delta: int)   |
| + find_spot(vtype) |     +-------------------+
| - _notify(...)     |
+--------------------+
         |
         | owns
         v
+--------------------+     +-------------------+
|   ParkingSpot      |     |   Vehicle         |
|--------------------|     |-------------------|
| - spot_id          |     | - license_plate   |
| - spot_type        |     | - vehicle_type    |
| - is_occupied      |     +-------------------+
| + occupy()         |
| + vacate()         |
+--------------------+
         ^
         |
+--------------------+
|   Ticket           |
|--------------------|
| - ticket_id        |
| - vehicle          |
| - spot             |
| - entry_time       |
| - exit_time        |
+--------------------+

Key Implementation

from __future__ import annotations

import threading
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, auto
from typing import Optional


class SpotType(Enum):
    COMPACT = auto()
    LARGE = auto()
    HANDICAPPED = auto()


class VehicleType(Enum):
    MOTORCYCLE = auto()
    CAR = auto()
    TRUCK = auto()


# Eligibility table: which spot types each vehicle type can use.
# Changing a rule means changing this dict and nothing else.
ELIGIBLE_SPOTS: dict[VehicleType, list[SpotType]] = {
    VehicleType.MOTORCYCLE: [SpotType.COMPACT, SpotType.LARGE],
    VehicleType.CAR: [SpotType.COMPACT, SpotType.LARGE],
    VehicleType.TRUCK: [SpotType.LARGE],
}


@dataclass
class Vehicle:
    license_plate: str
    vehicle_type: VehicleType


@dataclass
class ParkingSpot:
    spot_id: str
    floor_num: int
    spot_type: SpotType
    is_occupied: bool = False

    def occupy(self) -> None:
        if self.is_occupied:
            raise ValueError(f"Spot {self.spot_id} is already occupied.")
        self.is_occupied = True

    def vacate(self) -> None:
        self.is_occupied = False


@dataclass
class Ticket:
    ticket_id: str
    vehicle: Vehicle
    spot: ParkingSpot
    entry_time: datetime
    exit_time: Optional[datetime] = None

    def close(self) -> None:
        self.exit_time = datetime.now()

    def duration_hours(self) -> float:
        if self.exit_time is None:
            raise ValueError("Ticket is not closed yet.")
        delta = self.exit_time - self.entry_time
        return delta.total_seconds() / 3600


class PricingStrategy(ABC):
    @abstractmethod
    def calculate(self, ticket: Ticket) -> float:
        pass


class HourlyFee(PricingStrategy):
    def __init__(self, rate_per_hour: float) -> None:
        self._rate = rate_per_hour

    def calculate(self, ticket: Ticket) -> float:
        # Ceil to the nearest hour so a 61-minute stay costs 2 hours.
        import math
        return math.ceil(ticket.duration_hours()) * self._rate


class FlatFee(PricingStrategy):
    def __init__(self, flat_rate: float) -> None:
        self._rate = flat_rate

    def calculate(self, ticket: Ticket) -> float:
        return self._rate


class PremiumFee(PricingStrategy):
    """Higher rate for the first hour, standard hourly after that."""

    def __init__(self, premium_first_hour: float, rate_per_hour: float) -> None:
        self._premium = premium_first_hour
        self._standard = rate_per_hour

    def calculate(self, ticket: Ticket) -> float:
        import math
        hours = ticket.duration_hours()
        if hours <= 1:
            return self._premium
        return self._premium + math.ceil(hours - 1) * self._standard


class DisplayBoard:
    def __init__(self, floor_num: int) -> None:
        self._floor_num = floor_num
        self._counts: dict[SpotType, int] = {t: 0 for t in SpotType}

    def set_count(self, spot_type: SpotType, count: int) -> None:
        self._counts[spot_type] = count

    def on_change(self, spot_type: SpotType, delta: int) -> None:
        self._counts[spot_type] = max(0, self._counts[spot_type] + delta)

    def available(self, spot_type: SpotType) -> int:
        return self._counts[spot_type]


class ParkingFloor:
    def __init__(self, floor_num: int, spots: list[ParkingSpot]) -> None:
        self._floor_num = floor_num
        self._spots = spots
        self._lock = threading.Lock()
        self._board = DisplayBoard(floor_num)
        self._init_board()

    def _init_board(self) -> None:
        for spot_type in SpotType:
            count = sum(
                1 for s in self._spots
                if s.spot_type == spot_type and not s.is_occupied
            )
            self._board.set_count(spot_type, count)

    def find_and_assign(self, vehicle_type: VehicleType) -> Optional[ParkingSpot]:
        """
        Finds the nearest free spot for the vehicle type.
        Uses a lock to prevent two threads from claiming the same spot.
        Spots are stored in order, so the first match is the nearest.
        """
        eligible = ELIGIBLE_SPOTS[vehicle_type]
        with self._lock:
            for spot in self._spots:
                if not spot.is_occupied and spot.spot_type in eligible:
                    spot.occupy()
                    self._board.on_change(spot.spot_type, -1)
                    return spot
        return None

    def release(self, spot: ParkingSpot) -> None:
        with self._lock:
            spot.vacate()
            self._board.on_change(spot.spot_type, +1)

    def available_count(self, spot_type: SpotType) -> int:
        return self._board.available(spot_type)


class ParkingLot:
    def __init__(
        self,
        floors: list[ParkingFloor],
        strategy: PricingStrategy,
    ) -> None:
        self._floors = floors
        self._strategy = strategy
        self._tickets: dict[str, Ticket] = {}

    def enter(self, vehicle: Vehicle) -> Ticket:
        for floor in self._floors:
            spot = floor.find_and_assign(vehicle.vehicle_type)
            if spot:
                ticket = Ticket(
                    ticket_id=str(uuid.uuid4()),
                    vehicle=vehicle,
                    spot=spot,
                    entry_time=datetime.now(),
                )
                self._tickets[ticket.ticket_id] = ticket
                return ticket
        raise RuntimeError("Parking lot is full.")

    def exit(self, ticket_id: str) -> float:
        ticket = self._tickets.get(ticket_id)
        if ticket is None:
            raise ValueError(f"Unknown ticket: {ticket_id}")
        ticket.close()
        fee = self._strategy.calculate(ticket)
        floor = self._floors[ticket.spot.floor_num]
        floor.release(ticket.spot)
        del self._tickets[ticket_id]
        return fee

Design Decisions and Trade-offs

Enum for spot type vs. inheritance.

The inheritance path is tempting: CompactSpot(ParkingSpot), LargeSpot(ParkingSpot), each with its own behavior. The problem is that spot behavior is entirely uniform. All spots occupy and vacate the same way. The only thing that varies is the type label and the eligibility rules. Subclassing to hold a type label is the classic over-engineering trap. An enum captures the variation cleanly, and the ELIGIBLE_SPOTS dict captures the rules in one place. If you later need spot-type-specific behavior (say, handicapped spots require a permit check), you can add a method to ParkingSpot that branches on self.spot_type without restructuring anything.

Observer for the display board.

The floor does not reach into the display board and update counts directly in a procedural way. Instead, it notifies the board through on_change() with a signed delta. This means the board can be replaced, extended, or mocked in tests without touching the floor. A future version could push these updates to a WebSocket broadcast or a digital signage API by swapping the board implementation, and ParkingFloor would not notice.

Concurrent spot allocation.

The tricky part is two cars arriving simultaneously and being assigned to the same spot. The lock in find_and_assign is a coarse-grained mutex on the floor. While a car is being assigned, no other car on that floor can be processed. This is the pessimistic approach and it is correct. The optimistic alternative (check then compare-and-swap) requires a CAS primitive on the spot’s occupied flag, which Python does not expose cleanly. For a parking lot with dozens of cars per minute, the pessimistic lock is fast enough and easier to reason about.

Finding the nearest spot.

The implementation above treats “nearest” as the first spot in a pre-ordered list. The real definition of nearest depends on the layout: in a single-floor lot it might be Euclidean distance from the entrance. In a multi-floor lot it might factor in elevator proximity. By keeping the search inside ParkingFloor.find_and_assign(), you can replace the linear scan with a priority queue ordered by distance without changing anything outside the class.

Pricing strategy and the Strategy pattern.

Adding a new pricing model (say, weekend rates or validation-based free parking) means adding a new class that implements PricingStrategy.calculate(). ParkingLot does not change. Ticket does not change. The Strategy pattern costs one extra level of indirection and buys you completely isolated, independently testable pricing logic.

What I’d Extend Next

If I were extending this further, I’d add a PaymentProcessor that takes a fee and a payment method, keeping payment logic out of ParkingLot. I’d also make Ticket immutable after closing by raising on any mutation after exit_time is set. For analytics, a ParkingEvent log that records every entry, exit, and spot assignment lets you reconstruct occupancy history without querying live state.


If any of this sparked a question or you’d approach the concurrency problem differently, I’d genuinely like to hear it. Reach out on Twitter or LinkedIn.

Tags:

#lld #machine-coding #object-oriented-design #python #parking

Related Posts

Let's Connect! 💬

Whether you're looking to hire, want to collaborate on a project, or just want to chat about tech—I'd love to hear from you!