← Back to Design & Development
Low-Level Design

Parking Lot

A multi-floor smart parking lot — built up from a paper-ticket booth, with Strategy + State + Observer + Singleton, full Java code & the gaps in Grokking's classic design fixed.

Step 1

Clarify Requirements

Imagine you're at a busy mall on a Saturday afternoon. Cars are streaming in, motorcycles are weaving through, the security guard is waving people up to floor 2 because floor 1 is full, and an electric SUV is hunting for a charger. Every one of those flows is something the parking lot has to handle. Before we draw a single class, we lock down what's in scope and what isn't — that's how an interviewer hears that you've thought about the problem, not just the code.

✅ Functional Requirements

  • Multi-floor lot with multiple entry & exit gates per floor
  • Issue a ticket at entry (license plate + entry-time + assigned spot)
  • Park different vehicles: Motorcycle, Car, Van, Truck, Electric Car
  • Different spot types: Motorcycle, Compact, Large, Electric, Handicapped
  • Smart spot allocation — pick the best spot for the vehicle (nearest, type-fit)
  • Calculate fee at exit — flat, tiered, and peak-hour rates supported
  • Pay by Cash or Card; failed payment must be safely retried
  • Display board on each floor shows free spots per type — updates live
  • Admin can add/remove floors, spots, gates, attendants; tune pricing
  • Lost ticket flow — flat penalty fee, manual lookup by license plate

🎯 Non-Functional Requirements

  • Concurrency: 50+ entry/exit gates issuing tickets simultaneously must never double-allocate a spot
  • Latency: Spot allocation < 100 ms — drivers don't wait at the gate
  • Availability: A single floor's gate failure must not bring the lot down
  • Auditability: Every ticket, payment, and spot transition is logged

🚫 Out of Scope

  • Online reservation / pre-booking (extension point only)
  • License-plate OCR / automatic gate hardware drivers
  • Valet service & multi-tenant operators
  • Tax computation, refund disputes, accounting integrations
Interviewer trick: They love when you mention "lost ticket" and "peak-hour pricing" up-front. Both expose lifecycle & strategy thinking — which most candidates skip and only stumble into when prodded.
Step 2

Actors & Use Cases

Before nouns become classes, we ask: who is doing things in this system, and what do they do? Four humans interact with the lot directly, and one silent actor — the System itself — runs the display board, the allocator, and the clock. Drawing these as a flowchart (instead of just listing them) makes the boundary between human-driven and system-driven actions visible at a glance.

flowchart LR D([Driver / Customer]) A([Parking Attendant]) M([Admin / Manager]) S([System]) D --> E[Take Ticket at Entry] D --> P[Pay at Exit] D --> LT[Report Lost Ticket] A --> SC[Scan Ticket] A --> CP[Process Cash Payment] A --> LP[Lookup by License Plate] M --> AF[Add / Remove Floor] M --> ASP[Add / Remove Spot] M --> AG[Add / Remove Gate] M --> PR[Configure Pricing] S --> AL[Allocate Best Spot] S --> DB[Update Display Board] S --> CF[Calculate Fee] S --> NT[Notify on Full] style D fill:#e8743b,stroke:#e8743b,color:#fff style A fill:#4a90d9,stroke:#4a90d9,color:#fff style M fill:#9b72cf,stroke:#9b72cf,color:#fff style S fill:#38b265,stroke:#38b265,color:#fff

Driver

The person whose car is being parked. Triggers entry, pays at exit, may lose a ticket.

Attendant

Floor staff. Scans tickets, accepts cash, looks up vehicles by plate when tickets are lost.

Admin

Operations manager. Configures floors, spots, gates, pricing — everything that shapes capacity and revenue.

System

The silent actor. Allocates spots, refreshes display boards, calculates fees, and emits "lot full" alerts.

Step 3

Story — From a Paper-Ticket Booth to a Smart Lot

The fastest way to design a parking lot is to start with the dumbest version that could plausibly work, watch it break, and let each break suggest the next class. We do this in three passes: the naive design, the mental split that fixes it, and the production shape. Every box you'll see in the class diagram later was put there because something concrete broke without it.

Pass 1 — The naive design (and why it breaks)

You start with a single-floor lot, one gate, one attendant Raj at the booth with a stack of paper tickets and a clock. Cars come in, Raj scribbles the entry time on a ticket, hands it over. Cars leave, Raj reads the time, computes the fee on a calculator, accepts cash. Done. Code-wise this is one class with a list of spots and a HashMap of active tickets.

flowchart LR Driver --> Booth[Raj's Booth
paper tickets] Booth --> Spot[Open Spot] Booth --> DB[(Cashbox + Notebook)] style Booth fill:#e05252,stroke:#e05252,color:#fff

Then reality hits:

💥 Truck shows up

The next free spot is a compact slot. The truck doesn't fit. Raj walks the lot looking for a Large spot. Spots aren't fungible.

💥 Saturday rush

Five cars enter simultaneously through two gates. Raj and his colleague both write down spot 12 for two different cars. No concurrency control.

💥 Floor 2 fills up

Drivers waste 5 minutes circling floor 2 before realising it's full. No live capacity signal.

💥 EV needs charge

Compact spot fits the car, but there's no charging cable. Spots have features, not just sizes.

💥 Peak hour

Mall is packed at 7pm. Flat rate gives away the scarcest hour for the same price as 3am. Pricing is a strategy, not a constant.

💥 Lost ticket

Driver lost the paper. Raj has no way to look up entry time. Ticket lifecycle has more states than ACTIVE/PAID.

Pass 2 — The mental split

Every failure above maps to one of two concerns the booth was conflating:

🅿️ Inventory plane

What spots exist, where they are, what they fit, and whether they're free right now. This is hot, contended, must be thread-safe, and is the source of truth the display board reads from. ParkingLot, ParkingFloor, ParkingSpot live here.

🎟️ Transaction plane

What ticket was issued to whom, when, what they owe, and whether they paid. This is a per-vehicle workflow with a clear lifecycle (state machine). ParkingTicket, Payment, PricingStrategy live here.

That single split — inventory vs transaction — is the spine of the design. Anything in the inventory plane mutates a shared map under a lock; anything in the transaction plane is a per-ticket state machine that doesn't need to fight with anyone else for a lock.

Pass 3 — The production shape

Now we build the full architecture. Each numbered box below earns its place by fixing one of the failures from Pass 1. Read the badge color on each card to find the matching box in the diagram.

flowchart TB subgraph entry [" "] direction LR EG[① Entry Gate] end subgraph lot ["ParkingLot Singleton"] direction TB PL[② ParkingLot] SAS[③ SpotAllocationStrategy] PS[④ PricingStrategy] end subgraph floors ["Floors"] direction LR F1[⑤ ParkingFloor 1] F2[⑤ ParkingFloor 2] F3[⑤ ParkingFloor 3] end subgraph spots ["Spots per floor"] direction LR SP[⑥ ParkingSpot] DBd[⑦ DisplayBoard
Observer] end subgraph tx ["Transaction"] direction LR PT[⑧ ParkingTicket
State Machine] PMT[⑨ Payment] end subgraph exit [" "] direction LR XG[⑩ Exit Gate] end EG --> PL PL --> SAS PL --> PS PL --> F1 PL --> F2 PL --> F3 F1 --> SP F1 --> DBd SP -.notify.-> DBd EG --> PT XG --> PT PT --> PMT PMT --> PS style EG fill:#e8743b,stroke:#e8743b,color:#fff style PL fill:#4a90d9,stroke:#4a90d9,color:#fff style SAS fill:#9b72cf,stroke:#9b72cf,color:#fff style PS fill:#9b72cf,stroke:#9b72cf,color:#fff style F1 fill:#38b265,stroke:#38b265,color:#fff style F2 fill:#38b265,stroke:#38b265,color:#fff style F3 fill:#38b265,stroke:#38b265,color:#fff style SP fill:#3cbfbf,stroke:#3cbfbf,color:#fff style DBd fill:#d4a838,stroke:#d4a838,color:#000 style PT fill:#e05252,stroke:#e05252,color:#fff style PMT fill:#e05252,stroke:#e05252,color:#fff style XG fill:#e8743b,stroke:#e8743b,color:#fff

Component-by-component — what each numbered box does

Use the numbers in the diagram to find the matching card. Each card answers four questions: what is it, why does it exist, what would break without it, and what does it talk to.

Entry / Exit Gate

The physical gate where Raj used to sit, now a software service backing a hardware terminal. On entry it asks the lot to allocate a spot and prints a ticket. On exit it scans the ticket, asks PricingStrategy for the fee, takes payment, and triggers the spot release.

Solves: Without separate Entry & Exit Gate services, the lot has no API surface. They're the only place a Driver actually touches the system.

ParkingLot (Singleton)

The orchestrator. There's exactly one parking lot per facility — every gate, every floor, every active ticket roots up to it. Holds the map of floors, the registry of active tickets, and the shared allocation/pricing strategies.

Solves: Without a single root, two gates could each spin up their own "lot" and double-allocate. Singleton + a synchronized issueTicket method is what makes "50 gates, no double-booking" actually work.

SpotAllocationStrategy

An interface, not a method. NearestFirstStrategy grabs the closest spot to the entry gate; BestFitStrategy grabs the smallest spot the vehicle still fits in (compact car → compact, never large). Swappable at runtime.

Solves: The Grokking reference hardcodes a switch statement that always picks the first matching spot. The moment you want "EV cars prefer charging spots, fall back to compact" or "VIP gate gets nearest spots", a switch becomes a 6-clause mess. Strategy turns it into one line: strategy.allocate(vehicle).

PricingStrategy

Another interface. FlatRateStrategy is ₹50/hour. TieredRateStrategy is ₹40 first hour, ₹30 next two, ₹20 after. PeakHourStrategy applies a 1.5× multiplier between 6–9 PM. The lot doesn't care which is plugged in.

Solves: The reference design has one method calculateFee(hours) wired to a single rate table. Switching to peak pricing means rewriting that method everywhere it's called. Strategy isolates the change to one new class.

ParkingFloor

One physical floor. Owns its spots (segregated by type for O(1) lookup), has its own gates and display board. Knows how many spots of each type are free.

Solves: Without a floor abstraction, a 4-floor lot becomes a single flat list of 800 spots and the allocator has no notion of "skip floor 2, it's full". Floor is the unit of partition.

ParkingSpot

One slot on the floor. Has a type (Compact / Large / Motorcycle / Electric / Handicapped), a number, and exactly one vehicle (or none). The fits(vehicle) method encodes the size-compatibility rules so they live in one place.

Solves: Without a polymorphic fits-check, every allocator becomes a chain of if-vehicle-is-X-and-spot-is-Y conditions. Pushing the rule into the spot keeps allocation logic dumb.

DisplayBoard (Observer)

The "Floor 2 — 14 free, 3 EV" board you see when you drive in. Subscribes to spot events: when a spot becomes occupied or freed, the floor publishes an event, and every board on that floor refreshes its counts.

Solves: Without Observer, the gate code would have to remember to call board.refresh() after every assign/release — easy to forget, easy to call twice, and impossible to scale to multiple boards. Observer is "publish once, listen forever".

ParkingTicket (State Machine)

The receipt. Lives a real lifecycle: ACTIVE while the car is parked, PAID the moment payment clears, EXITED when the gate lifts, or LOST if the driver reports it gone. Each transition is gated — you can't pay a LOST ticket without going through the lost-ticket flow first.

Solves: Reference has an enum with the values but no transitions. State pattern means a misuse like "pay an already-paid ticket" throws instead of double-charging.

Payment

One payment attempt — amount, method (Cash / Card), result. Uses PricingStrategy to compute the amount, hands the result back to the ticket. On failure, the ticket stays in ACTIVE and the spot stays held.

Solves: Without a Payment object, fee calculation and spot release are tangled. If card processing fails halfway, you've already freed the spot and the next car drives over a still-parked car. Payment is the atomic boundary.

Exit Gate

Same shape as Entry, opposite direction. Validates the ticket is in PAID state, releases the spot back into the inventory, and lifts the barrier.

Solves: A separate exit path means we can have more exits than entries at peak (drainage is faster than fill), and the entry/exit code paths can evolve independently.

So in short — the parking lot is split into two planes. The inventory plane (Lot → Floor → Spot) is shared and contended; one synchronized method on the singleton serializes every spot allocation. The transaction plane (Ticket → Payment) is a per-vehicle state machine with no contention. The board listens to both; the strategies plug into either; the gates are the only API the outside world sees.
Step 4

Core Entities

Now we name every noun. For each one we say one sentence about its responsibility and list the 3–5 fields it absolutely needs. Methods come later in the class diagram — for now, just attributes.

EntityResponsibilityKey Fields
ParkingLotSingleton root — owns floors, gates, active ticketsString name, List<ParkingFloor> floors, Map<String,ParkingTicket> activeTickets, SpotAllocationStrategy allocator, PricingStrategy pricer
ParkingFloorOne physical floor with its own spots, gates, boardString floorId, Map<SpotType,List<ParkingSpot>> spots, DisplayBoard board
ParkingSpotOne slot — knows its type and current vehicleString spotId, SpotType type, Vehicle vehicle, boolean free
VehicleAbstract — the thing being parkedString licensePlate, VehicleType type
ParkingTicketReceipt for one parking session — has a state machineString ticketId, String licensePlate, String spotId, LocalDateTime entryTime, TicketStatus status, BigDecimal amount
GateAbstract entry/exit terminalString gateId, String floorId
EntryGate / ExitGateSubclasses — issue or close out a ticket(plus methods)
DisplayBoardObserver of spot events — shows live counts per typeString floorId, Map<SpotType,Integer> freeCounts
SpotAllocationStrategyInterface — picks a spot for a vehicle(no fields, behavior only)
PricingStrategyInterface — computes fee from entry/exit time(no fields, behavior only)
PaymentOne payment attemptString paymentId, BigDecimal amount, PaymentMethod method, PaymentStatus status
AccountAbstract user — Admin / AttendantString userId, String name, AccountStatus status
Step 5

ER / Schema Diagram

Even though the in-memory design doesn't strictly need a database, drawing the ER tells you the persistence shape — and in an interview it shows you separated identity from references. Read each line as "one X owns many Y" or "one X belongs to one Y".

erDiagram PARKING_LOT { string lot_id PK string name string address } PARKING_FLOOR { string floor_id PK string lot_id FK int floor_number } PARKING_SPOT { string spot_id PK string floor_id FK string type string spot_number boolean is_free } GATE { string gate_id PK string floor_id FK string kind } VEHICLE { string license_plate PK string type } PARKING_TICKET { string ticket_id PK string license_plate FK string spot_id FK string entry_gate_id FK datetime entry_time datetime exit_time string status decimal amount } PAYMENT { string payment_id PK string ticket_id FK decimal amount string method string status datetime paid_at } ACCOUNT { string user_id PK string role string status } PARKING_LOT ||--o{ PARKING_FLOOR : "has" PARKING_FLOOR ||--o{ PARKING_SPOT : "contains" PARKING_FLOOR ||--o{ GATE : "has" PARKING_SPOT ||--o| PARKING_TICKET : "currently holds" VEHICLE ||--o{ PARKING_TICKET : "issued for" PARKING_TICKET ||--o{ PAYMENT : "settled by"

Read the cardinality: one Lot has many Floors. One Floor has many Spots and many Gates. A Spot can hold zero or one Ticket at any moment (the ||--o|). A Vehicle is identified by its plate and can have many tickets across visits. A Ticket can have multiple Payment attempts (because the first one might fail).

Step 6

Class Diagram

Now we wire methods, inheritance, and relationships. Pay attention to which arrows are inheritance (<|--), which are composition (*--), and which are association (-->). The split between abstract Vehicle/ParkingSpot/Account and their concrete subclasses is what lets the lot work with "any vehicle" and "any spot" without caring about the concrete type.

classDiagram class ParkingLot { -String name -List~ParkingFloor~ floors -Map~String,ParkingTicket~ activeTickets -SpotAllocationStrategy allocator -PricingStrategy pricer -static ParkingLot instance +getInstance() ParkingLot +issueTicket(Vehicle) ParkingTicket +closeTicket(String,PaymentMethod) Payment +reportLostTicket(String) ParkingTicket -synchronized assignSpot(Vehicle) ParkingSpot } class ParkingFloor { -String floorId -Map~SpotType,List~ParkingSpot~~ spots -DisplayBoard board +findFreeSpot(Vehicle) ParkingSpot +releaseSpot(ParkingSpot) } class ParkingSpot { <> -String spotId -SpotType type -Vehicle vehicle -boolean free +fits(Vehicle) boolean +assign(Vehicle) +release() } class CompactSpot class LargeSpot class MotorcycleSpot class ElectricSpot class HandicappedSpot class Vehicle { <> -String licensePlate -VehicleType type } class Car class Motorcycle class Truck class Van class ElectricCar class ParkingTicket { -String ticketId -String licensePlate -String spotId -LocalDateTime entryTime -LocalDateTime exitTime -TicketStatus status -BigDecimal amount +markPaid(BigDecimal) +markLost() +markExited() } class SpotAllocationStrategy { <> +allocate(Vehicle, List~ParkingFloor~) ParkingSpot } class NearestFirstStrategy class BestFitStrategy class PricingStrategy { <> +price(LocalDateTime, LocalDateTime, VehicleType) BigDecimal } class FlatRateStrategy class TieredRateStrategy class PeakHourStrategy class Payment { -String paymentId -BigDecimal amount -PaymentMethod method -PaymentStatus status +process() boolean } class DisplayBoard { -String floorId -Map~SpotType,Integer~ freeCounts +onSpotChanged(SpotEvent) +render() String } class Gate { <> } class EntryGate class ExitGate class Account { <> } class Admin class ParkingAttendant ParkingSpot <|-- CompactSpot ParkingSpot <|-- LargeSpot ParkingSpot <|-- MotorcycleSpot ParkingSpot <|-- ElectricSpot ParkingSpot <|-- HandicappedSpot Vehicle <|-- Car Vehicle <|-- Motorcycle Vehicle <|-- Truck Vehicle <|-- Van Vehicle <|-- ElectricCar SpotAllocationStrategy <|.. NearestFirstStrategy SpotAllocationStrategy <|.. BestFitStrategy PricingStrategy <|.. FlatRateStrategy PricingStrategy <|.. TieredRateStrategy PricingStrategy <|.. PeakHourStrategy Gate <|-- EntryGate Gate <|-- ExitGate Account <|-- Admin Account <|-- ParkingAttendant ParkingLot "1" *-- "many" ParkingFloor ParkingFloor "1" *-- "many" ParkingSpot ParkingFloor "1" *-- "1" DisplayBoard ParkingLot "1" --> "1" SpotAllocationStrategy ParkingLot "1" --> "1" PricingStrategy ParkingTicket "1" --> "0..*" Payment ParkingSpot "0..1" --> "0..1" Vehicle ParkingTicket --> ParkingSpot
Step 7

Design Patterns Used

An interviewer will absolutely ask "where are the patterns?". Here are the six that earn their keep — pattern name, who plays it, and the single sentence that justifies it.

🔒 Singleton — ParkingLot

One physical lot, one in-memory root. Every gate, every floor, every active ticket reaches up to the same instance, so a synchronized issueTicket on it is enough to prevent double allocation across all gates.

🎯 Strategy — SpotAllocationStrategy & PricingStrategy

Two axes that vary independently and frequently. Allocation can be Nearest, BestFit, or VIP. Pricing can be Flat, Tiered, or Peak. Strategy makes both swappable at runtime without touching the lot.

🚦 State — ParkingTicket

The ticket has four states (ACTIVE → PAID → EXITED, plus LOST). Every transition is a method on the ticket; illegal transitions throw. Stops the "double-pay" and "exit without paying" bugs at the type level.

📡 Observer — DisplayBoard

The board subscribes to the floor's spot events. Every assign/release publishes once; every board on that floor refreshes. Add a new board (say, an LED screen near the entrance) without touching the gate code.

🏭 Factory — VehicleFactory & SpotFactory

Encapsulates the "given a type enum, give me the right subclass" mapping. Keeps construction logic out of the lot, and means adding a new vehicle type touches one factory and one enum, not the rest of the code.

🛡️ Template Method — Gate.process()

Both EntryGate and ExitGate follow the same skeleton: validate → call lot → emit event → log. The shared steps live in the abstract Gate; only the call to the lot differs. Cuts duplication and locks the order in.

Step 8

Sequence — Entry & Exit

Here's the happy path for the most important flow: a car drives in, parks for an hour, drives out. Watch where the synchronization lives — it's only on the spot allocation. Pricing and payment are per-ticket and need no lock.

Flow A — Sarah parks her Honda

sequenceDiagram actor Sarah as Sarah (Driver) participant EG as EntryGate participant PL as ParkingLot participant SAS as AllocationStrategy participant SP as ParkingSpot participant DB as DisplayBoard Sarah->>EG: pulls up, presses ticket button EG->>PL: issueTicket(vehicle) Note over PL: synchronized block starts PL->>SAS: allocate(vehicle, floors) SAS-->>PL: ParkingSpot S-2-12 PL->>SP: assign(vehicle) SP-->>DB: SpotChanged(occupied) Note over PL: synchronized block ends PL-->>EG: ParkingTicket{T-9X1, ACTIVE} EG-->>Sarah: prints ticket "Floor 2, Spot 12" Sarah->>SP: parks the car DB-->>DB: refreshes "Floor 2 — 13 free"

Flow B — Sarah leaves an hour later

sequenceDiagram actor Sarah as Sarah (Driver) participant XG as ExitGate participant PL as ParkingLot participant PT as ParkingTicket participant PS as PricingStrategy participant PMT as Payment participant SP as ParkingSpot participant DB as DisplayBoard Sarah->>XG: scans ticket T-9X1 XG->>PL: closeTicket("T-9X1", CARD) PL->>PT: lookup, status=ACTIVE PL->>PS: price(entry, now, CAR) PS-->>PL: ₹65 PL->>PMT: process(₹65, CARD) PMT-->>PL: SUCCESS PL->>PT: markPaid(₹65) PL->>PT: markExited() PL->>SP: release() SP-->>DB: SpotChanged(free) PL-->>XG: Receipt XG-->>Sarah: barrier opens
Concurrency note: only the assignSpot and release mutations need synchronization. Pricing is pure (entry + exit + type → fee), and ticket state transitions only mutate the ticket itself — no shared state. So 50 gates can compute fees in parallel; only spot allocation serializes.
Step 9

State Diagram — ParkingTicket

The ticket is the entity with the most lifecycle complexity. Without a state diagram you'll write code that lets you "pay an exited ticket" or "exit without paying". Each arrow is a method on ParkingTicket; the methods in any other state throw IllegalTicketStateException.

stateDiagram-v2 [*] --> ACTIVE : issueTicket() ACTIVE --> PAID : markPaid(amount) ACTIVE --> LOST : markLost() LOST --> PAID : markPaid(amount + penalty) PAID --> EXITED : markExited() EXITED --> [*] note right of LOST Driver reported ticket lost. Attendant looks up by license plate, flat penalty fee added to amount. end note note right of PAID Spot is still held until markExited() — prevents unpaid car driving out. end note

ACTIVE

Issued at entry. Spot is held. Awaiting payment.

LOST

Driver reported missing. Attendant validates by plate. Penalty added.

PAID

Payment cleared. Spot is still held until barrier opens.

EXITED

Barrier lifted. Spot released back to inventory. Terminal.

Step 10

Java Implementation

Java 17+. No frameworks. Showing the parts that demonstrate the patterns and the concurrency story; a real implementation would also have factories, persistence, and gates. Code order: enums → interfaces → entities → strategies → orchestrator → demo.

Enums.java — types & states
public enum VehicleType { MOTORCYCLE, CAR, VAN, TRUCK, ELECTRIC_CAR }

public enum SpotType { MOTORCYCLE, COMPACT, LARGE, ELECTRIC, HANDICAPPED }

public enum TicketStatus { ACTIVE, PAID, EXITED, LOST }

public enum PaymentMethod { CASH, CARD }

public enum PaymentStatus { PENDING, SUCCESS, FAILED }
Vehicle.java — abstract + subclasses
public abstract class Vehicle {
  private final String licensePlate;
  private final VehicleType type;
  protected Vehicle(String plate, VehicleType t) {
    this.licensePlate = plate; this.type = t;
  }
  public String getLicensePlate() { return licensePlate; }
  public VehicleType getType() { return type; }
}
public class Car         extends Vehicle { public Car(String p)         { super(p, VehicleType.CAR); } }
public class Motorcycle  extends Vehicle { public Motorcycle(String p)  { super(p, VehicleType.MOTORCYCLE); } }
public class Truck       extends Vehicle { public Truck(String p)       { super(p, VehicleType.TRUCK); } }
public class Van         extends Vehicle { public Van(String p)         { super(p, VehicleType.VAN); } }
public class ElectricCar extends Vehicle { public ElectricCar(String p) { super(p, VehicleType.ELECTRIC_CAR); } }
ParkingSpot.java — fits() encodes the rules
public abstract class ParkingSpot {
  private final String spotId;
  private final SpotType type;
  private Vehicle vehicle;
  private boolean free = true;

  protected ParkingSpot(String id, SpotType t) { this.spotId = id; this.type = t; }

  public abstract boolean fits(Vehicle v);

  public synchronized void assign(Vehicle v) {
    if (!free) throw new IllegalStateException("spot already taken");
    if (!fits(v)) throw new IllegalArgumentException("vehicle does not fit");
    this.vehicle = v; this.free = false;
  }
  public synchronized void release() { this.vehicle = null; this.free = true; }
  public boolean isFree() { return free; }
  public SpotType getType() { return type; }
  public String getId() { return spotId; }
}

public class CompactSpot extends ParkingSpot {
  public CompactSpot(String id) { super(id, SpotType.COMPACT); }
  public boolean fits(Vehicle v) {
    return v.getType() == VehicleType.CAR
        || v.getType() == VehicleType.ELECTRIC_CAR;
  }
}
public class LargeSpot extends ParkingSpot {
  public LargeSpot(String id) { super(id, SpotType.LARGE); }
  public boolean fits(Vehicle v) {
    return v.getType() != VehicleType.MOTORCYCLE; // fits everything except bikes
  }
}
public class MotorcycleSpot extends ParkingSpot {
  public MotorcycleSpot(String id) { super(id, SpotType.MOTORCYCLE); }
  public boolean fits(Vehicle v) { return v.getType() == VehicleType.MOTORCYCLE; }
}
public class ElectricSpot extends ParkingSpot {
  public ElectricSpot(String id) { super(id, SpotType.ELECTRIC); }
  public boolean fits(Vehicle v) { return v.getType() == VehicleType.ELECTRIC_CAR; }
}
SpotAllocationStrategy.java — Strategy pattern
public interface SpotAllocationStrategy {
  Optional<ParkingSpot> allocate(Vehicle v, List<ParkingFloor> floors);
}

public class NearestFirstStrategy implements SpotAllocationStrategy {
  public Optional<ParkingSpot> allocate(Vehicle v, List<ParkingFloor> floors) {
    return floors.stream()                  // floors are sorted by proximity to entry
      .map(f -> f.findFreeSpot(v))
      .filter(Optional::isPresent)
      .map(Optional::get)
      .findFirst();
  }
}

public class BestFitStrategy implements SpotAllocationStrategy {
  public Optional<ParkingSpot> allocate(Vehicle v, List<ParkingFloor> floors) {
    // EV → Electric → Compact → Large; Car → Compact → Large; Truck/Van → Large
    List<SpotType> preference = switch (v.getType()) {
      case ELECTRIC_CAR -> List.of(SpotType.ELECTRIC, SpotType.COMPACT, SpotType.LARGE);
      case CAR          -> List.of(SpotType.COMPACT, SpotType.LARGE);
      case MOTORCYCLE   -> List.of(SpotType.MOTORCYCLE);
      case VAN, TRUCK   -> List.of(SpotType.LARGE);
    };
    for (SpotType pref : preference)
      for (ParkingFloor f : floors) {
        Optional<ParkingSpot> s = f.findFreeSpotOfType(pref, v);
        if (s.isPresent()) return s;
      }
    return Optional.empty();
  }
}
PricingStrategy.java — Strategy pattern
public interface PricingStrategy {
  BigDecimal price(LocalDateTime entry, LocalDateTime exit, VehicleType type);
}

public class FlatRateStrategy implements PricingStrategy {
  private final BigDecimal ratePerHour;
  public FlatRateStrategy(BigDecimal r) { this.ratePerHour = r; }
  public BigDecimal price(LocalDateTime entry, LocalDateTime exit, VehicleType t) {
    long hours = Math.max(1, Duration.between(entry, exit).toHours());
    return ratePerHour.multiply(BigDecimal.valueOf(hours));
  }
}

public class TieredRateStrategy implements PricingStrategy {
  // 1st hour ₹40, hours 2-3 ₹30 each, 4+ ₹20 each
  public BigDecimal price(LocalDateTime entry, LocalDateTime exit, VehicleType t) {
    long h = Math.max(1, Duration.between(entry, exit).toHours());
    BigDecimal total = BigDecimal.valueOf(40);
    if (h > 1) total = total.add(BigDecimal.valueOf(Math.min(h - 1, 2) * 30));
    if (h > 3) total = total.add(BigDecimal.valueOf((h - 3) * 20));
    return total;
  }
}

public class PeakHourStrategy implements PricingStrategy {
  // Decorator-style: wraps another strategy, applies 1.5x for any hour overlapping 18:00–21:00
  private final PricingStrategy inner;
  public PeakHourStrategy(PricingStrategy inner) { this.inner = inner; }
  public BigDecimal price(LocalDateTime entry, LocalDateTime exit, VehicleType t) {
    BigDecimal base = inner.price(entry, exit, t);
    boolean peakOverlap = entry.getHour() < 21 && exit.getHour() >= 18;
    return peakOverlap ? base.multiply(BigDecimal.valueOf(1.5)) : base;
  }
}
ParkingTicket.java — State pattern (lite)
public class ParkingTicket {
  private final String ticketId;
  private final String licensePlate;
  private final String spotId;
  private final LocalDateTime entryTime;
  private LocalDateTime exitTime;
  private TicketStatus status = TicketStatus.ACTIVE;
  private BigDecimal amount = BigDecimal.ZERO;

  public ParkingTicket(String plate, String spotId) {
    this.ticketId = UUID.randomUUID().toString();
    this.licensePlate = plate;
    this.spotId = spotId;
    this.entryTime = LocalDateTime.now();
  }

  public synchronized void markPaid(BigDecimal amt) {
    if (status != TicketStatus.ACTIVE && status != TicketStatus.LOST)
      throw new IllegalStateException("cannot pay ticket in state " + status);
    this.amount = amt;
    this.status = TicketStatus.PAID;
  }
  public synchronized void markLost() {
    if (status != TicketStatus.ACTIVE)
      throw new IllegalStateException("only ACTIVE tickets can be marked LOST");
    this.status = TicketStatus.LOST;
  }
  public synchronized void markExited() {
    if (status != TicketStatus.PAID)
      throw new IllegalStateException("must pay before exit, current=" + status);
    this.exitTime = LocalDateTime.now();
    this.status = TicketStatus.EXITED;
  }
  // getters omitted
}
DisplayBoard.java — Observer
public record SpotEvent(SpotType type, boolean nowFree) {}

public interface SpotListener { void onSpotChanged(SpotEvent e); }

public class DisplayBoard implements SpotListener {
  private final String floorId;
  private final Map<SpotType, AtomicInteger> freeCounts = new EnumMap<>(SpotType.class);
  public DisplayBoard(String floorId, Map<SpotType, Integer> initial) {
    this.floorId = floorId;
    initial.forEach((k, v) -> freeCounts.put(k, new AtomicInteger(v)));
  }
  public void onSpotChanged(SpotEvent e) {
    freeCounts.get(e.type()).addAndGet(e.nowFree() ? 1 : -1);
  }
  public String render() {
    StringBuilder sb = new StringBuilder("Floor ").append(floorId).append(" — ");
    freeCounts.forEach((k, v) -> sb.append(k).append(":").append(v.get()).append(" "));
    return sb.toString();
  }
}
ParkingLot.java — the orchestrator
public class ParkingLot {
  private static volatile ParkingLot instance;
  public static ParkingLot getInstance() {
    if (instance == null) synchronized (ParkingLot.class) {
      if (instance == null) instance = new ParkingLot();
    }
    return instance;
  }

  private final List<ParkingFloor> floors = new ArrayList<>();
  private final Map<String, ParkingTicket> activeTickets = new ConcurrentHashMap<>();
  private final Map<String, String> plateIndex = new ConcurrentHashMap<>(); // for lost-ticket lookup
  private SpotAllocationStrategy allocator = new BestFitStrategy();
  private PricingStrategy pricer = new PeakHourStrategy(new TieredRateStrategy());
  private final BigDecimal lostPenalty = new BigDecimal("200");

  public synchronized ParkingTicket issueTicket(Vehicle v) {
    ParkingSpot spot = allocator.allocate(v, floors)
      .orElseThrow(() -> new IllegalStateException("lot full for " + v.getType()));
    spot.assign(v);
    ParkingTicket t = new ParkingTicket(v.getLicensePlate(), spot.getId());
    activeTickets.put(t.getTicketId(), t);
    plateIndex.put(v.getLicensePlate(), t.getTicketId());
    notifyFloorOf(spot, false); // emits SpotEvent → DisplayBoard
    return t;
  }

  public Payment closeTicket(String ticketId, PaymentMethod method) {
    ParkingTicket t = Optional.ofNullable(activeTickets.get(ticketId))
      .orElseThrow(() -> new NoSuchElementException("unknown ticket"));
    BigDecimal fee = pricer.price(t.getEntryTime(), LocalDateTime.now(), lookupVehicleType(t));
    if (t.getStatus() == TicketStatus.LOST) fee = fee.add(lostPenalty);
    Payment p = new Payment(fee, method);
    if (!p.process()) throw new PaymentFailedException(p);
    t.markPaid(fee);
    t.markExited();
    releaseSpot(t.getSpotId());
    activeTickets.remove(ticketId);
    plateIndex.remove(t.getLicensePlate());
    return p;
  }

  public ParkingTicket reportLostTicket(String licensePlate) {
    String ticketId = Optional.ofNullable(plateIndex.get(licensePlate))
      .orElseThrow(() -> new NoSuchElementException("no active ticket for plate"));
    ParkingTicket t = activeTickets.get(ticketId);
    t.markLost();
    return t;
  }

  // admin hooks for hot-swapping strategies — Open/Closed in action
  public void setAllocator(SpotAllocationStrategy s) { this.allocator = s; }
  public void setPricer(PricingStrategy p)        { this.pricer = p; }
  // notifyFloorOf, releaseSpot, lookupVehicleType helpers omitted
}
Demo.java — exercise the happy + lost-ticket paths
public class Demo {
  public static void main(String[] args) {
    ParkingLot lot = ParkingLot.getInstance();
    // admin pre-seeds floors & spots elsewhere…

    Vehicle sarahCar = new Car("DL-3C-AA-1234");
    ParkingTicket t1 = lot.issueTicket(sarahCar);
    System.out.println("Sarah parked at " + t1.getSpotId());

    // Sarah loses her ticket
    ParkingTicket recovered = lot.reportLostTicket("DL-3C-AA-1234");
    System.out.println("Recovered ticket " + recovered.getTicketId() + ", state=" + recovered.getStatus());

    // pays at exit (penalty included)
    Payment p = lot.closeTicket(recovered.getTicketId(), PaymentMethod.CARD);
    System.out.println("Sarah paid ₹" + p.getAmount());
  }
}
Step 11

Intuition — How to arrive at this design from scratch

If an interviewer asks you to solve this cold, this is the order to think it. You won't reach for Strategy and Observer because you remembered them — you'll reach for them because the problem keeps pushing you there.

1️⃣ Start from the action, not the diagram

What does a driver do? Take a ticket, park, pay, leave. That's your primary flow. Everything else exists to support it.

2️⃣ Find nouns & verbs

Nouns: lot, floor, spot, vehicle, ticket, gate, board, payment. Verbs: take, allocate, fit, free, calculate, pay, exit. Nouns become classes; verbs become methods.

3️⃣ Ask "who owns what?"

Lot owns floors. Floors own spots. Spots hold a vehicle (but don't own it — vehicles outlive the parking session). Tickets reference spots; they don't contain them.

4️⃣ Find what changes state

Spots flip free↔occupied. Tickets cycle ACTIVE→PAID→EXITED with a LOST detour. Both deserve explicit state tracking; the ticket gets a full state machine because its transitions matter for correctness.

5️⃣ Spot repeated behavior

All vehicles have a plate and type. All spots have an id and a fits-check. Pull both up to abstract base classes.

6️⃣ Find variation points

Allocation strategy varies (nearest, best-fit, VIP). Pricing varies (flat, tiered, peak). Both go behind interfaces — that's Strategy.

7️⃣ Check concurrency

Spot allocation is shared mutable state with high contention. Synchronized method on the singleton. Pricing and ticket state are per-ticket — no lock needed.

8️⃣ Happy path first

Code "park, pay, leave" end-to-end. Then add lost-ticket. Then add EV charging. Don't design for hypotheticals before the basic flow runs.

Step 12

Improvements over the Grokking Reference Design

The "Grokking the Object Oriented Design Interview" design is the canonical answer most candidates parrot. It works, but a senior interviewer will probe its seams. Here's what we changed and why each change is interview-defensible.

AreaGrokking designThis designWhy it matters
Spot allocation switch on VehicleType inside ParkingLot, picks first matching SpotAllocationStrategy interface — NearestFirst, BestFit, swappable VIP gates, EV preference, off-peak rebalancing — all become new strategy classes, no edits to the lot
Pricing Single rate table with tier counters baked into a method PricingStrategy interface + PeakHourStrategy as a decorator wrapping any base strategy Peak hour, weekend, EV-discount all stack as decorators. Flat-rate test pricing is a one-line swap
Ticket lifecycle Enum with values ACTIVE/PAID/LOST, no transition guards Full state machine — markPaid/markLost/markExited throw on illegal transitions Stops the "double-pay" and "exit without paying" bugs at compile/runtime instead of in production
Lost ticket Status exists but no flow defined plateIndex for O(1) lookup, reportLostTicket(plate), lostPenalty added at exit This is the most-asked follow-up — having a real implementation differentiates senior from junior
Display board Single free spot per type, manually refreshed by the lot after each assign Observer pattern — board listens to SpotEvent stream, atomic counters per type Adding a second board (LED at the gate) is zero-edit; missing a refresh call is impossible
Payment failure Not handled — fee calc & spot release are tangled Payment is a separate object; ticket only flips to PAID after process() returns true Card decline doesn't free a still-occupied spot. Driver retries; spot stays held
fits() rule Lives in ParkingLot.isFull as a tangle of if-vehicle-and-spot conditions Polymorphic — each spot subclass implements its own fits(Vehicle) Adding a "TruckOnly" spot or a "BikeOrCar" combo spot is one new class. Allocation logic stays untouched
Concurrency Singleton with one synchronized method, no atomic counters Synchronized only on allocation; ConcurrentHashMap for tickets & plate index; AtomicInteger in board Reads of "free counts" and "is plate parked" don't block writers — much higher gate throughput
Interview tip: When the interviewer says "what would you change?", these are gold. Pick two or three; explaining why the original choice failed is more impressive than the change itself.
Step 13

Extension Points (Open / Closed)

Three things this design can absorb without editing the existing classes — which is exactly what Open/Closed promises.

🅿️ Reservations

Add a ReservationService that pre-allocates spots and issues a ticket in RESERVED state. Add one new state and one new transition (RESERVED → ACTIVE on arrival). No edits to existing classes.

⚡ EV charging billing

Wrap the current PricingStrategy with a ChargingDecoratorStrategy that adds ₹X per kWh consumed (read from the spot's charger). Stacks on top of peak-hour without conflict.

👑 VIP gates

Add a VipNearestStrategy that always picks floor 1 spots. Wire it to the VIP entry gate only. The default gates keep using BestFit.

Step 14

Trade-offs & Talking Points

Every choice in the design beats one alternative — and brings its own cost. These are the trade-offs an interviewer will probe.

DecisionAlternativeWhy this choice
Singleton ParkingLotStatic utility class / DI containerOne physical lot = one in-memory root; static utilities can't be mocked easily in tests; full DI is overkill for a single-process system. Singleton with volatile + double-checked locking is the smallest correct option.
Synchronized method on issueTicketPer-floor lock, optimistic CAS on each spotPer-floor would be faster but allows global "lot is full" races. CAS works but complicates the allocator. synchronized on one method is fast enough for <500 gates and far simpler to reason about.
Strategy for allocationIf/else inside ParkingLotSix conditions today, twelve tomorrow. Strategy pays for itself the second you need to A/B test allocation policies in different gates.
fits() on spot, not allocatorCentralized compatibility tableA table is fine until you add a "BikeOrCar" combo spot whose rules don't fit a 2D matrix. Polymorphism scales; tables don't.
State machine on ticketPlain status enumAn enum says "this is valid", not "this transition is valid". Methods that throw on illegal transitions are the cheapest correctness test you'll ever write.
Observer for boardLot calls board.refresh() after each opEasy to forget; impossible to scale to multiple boards. Observer is "publish once, listen forever".
BigDecimal for moneydouble / floatFloating point loses pennies on multiplication. Always BigDecimal for currency. Always.
plateIndex for lost ticketsLinear scan of activeTicketsO(n) at every lost-ticket report; an attendant looking up at 6pm rush could wait seconds. The index is one extra map and lookup is O(1).
Step 15

Interview Q & A

The follow-ups an interviewer almost always asks after the main design. Practice saying these out loud — they're the difference between "candidate knows the canonical answer" and "candidate has actually thought about this".

How do you guarantee two cars at different gates don't get the same spot?
Spot allocation is the only place we mutate shared state. ParkingLot.issueTicket is synchronized, so only one thread is inside the allocator + assign sequence at a time. Inside, the allocator returns a free spot, we immediately call spot.assign(vehicle) which double-checks !free and flips the flag — all under the lock. Other gates wait. Throughput is fine for <500 gates; if we needed more we'd partition by floor and only lock the chosen floor.
A driver loses their ticket. What happens?
The attendant calls reportLostTicket(licensePlate). We keep a plateIndex map (plate → ticketId) populated at issue time, so this lookup is O(1). The ticket's state moves ACTIVE → LOST. At exit, closeTicket sees the LOST status, computes the regular fee with PricingStrategy, and adds a flat lostPenalty. Ticket then moves LOST → PAID → EXITED. We don't compute fee from "now minus best-guess entry" because the entry time was recorded at issue; only the receipt is missing.
What if the card payment fails at exit?
Payment.process() returns false, closeTicket throws PaymentFailedException, and the ticket stays in ACTIVE. The spot is still held, the barrier doesn't lift, the driver retries with another card or pays cash. The key invariant is: the spot is only released after the ticket is PAID and EXITED. No payment, no exit.
How do you support peak-hour pricing without rewriting everything?
PeakHourStrategy is a decorator wrapping another PricingStrategy. Its price() calls the inner strategy first, then applies a 1.5× multiplier if any hour of the parking session overlaps 18:00–21:00. To turn it on, the admin calls lot.setPricer(new PeakHourStrategy(new TieredRateStrategy())). To turn it off, swap back. Zero edits to ParkingLot or anything else.
An EV needs charging. How does allocation handle that?
BestFitStrategy has a preference list per vehicle type. For ELECTRIC_CAR it's [ELECTRIC, COMPACT, LARGE] — try electric spots first (charger access), fall back to compact, finally large. We iterate the preference, and for each preferred type scan all floors. The strategy stays clean; the rule lives in one place.
How would you scale to 100 lots in a city?
Now Singleton stops being right — we need one ParkingLot instance per facility, behind a LotRegistry keyed by lot ID. State moves out of memory into a database (Postgres for tickets/payments, Redis for spot availability since it's hot). Display boards become per-floor topics on a pub/sub bus. Allocation is now a service call. The class design stays the same; only the persistence layer and the location of state change.
Why state pattern and not just an enum check?
An enum check is "if (status == ACTIVE) { … }" scattered across the codebase. Forget one check, you've got a bug. State pattern (even the lite version where transitions are methods on the entity) centralizes the rules in one place. The compiler won't catch markExited() on an ACTIVE ticket — but the throw at runtime in tests will, and it's easy to find.
Why didn't you use the Builder pattern?
Builder pays off when constructors have >3 params and many are optional. Our entities are slim — Vehicle has 2, Spot has 2, Ticket gets 2 at construction. If the interviewer adds "vehicle has color, owner, registration date, insurance…" then we'd builder-ize Vehicle. Don't add abstractions before they earn their keep.
How does the display board stay consistent with the allocator?
The board is an Observer subscribed to SpotEvents emitted by the floor whenever a spot's free flag flips. Counters on the board are AtomicIntegers, so concurrent onSpotChanged calls don't lose updates. The board is eventually consistent — there's a microsecond between the spot flipping and the counter updating — but for a "Floor 2 — 13 free" display that's fine. If we needed strict consistency we'd hold the same lock as issueTicket, at the cost of throughput.