Design a Parking Lot

Mediumobject-oriented design · design patterns · strategy pattern · concurrency · java⏱ 11 min readAsked at Amazon, Microsoft, Google, Uber, Goldman SachsUpdated

A classic object-oriented design question - model vehicles, spots, levels, tickets and pricing with clean abstractions and thread-safe spot assignment.

Requirements

Low-level design interviews test whether you can turn fuzzy requirements into clean classes, responsibilities and relationships. Start by pinning the scope.

Functional

  • The lot has multiple levels; each level has spots of different sizes (small, compact, large).
  • Supports motorcycles, cars and trucks. A vehicle fits any spot at least as big as it needs:
    • Motorcycle → small, compact or large
    • Car → compact or large
    • Truck → large only
  • On entry, assign a spot and issue a ticket; reject the vehicle if the lot is full.
  • On exit, compute the fee from the ticket and free the spot.
  • Show availability per spot size.

Non-functional

  • Multiple entry and exit gates operate concurrently; two cars must never get the same spot.
  • Pricing and spot-assignment rules should be swappable without touching core classes.
  • Easy to extend with new vehicle types (e.g. electric vehicles needing chargers).

Out of scope (confirm with the interviewer)

  • Payment processing, reservations, license-plate cameras, persistence.
Tip

Say your assumptions out loud: "one vehicle per spot, a truck takes one large spot, fees per started hour". It is far better than silently picking a complex variant like multi-spot trucks.

Core Entities

Entity Responsibility Key fields
Vehicle (abstract) Knows its plate, type and required spot size licensePlate, type
Motorcycle, Car, Truck Concrete vehicles override requiredSize()
ParkingSpot One physical spot; atomic assign/release id, size, vehicle
Level Groups spots on one floor; reports free counts number, spots
Ticket Proof of entry; links vehicle, spot and time id, vehicle, spot, entryTime
SpotAssignmentStrategy Chooses which free spot to use interface
PricingStrategy Computes the fee for a ticket interface
ParkingLot Facade: park, unpark, availability; owns active tickets levels, strategies, activeTickets
VehicleFactory Creates the right Vehicle subtype from a type static create()

Notice what is not an entity: Gate, Payment, DisplayBoard. Add them only if the interviewer asks; they hang off ParkingLot without changing the core.

Class Diagram

classDiagram
  class Vehicle {
    <<abstract>>
    -String licensePlate
    -VehicleType type
    +requiredSize() SpotSize
  }
  class Motorcycle
  class Car
  class Truck
  Vehicle <|-- Motorcycle
  Vehicle <|-- Car
  Vehicle <|-- Truck

  class ParkingSpot {
    -String id
    -SpotSize size
    -Vehicle vehicle
    +canFit(Vehicle) boolean
    +assign(Vehicle) boolean
    +release() void
  }
  class Level {
    -int number
    -List~ParkingSpot~ spots
    +freeCount(SpotSize) long
  }
  class Ticket {
    -String id
    -Vehicle vehicle
    -ParkingSpot spot
    -LocalDateTime entryTime
  }
  class ParkingLot {
    -List~Level~ levels
    -Map activeTickets
    +park(Vehicle, LocalDateTime) Optional~Ticket~
    +unpark(String, LocalDateTime) long
    +availability() Map
  }
  class SpotAssignmentStrategy {
    <<interface>>
    +findSpot(List~Level~, Vehicle) Optional~ParkingSpot~
  }
  class PricingStrategy {
    <<interface>>
    +feeInCents(Ticket, LocalDateTime) long
  }
  class BestFitLowestLevelStrategy
  class HourlyPricingStrategy
  class VehicleFactory {
    +create(VehicleType, String) Vehicle
  }

  ParkingLot "1" *-- "many" Level
  Level "1" *-- "many" ParkingSpot
  ParkingLot "1" o-- "many" Ticket
  Ticket --> Vehicle
  Ticket --> ParkingSpot
  ParkingSpot --> Vehicle
  ParkingLot --> SpotAssignmentStrategy
  ParkingLot --> PricingStrategy
  SpotAssignmentStrategy <|.. BestFitLowestLevelStrategy
  PricingStrategy <|.. HourlyPricingStrategy
  VehicleFactory ..> Vehicle : creates

Design Patterns Used

Pattern Where Why it is justified
Strategy PricingStrategy, SpotAssignmentStrategy Pricing (hourly, flat, weekend, surge) and assignment (nearest to gate, best fit, EV-first) change often; new rules become new classes, not if chains
Factory VehicleFactory Gates receive a vehicle type from a sensor or UI; one place maps type → subtype
Facade ParkingLot Clients call park / unpark without knowing about levels or spots
Polymorphism over conditionals Vehicle.requiredSize() Each vehicle knows what it needs; adding a vehicle type does not modify spot logic

What about Singleton? Interviewers often expect ParkingLot.getInstance(). A real system has exactly one lot per process, but a hard-coded Singleton makes testing harder (global state, no fresh instance per test) and breaks if the company later manages several lots. A better answer: "one instance, created at startup and passed in via dependency injection". Mention the trade-off rather than reflexively using the pattern.

Insight

canFit compares enum ordinals (SMALL < COMPACT < LARGE), so "fits in this size or larger" is one line. The best-fit strategy then picks the smallest fitting spot, keeping large spots free for trucks.

Java Implementation

The code compiles as a single Main.java (Java 17+). Times are passed in explicitly so the demo is deterministic; production code would inject a java.time.Clock. Money is held in cents to avoid floating-point rounding.

java
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

enum VehicleType { MOTORCYCLE, CAR, TRUCK }

enum SpotSize { SMALL, COMPACT, LARGE }   // ordered smallest to largest

abstract class Vehicle {
    private final String licensePlate;
    private final VehicleType type;

    protected Vehicle(String licensePlate, VehicleType type) {
        this.licensePlate = licensePlate;
        this.type = type;
    }

    String getLicensePlate() { return licensePlate; }
    VehicleType getType() { return type; }
    abstract SpotSize requiredSize();

    @Override public String toString() { return type + "(" + licensePlate + ")"; }
}

class Motorcycle extends Vehicle {
    Motorcycle(String plate) { super(plate, VehicleType.MOTORCYCLE); }
    SpotSize requiredSize() { return SpotSize.SMALL; }
}

class Car extends Vehicle {
    Car(String plate) { super(plate, VehicleType.CAR); }
    SpotSize requiredSize() { return SpotSize.COMPACT; }
}

class Truck extends Vehicle {
    Truck(String plate) { super(plate, VehicleType.TRUCK); }
    SpotSize requiredSize() { return SpotSize.LARGE; }
}

final class VehicleFactory {
    private VehicleFactory() {}

    static Vehicle create(VehicleType type, String plate) {
        return switch (type) {
            case MOTORCYCLE -> new Motorcycle(plate);
            case CAR -> new Car(plate);
            case TRUCK -> new Truck(plate);
        };
    }
}

class ParkingSpot {
    private final String id;
    private final SpotSize size;
    private Vehicle vehicle;

    ParkingSpot(String id, SpotSize size) {
        this.id = id;
        this.size = size;
    }

    String getId() { return id; }
    SpotSize getSize() { return size; }

    synchronized boolean isFree() { return vehicle == null; }

    boolean canFit(Vehicle v) { return size.compareTo(v.requiredSize()) >= 0; }

    // Atomic check-and-set: only one thread can win a given spot.
    synchronized boolean assign(Vehicle v) {
        if (vehicle != null || !canFit(v)) return false;
        vehicle = v;
        return true;
    }

    synchronized void release() { vehicle = null; }
}

class Level {
    private final int number;
    private final List<ParkingSpot> spots = new ArrayList<>();

    Level(int number, int small, int compact, int large) {
        this.number = number;
        addSpots(SpotSize.SMALL, small);
        addSpots(SpotSize.COMPACT, compact);
        addSpots(SpotSize.LARGE, large);
    }

    private void addSpots(SpotSize size, int count) {
        for (int i = 0; i < count; i++) {
            spots.add(new ParkingSpot("L" + number + "-" + size.name().charAt(0) + (i + 1), size));
        }
    }

    int getNumber() { return number; }
    List<ParkingSpot> getSpots() { return Collections.unmodifiableList(spots); }

    long freeCount(SpotSize size) {
        return spots.stream().filter(s -> s.getSize() == size && s.isFree()).count();
    }
}

interface SpotAssignmentStrategy {
    Optional<ParkingSpot> findSpot(List<Level> levels, Vehicle vehicle);
}

// Lowest level first; within a level, the smallest spot that fits (keeps big spots for big vehicles).
class BestFitLowestLevelStrategy implements SpotAssignmentStrategy {
    public Optional<ParkingSpot> findSpot(List<Level> levels, Vehicle vehicle) {
        for (Level level : levels) {
            Optional<ParkingSpot> spot = level.getSpots().stream()
                    .filter(s -> s.isFree() && s.canFit(vehicle))
                    .min(Comparator.comparing(ParkingSpot::getSize));
            if (spot.isPresent()) return spot;
        }
        return Optional.empty();
    }
}

class Ticket {
    private final String id;
    private final Vehicle vehicle;
    private final ParkingSpot spot;
    private final LocalDateTime entryTime;

    Ticket(String id, Vehicle vehicle, ParkingSpot spot, LocalDateTime entryTime) {
        this.id = id;
        this.vehicle = vehicle;
        this.spot = spot;
        this.entryTime = entryTime;
    }

    String getId() { return id; }
    Vehicle getVehicle() { return vehicle; }
    ParkingSpot getSpot() { return spot; }
    LocalDateTime getEntryTime() { return entryTime; }
}

interface PricingStrategy {
    long feeInCents(Ticket ticket, LocalDateTime exitTime);
}

// Charges per started hour, minimum one hour, with a rate per vehicle type.
class HourlyPricingStrategy implements PricingStrategy {
    private final Map<VehicleType, Long> centsPerHour = new EnumMap<>(VehicleType.class);

    HourlyPricingStrategy() {
        centsPerHour.put(VehicleType.MOTORCYCLE, 100L);
        centsPerHour.put(VehicleType.CAR, 250L);
        centsPerHour.put(VehicleType.TRUCK, 500L);
    }

    public long feeInCents(Ticket ticket, LocalDateTime exitTime) {
        long minutes = Duration.between(ticket.getEntryTime(), exitTime).toMinutes();
        long hours = Math.max(1, (minutes + 59) / 60);
        return hours * centsPerHour.get(ticket.getVehicle().getType());
    }
}

class ParkingLot {
    private final String name;
    private final List<Level> levels;
    private final SpotAssignmentStrategy assignmentStrategy;
    private final PricingStrategy pricingStrategy;
    private final Map<String, Ticket> activeTickets = new ConcurrentHashMap<>();
    private final AtomicLong ticketSequence = new AtomicLong();

    ParkingLot(String name, List<Level> levels,
               SpotAssignmentStrategy assignmentStrategy, PricingStrategy pricingStrategy) {
        this.name = name;
        this.levels = List.copyOf(levels);
        this.assignmentStrategy = assignmentStrategy;
        this.pricingStrategy = pricingStrategy;
    }

    Optional<Ticket> park(Vehicle vehicle, LocalDateTime entryTime) {
        while (true) {
            Optional<ParkingSpot> candidate = assignmentStrategy.findSpot(levels, vehicle);
            if (candidate.isEmpty()) return Optional.empty();
            ParkingSpot spot = candidate.get();
            if (spot.assign(vehicle)) {   // lost the race? loop and pick another spot
                String id = "T" + ticketSequence.incrementAndGet();
                Ticket ticket = new Ticket(id, vehicle, spot, entryTime);
                activeTickets.put(id, ticket);
                return Optional.of(ticket);
            }
        }
    }

    long unpark(String ticketId, LocalDateTime exitTime) {
        Ticket ticket = activeTickets.remove(ticketId);
        if (ticket == null) throw new IllegalArgumentException("Unknown or used ticket: " + ticketId);
        long fee = pricingStrategy.feeInCents(ticket, exitTime);
        ticket.getSpot().release();
        return fee;
    }

    Map<SpotSize, Long> availability() {
        Map<SpotSize, Long> free = new EnumMap<>(SpotSize.class);
        for (SpotSize size : SpotSize.values()) {
            free.put(size, levels.stream().mapToLong(l -> l.freeCount(size)).sum());
        }
        return free;
    }

    String getName() { return name; }
}

public class Main {
    public static void main(String[] args) {
        ParkingLot lot = new ParkingLot("City Center",
                List.of(new Level(0, 1, 1, 1), new Level(1, 0, 2, 1)),
                new BestFitLowestLevelStrategy(),
                new HourlyPricingStrategy());

        System.out.println(lot.getName() + " free spots: " + lot.availability());

        LocalDateTime t0 = LocalDateTime.of(2026, 10, 29, 9, 0);
        List<Vehicle> arrivals = List.of(
                VehicleFactory.create(VehicleType.MOTORCYCLE, "MC-1"),
                VehicleFactory.create(VehicleType.CAR, "CAR-1"),
                VehicleFactory.create(VehicleType.TRUCK, "TRK-1"),
                VehicleFactory.create(VehicleType.CAR, "CAR-2"),
                VehicleFactory.create(VehicleType.TRUCK, "TRK-2"),
                VehicleFactory.create(VehicleType.TRUCK, "TRK-3"));

        Map<String, Ticket> tickets = new LinkedHashMap<>();
        for (Vehicle v : arrivals) {
            Optional<Ticket> ticket = lot.park(v, t0);
            if (ticket.isPresent()) {
                Ticket t = ticket.get();
                tickets.put(v.getLicensePlate(), t);
                System.out.println("Parked " + v + " at " + t.getSpot().getId() + " ticket " + t.getId());
            } else {
                System.out.println("No spot for " + v);
            }
        }
        System.out.println("Free spots: " + lot.availability());

        long carFee = lot.unpark(tickets.get("CAR-1").getId(), t0.plusMinutes(95));
        long truckFee = lot.unpark(tickets.get("TRK-1").getId(), t0.plusMinutes(30));
        System.out.printf("CAR-1 paid $%.2f after 95 min%n", carFee / 100.0);
        System.out.printf("TRK-1 paid $%.2f after 30 min%n", truckFee / 100.0);

        Optional<Ticket> retry = lot.park(VehicleFactory.create(VehicleType.TRUCK, "TRK-3"), t0.plusMinutes(31));
        System.out.println("Retry TRK-3: " + retry.map(t -> "parked at " + t.getSpot().getId()).orElse("still full"));
        System.out.println("Free spots: " + lot.availability());

        try {
            lot.unpark(tickets.get("CAR-1").getId(), t0.plusHours(3));
        } catch (IllegalArgumentException e) {
            System.out.println("Rejected: " + e.getMessage());
        }
    }
}

Demo output

text
City Center free spots: {SMALL=1, COMPACT=3, LARGE=2}
Parked MOTORCYCLE(MC-1) at L0-S1 ticket T1
Parked CAR(CAR-1) at L0-C1 ticket T2
Parked TRUCK(TRK-1) at L0-L1 ticket T3
Parked CAR(CAR-2) at L1-C1 ticket T4
Parked TRUCK(TRK-2) at L1-L1 ticket T5
No spot for TRUCK(TRK-3)
Free spots: {SMALL=0, COMPACT=1, LARGE=0}
CAR-1 paid $5.00 after 95 min
TRK-1 paid $5.00 after 30 min
Retry TRK-3: parked at L0-L1
Free spots: {SMALL=0, COMPACT=2, LARGE=0}
Rejected: Unknown or used ticket: T2

Walkthrough of the key decisions:

  • The motorcycle takes the small spot and the first car the compact spot on level 0: best fit, lowest level first.
  • The third truck is rejected because both large spots are taken, even though a compact spot is free.
  • Fees: 95 minutes is 2 started hours × $2.50 = $5.00; a 30-minute truck stay is billed the 1-hour minimum of $5.00.
  • Reusing a ticket fails because unpark removes it from activeTickets atomically.

Concurrency Considerations

Multiple gates call park simultaneously. The danger is a check-then-act race: two threads both see spot L0-C1 as free and both take it.

How the design prevents it:

  • ParkingSpot.assign is synchronized and re-checks vehicle == null before setting it, an atomic compare-and-set per spot.
  • park treats the strategy's choice as a candidate. If assign fails because another gate won, it loops and asks for another spot.
  • Lock granularity is one spot, so gates rarely block each other. A single lot-wide lock would be simpler but serializes every entry.
  • activeTickets is a ConcurrentHashMap, and remove makes ticket redemption idempotent: a double-scanned ticket is rejected, not charged twice.
  • AtomicLong generates unique ticket IDs without locking.
Warning

Scanning every spot on every park is O(total spots). For large lots, keep per-level, per-size free lists (e.g. a ConcurrentLinkedQueue<ParkingSpot> per size) so finding a spot is O(1). Then polling the queue is the atomic claim, and release offers the spot back.

Other points to raise:

  • Availability counters may be momentarily stale for the display board; that is acceptable (eventual consistency) as long as assignment itself is atomic.
  • In a distributed setup (several gate servers, shared database), the per-spot lock becomes a conditional update: UPDATE spot SET vehicle_id = ? WHERE id = ? AND vehicle_id IS NULL, checking that one row changed.

Extensions

Requirement Change
Electric vehicles with chargers Add ElectricCar and an EV_CHARGING spot type or a hasCharger flag; an EvFirstStrategy prefers charger spots
Different pricing (weekend, flat daily cap, membership) New PricingStrategy implementations; compose with a decorator for discounts
Nearest spot to entry gate SpotAssignmentStrategy that takes the gate and uses distances or a priority queue per gate
Display boards per level Observer: Level publishes spot changes; boards subscribe
Reservations Reservation entity holding a spot for a time window; assignment skips reserved spots
Payments PaymentProcessor interface (card, cash, wallet) called after fee calculation
Large vehicles spanning multiple spots Assign a contiguous run of spots atomically (lock spots in ID order to avoid deadlock)

Interview Tips

  • Spend 3–5 minutes on requirements and state assumptions; then list entities before writing code.
  • Draw the class diagram first; interviewers grade relationships (composition vs association) as much as code.
  • Use enums for fixed categories, an abstract class for vehicles, and interfaces for pluggable policy.
  • Justify every pattern. "Strategy because pricing changes often" beats naming patterns for their own sake.
  • Bring up concurrency yourself: the double-assignment race and how per-spot locking fixes it.
  • Keep money in integer cents (or BigDecimal) and inject time, which shows production instincts.
  • Close with extensions to show the design is open for change without modifying core classes.
My notes saved in this browser