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.
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.
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.
The person whose car is being parked. Triggers entry, pays at exit, may lose a ticket.
Floor staff. Scans tickets, accepts cash, looks up vehicles by plate when tickets are lost.
Operations manager. Configures floors, spots, gates, pricing — everything that shapes capacity and revenue.
The silent actor. Allocates spots, refreshes display boards, calculates fees, and emits "lot full" alerts.
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.
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.
Then reality hits:
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.
Five cars enter simultaneously through two gates. Raj and his colleague both write down spot 12 for two different cars. No concurrency control.
Drivers waste 5 minutes circling floor 2 before realising it's full. No live capacity signal.
Compact spot fits the car, but there's no charging cable. Spots have features, not just sizes.
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.
Driver lost the paper. Raj has no way to look up entry time. Ticket lifecycle has more states than ACTIVE/PAID.
Every failure above maps to one of two concerns the booth was conflating:
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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".
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.
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.
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.
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.
| Entity | Responsibility | Key Fields |
|---|---|---|
ParkingLot | Singleton root — owns floors, gates, active tickets | String name, List<ParkingFloor> floors, Map<String,ParkingTicket> activeTickets, SpotAllocationStrategy allocator, PricingStrategy pricer |
ParkingFloor | One physical floor with its own spots, gates, board | String floorId, Map<SpotType,List<ParkingSpot>> spots, DisplayBoard board |
ParkingSpot | One slot — knows its type and current vehicle | String spotId, SpotType type, Vehicle vehicle, boolean free |
Vehicle | Abstract — the thing being parked | String licensePlate, VehicleType type |
ParkingTicket | Receipt for one parking session — has a state machine | String ticketId, String licensePlate, String spotId, LocalDateTime entryTime, TicketStatus status, BigDecimal amount |
Gate | Abstract entry/exit terminal | String gateId, String floorId |
EntryGate / ExitGate | Subclasses — issue or close out a ticket | (plus methods) |
DisplayBoard | Observer of spot events — shows live counts per type | String floorId, Map<SpotType,Integer> freeCounts |
SpotAllocationStrategy | Interface — picks a spot for a vehicle | (no fields, behavior only) |
PricingStrategy | Interface — computes fee from entry/exit time | (no fields, behavior only) |
Payment | One payment attempt | String paymentId, BigDecimal amount, PaymentMethod method, PaymentStatus status |
Account | Abstract user — Admin / Attendant | String userId, String name, AccountStatus status |
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".
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).
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.
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.
ParkingLotOne 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.
SpotAllocationStrategy & PricingStrategyTwo 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.
ParkingTicketThe 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.
DisplayBoardThe 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.
VehicleFactory & SpotFactoryEncapsulates 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.
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.
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.
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.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.
Issued at entry. Spot is held. Awaiting payment.
Driver reported missing. Attendant validates by plate. Penalty added.
Payment cleared. Spot is still held until barrier opens.
Barrier lifted. Spot released back to inventory. Terminal.
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.
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 }
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); } }
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; } }
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(); } }
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; } }
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 }
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(); } }
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 }
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()); } }
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.
What does a driver do? Take a ticket, park, pay, leave. That's your primary flow. Everything else exists to support it.
Nouns: lot, floor, spot, vehicle, ticket, gate, board, payment. Verbs: take, allocate, fit, free, calculate, pay, exit. Nouns become classes; verbs become methods.
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.
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.
All vehicles have a plate and type. All spots have an id and a fits-check. Pull both up to abstract base classes.
Allocation strategy varies (nearest, best-fit, VIP). Pricing varies (flat, tiered, peak). Both go behind interfaces — that's Strategy.
Spot allocation is shared mutable state with high contention. Synchronized method on the singleton. Pricing and ticket state are per-ticket — no lock needed.
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.
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.
| Area | Grokking design | This design | Why 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 |
Three things this design can absorb without editing the existing classes — which is exactly what Open/Closed promises.
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.
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.
Add a VipNearestStrategy that always picks floor 1 spots. Wire it to the VIP entry gate only. The default gates keep using BestFit.
Every choice in the design beats one alternative — and brings its own cost. These are the trade-offs an interviewer will probe.
| Decision | Alternative | Why this choice |
|---|---|---|
| Singleton ParkingLot | Static utility class / DI container | One 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 issueTicket | Per-floor lock, optimistic CAS on each spot | Per-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 allocation | If/else inside ParkingLot | Six 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 allocator | Centralized compatibility table | A 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 ticket | Plain status enum | An 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 board | Lot calls board.refresh() after each op | Easy to forget; impossible to scale to multiple boards. Observer is "publish once, listen forever". |
| BigDecimal for money | double / float | Floating point loses pennies on multiplication. Always BigDecimal for currency. Always. |
| plateIndex for lost tickets | Linear scan of activeTickets | O(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). |
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".
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.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.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.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.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.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.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.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.