Design a parking lot. Vehicles drive in, get a spot and a ticket, and pay when they leave. Write the code.
That is the whole prompt, and it hides a dozen decisions. Which vehicles come in? Do all spots have the same size? Which spot does a car get? What happens when the lot is full, or when two cars reach for the last spot at the same moment?
A parking lot looks easy, so the interview is not about the parking. It tests whether you can ask the right questions, split the problem into a few clear classes, and keep the data correct when many gates work at once. We start with the questions.
The question is one line. Before drawing anything, ask the questions whose answers change the design, and write each answer down. Say why you are asking: it shows the interviewer how you think.
Which spot sizes are there, and which spot does a vehicle get?
Small, medium and large. A vehicle fits a spot of its size or bigger, and gets the smallest size that fits, then the lowest number, which is the one nearest the gate. If nothing fits, turn the vehicle away with no ticket.three sizes in a fixed order. Best fit, so large spots stay free for trucks. An ordered index of free spots, not a scan, so parking and leaving cost about O(log n). A full lot is a normal outcome, not an error
How does a driver prove they parked, and what if a ticket is reused?
A ticket with an id. A plate may be inside only once. An unknown or already used ticket is rejected, and a repeated exit must do no harm.a map of open tickets and a set of plates inside. A used ticket leaves the map at once, so it can never free someone else’s spot
How is the fee calculated?
Per started hour, with a rate by spot size, and rules that stack: rush hour, weekends and so on. We only return the fee on exit. Taking the payment is not part of this.pricing is a list of rules, not an if
How big and how busy is it, and how many servers share the lot?
10,000 spots, 10 gates, and a peak of about 20 entries and exits per second. Several servers share one lot, and open tickets must survive a restart.the load is small. The difficulty is correctness under concurrency: never give one spot to two vehicles. A process lock is not enough, so the database has to arbitrate
Out of scope (say it aloud): taking the payment, reservations, EV charging, number plate cameras and the screens. Each is a natural follow-up.
Interviewer: “Just assume it’s a normal parking lot.”
You: “Understood. I’ll write down the assumptions I’m making so you can correct them: three spot sizes, best fit, a ticket on entry, a fee on exit, and several gates at once.” You have still gathered what you need, and you did it in thirty seconds.
Before we write any class, look at what happens to one car. The picture shows the steps in plain words. After it, we go through the steps again and name the thing each one needs.
A car comes in
The car leaves
Now read the same story as a list. Each time something has to remember a fact or make a decision, we have found a class.
We put these classes into three groups, because each group has a different job. Entities hold facts and do not change once created: Vehicle, Spot, Ticket. Behaviours are rules that may change later: the pricing rules. Services do the work and join everything together: SpotIndex, Pricing, ParkingLot. Keeping the groups apart is what keeps every class small.
| Class | Group | What it is responsible for |
|---|---|---|
Vehicle | Entity | A plate and a size. It only carries information into the lot. |
Spot | Entity | An id, a floor and a size. None of these change once the spot exists. |
Ticket | Entity | Remembers which plate is in which spot, and since when. |
PricingRule, PerHour, PeakSurcharge | Behaviour | One pricing rule each. A rule gets the fee so far and returns the new fee. |
Pricing | Service | Runs the rules one after another and returns the final fee. |
SpotIndex | Service | Keeps the list of free spots and hands out the best one. |
ParkingLot | Service (the front door) | The one class the gates talk to. It takes the lock, asks the others to do the work, and keeps the open tickets. |
The two pictures below show the same classes. Figure 2 shows who asks whom for help. Figure 3 shows what a ticket holds.
ParkingLot is a thin front door: search lives in SpotIndex and money in Pricing. Orange cards are the pricing behaviour, an interface and the rules that implement it.Vehicle is plain input: a plate and a size. There are no Car or Truck classes.
Interviewer: “Why not Car, Motorcycle and Truck classes?”
You: “They would have the same fields and the same methods and differ only in size, so a class tree adds code and no behaviour. One Vehicle with a size does the job, and I would add a subclass only when a type needs its own rule, for example a truck that takes two spots.”
Classes are the nouns. Now the verbs: what can be asked of each class. A good test is whether a caller could ask for it in one sentence.
ParkingLot.park(vehicle, now)Take the lock, reject a plate already inside, get the smallest free spot that fits, write a ticket and return it. If nothing fits, return none and turn the vehicle away.
called by an entry gateParkingLot.unpark(ticket_id, now)Spend the ticket once, give the spot back to the index, and return the fee. An unknown or used ticket is rejected.
called by an exit gateSpotIndex.take(vehicle)Remove and return the nearest free spot of the smallest size that fits. Taking and removing are one step.
called by the lot, under its lockSpotIndex.give(spot)Put a spot back among the free ones.
called by the lot, under its lockPricing.fee(ticket, exit_time)Run the rules in order. Each gets the fee so far and returns a new one.
called by the lotPricingRule.apply(ticket, exit_time, fee)One rule: per hour, a peak surcharge, a weekend discount. Reads the ticket and returns a number.
called by PricingInside park, everything between taking the lock and releasing it is the critical section: the one stretch of code that only one gate may run at a time. The concurrency deep dive below shows why.
The design in one breath. A ParkingLot facade owns the lock and the open tickets. To park, it asks the SpotIndex for the smallest free spot that fits and writes a Ticket. To leave, the ticket is spent once, the spot goes back to the index, and Pricing runs its rules in order to produce the fee.
We build from the bottom up, so every block uses only what is already written. Each block shows the code, then explains the choices behind it: why it is shaped this way and what the alternative would cost.
from __future__ import annotations
import heapq
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import IntEnum
from itertools import count
from threading import Lock
from typing import Optional
class Size(IntEnum):
SMALL = 1 # the numbers are the fit order
MEDIUM = 2
LARGE = 3
@dataclass(frozen=True)
class Vehicle:
plate: str
size: Sizeimport java.util.*;
enum Size { SMALL, MEDIUM, LARGE } // declaration order is the fit order
record Vehicle(String plate, Size size) {}#include <array>
#include <iostream>
#include <memory>
#include <mutex>
#include <optional>
#include <queue>
#include <stdexcept>
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace std;
enum class Size { SMALL = 1, MEDIUM = 2, LARGE = 3 }; // the numbers are the fit order
struct Vehicle {
string plate;
Size size;
};SMALL < MEDIUM < LARGE means “a vehicle fits a spot if the spot’s size is at least the vehicle’s”. There is no lookup table to keep in sync.record in Java, plain fields in C++. A value that cannot change can be shared between threads with no locking, and size is data, not a type.@dataclass(frozen=True)
class Spot:
id: int
floor: int
size: Size
@dataclass(frozen=True)
class Ticket:
id: int
plate: str
spot: Spot
entry_time: int # seconds; passed in, never read from a clockrecord Spot(int id, int floor, Size size) {}
record Ticket(int id, String plate, Spot spot, long entryTime) {} // seconds; passed in, never read from a clockstruct Spot {
int id, floor;
Size size;
};
struct Ticket {
int id;
string plate;
const Spot* spot;
long entryTime; // seconds; passed in, never read from a clock
};Spot has no occupied flag. A spot is free exactly when it is in the index. One fact, stored once, cannot drift out of step with itself.Ticket holds the Spot, not just its id. Leaving needs no lookup, and pricing can read the spot’s size. entry_time is seconds. Nothing reads the clock, so a test can say “two hours later” without waiting. In production, pass the database clock rather than each server’s own.class SpotIndex:
"""Free spots only, one min-heap per size: best fit in O(log n)."""
def __init__(self, spots: list[Spot]) -> None:
self._free: dict[Size, list[tuple[int, Spot]]] = {size: [] for size in Size}
for spot in spots:
self.give(spot)
def take(self, vehicle: Vehicle) -> Optional[Spot]:
for size in Size: # SMALL, MEDIUM, LARGE: the smallest that fits wins
if size >= vehicle.size and self._free[size]:
return heapq.heappop(self._free[size])[1]
return None
def give(self, spot: Spot) -> None:
heapq.heappush(self._free[spot.size], (spot.id, spot))class SpotIndex {
// Free spots only, one min-heap per size: best fit in O(log n).
private final List<PriorityQueue<Spot>> free = new ArrayList<>();
SpotIndex(List<Spot> spots) {
for (int i = 0; i < Size.values().length; i++)
free.add(new PriorityQueue<>(Comparator.comparingInt(Spot::id)));
spots.forEach(this::give);
}
Spot take(Vehicle v) {
for (int size = v.size().ordinal(); size < free.size(); size++) { // the smallest that fits wins
Spot s = free.get(size).poll();
if (s != null) return s;
}
return null;
}
void give(Spot s) { free.get(s.size().ordinal()).add(s); }
}class SpotIndex {
// Free spots only, one min-heap per size: best fit in O(log n).
struct ById {
bool operator()(const Spot* a, const Spot* b) const { return a->id > b->id; }
};
using Heap = priority_queue<const Spot*, vector<const Spot*>, ById>;
array<Heap, 4> free; // indexed by Size (1..3)
public:
explicit SpotIndex(const vector<Spot>& spots) {
for (const Spot& s : spots) give(&s);
}
const Spot* take(const Vehicle& v) {
for (int size = int(v.size); size <= 3; size++) { // the smallest that fits wins
if (free[size].empty()) continue;
const Spot* s = free[size].top();
free[size].pop();
return s;
}
return nullptr;
}
void give(const Spot* s) { free[int(s->size)].push(s); }
};This is the one class where search speed is decided. It keeps only the free spots, in one min-heap per size, ordered by spot number.
take removes the spot as it returns it. There is no “peek, then mark taken” pair of steps with a gap between them. That gap is where a double booking would come from.ParkingLot. Two classes each taking their own lock is how deadlocks start.(id, spot): ids are unique, so two Spot objects are never compared.)The fee is where requirements change most often, so it is isolated behind one small interface. Each rule gets the ticket, the exit time and the fee so far, and returns a new fee. Pricing runs the rules in order.
class PricingRule(ABC):
@abstractmethod
def apply(self, ticket: Ticket, exit_time: int, fee: int) -> int:
"""Return the new fee, given the fee so far."""
class PerHour(PricingRule):
def __init__(self, rates: dict[Size, int]) -> None:
self._rates = dict(rates) # price per started hour, by spot size
def apply(self, ticket: Ticket, exit_time: int, fee: int) -> int:
hours = max(1, math.ceil((exit_time - ticket.entry_time) / 3600))
return hours * self._rates[ticket.spot.size]
class PeakSurcharge(PricingRule):
"""+50% when the vehicle came in during rush hour."""
PEAK_HOURS = (8, 9, 17, 18)
def apply(self, ticket: Ticket, exit_time: int, fee: int) -> int:
hour = ticket.entry_time // 3600 % 24
return fee * 3 // 2 if hour in self.PEAK_HOURS else fee
class Pricing:
def __init__(self, rules: list[PricingRule]) -> None:
self._rules = list(rules)
def fee(self, ticket: Ticket, exit_time: int) -> int:
fee = 0
for rule in self._rules: # each rule gets the fee so far
fee = rule.apply(ticket, exit_time, fee)
return feeinterface PricingRule {
/** Returns the new fee, given the fee so far. */
int apply(Ticket t, long exitTime, int fee);
}
class PerHour implements PricingRule {
private final int[] rates; // price per started hour, indexed by Size
PerHour(int[] rates) { this.rates = rates.clone(); }
public int apply(Ticket t, long exitTime, int fee) {
long hours = Math.max(1, (exitTime - t.entryTime() + 3599) / 3600);
return (int) hours * rates[t.spot().size().ordinal()];
}
}
class PeakSurcharge implements PricingRule {
// +50% when the vehicle came in during rush hour.
public int apply(Ticket t, long exitTime, int fee) {
long hour = t.entryTime() / 3600 % 24;
boolean peak = hour == 8 || hour == 9 || hour == 17 || hour == 18;
return peak ? fee * 3 / 2 : fee;
}
}
class Pricing {
private final List<PricingRule> rules;
Pricing(List<PricingRule> rules) { this.rules = List.copyOf(rules); }
int fee(Ticket t, long exitTime) {
int fee = 0;
for (PricingRule rule : rules) fee = rule.apply(t, exitTime, fee); // each rule gets the fee so far
return fee;
}
}struct PricingRule {
// Returns the new fee, given the fee so far.
virtual int apply(const Ticket& t, long exitTime, int fee) const = 0;
virtual ~PricingRule() = default;
};
struct PerHour : PricingRule {
array<int, 4> rates; // price per started hour, indexed by Size
explicit PerHour(array<int, 4> rates) : rates(rates) {}
int apply(const Ticket& t, long exitTime, int) const override {
long hours = max(1L, (exitTime - t.entryTime + 3599) / 3600);
return hours * rates[int(t.spot->size)];
}
};
struct PeakSurcharge : PricingRule {
// +50% when the vehicle came in during rush hour.
int apply(const Ticket& t, long, int fee) const override {
long hour = t.entryTime / 3600 % 24;
bool peak = hour == 8 || hour == 9 || hour == 17 || hour == 18;
return peak ? fee * 3 / 2 : fee;
}
};
class Pricing {
vector<unique_ptr<PricingRule>> rules;
public:
explicit Pricing(vector<unique_ptr<PricingRule>> rules) : rules(std::move(rules)) {}
int fee(const Ticket& t, long exitTime) const {
int fee = 0;
for (auto& rule : rules) fee = rule->apply(t, exitTime, fee); // each rule gets the fee so far
return fee;
}
};PricingRule is the strategy interface, PerHour and PeakSurcharge are interchangeable implementations, and Pricing is the context that uses them without knowing which they are.A weekend discount of 20%. This block is the whole change:
class WeekendDiscount(PricingRule):
"""-20% when the vehicle came in on a Saturday or Sunday."""
def apply(self, ticket: Ticket, exit_time: int, fee: int) -> int:
weekday = (ticket.entry_time // 86400 + 4) % 7 # 0 = Sunday
return fee * 4 // 5 if weekday in (0, 6) else feeclass WeekendDiscount implements PricingRule {
// -20% when the vehicle came in on a Saturday or Sunday.
public int apply(Ticket t, long exitTime, int fee) {
long weekday = (t.entryTime() / 86400 + 4) % 7; // 0 = Sunday
return weekday == 0 || weekday == 6 ? fee * 4 / 5 : fee;
}
}struct WeekendDiscount : PricingRule {
// -20% when the vehicle came in on a Saturday or Sunday.
int apply(const Ticket& t, long, int fee) const override {
long weekday = (t.entryTime / 86400 + 4) % 7; // 0 = Sunday
return weekday == 0 || weekday == 6 ? fee * 4 / 5 : fee;
}
};Append it to the list passed to Pricing and it is live. ParkingLot, Pricing and the existing rules are untouched. That is the open/closed principle, shown rather than stated. (Day 0 of Unix time was a Thursday, hence the + 4: it makes 0 = Sunday and 6 = Saturday.)
Interviewer: “Premium customers pay half price. How much code changes?”
You: “One new class implementing PricingRule, and one more entry in the list where the lot is created. The lot and the other rules stay as they are.”
This is the class the gates see. park follows the entry flow in Figure 1, with the lock wrapped around it.
class UnknownTicketError(Exception):
pass
class AlreadyParkedError(Exception):
pass
class ParkingLot:
def __init__(self, spots: list[Spot], pricing: Pricing) -> None:
self._index = SpotIndex(spots)
self._pricing = pricing
self._open: dict[int, Ticket] = {}
self._plates: set[str] = set()
self._ids = count(1)
self._lock = Lock()
def park(self, vehicle: Vehicle, now: int) -> Optional[Ticket]:
"""Return a ticket, or None when the lot is full."""
with self._lock: # find + take + ticket is one step
if vehicle.plate in self._plates:
raise AlreadyParkedError(vehicle.plate)
spot = self._index.take(vehicle)
if spot is None:
return None
ticket = Ticket(next(self._ids), vehicle.plate, spot, now)
self._open[ticket.id] = ticket
self._plates.add(ticket.plate)
return ticket
def unpark(self, ticket_id: int, now: int) -> int:
"""Return the fee. Raises UnknownTicketError for an unknown or used ticket."""
with self._lock:
ticket = self._open.pop(ticket_id, None)
if ticket is None:
raise UnknownTicketError(ticket_id)
self._plates.discard(ticket.plate)
self._index.give(ticket.spot)
return self._pricing.fee(ticket, now)class UnknownTicketException extends RuntimeException {
UnknownTicketException(int id) { super("unknown ticket " + id); }
}
class AlreadyParkedException extends RuntimeException {
AlreadyParkedException(String plate) { super("already parked: " + plate); }
}
class ParkingLot {
private final SpotIndex index;
private final Pricing pricing;
private final Map<Integer, Ticket> open = new HashMap<>();
private final Set<String> plates = new HashSet<>();
private int nextId = 1;
ParkingLot(List<Spot> spots, Pricing pricing) {
this.index = new SpotIndex(spots);
this.pricing = pricing;
}
/** Returns a ticket, or null when the lot is full. */
synchronized Ticket park(Vehicle v, long now) { // find + take + ticket is one step
if (plates.contains(v.plate())) throw new AlreadyParkedException(v.plate());
Spot spot = index.take(v);
if (spot == null) return null;
Ticket t = new Ticket(nextId++, v.plate(), spot, now);
open.put(t.id(), t);
plates.add(t.plate());
return t;
}
/** Returns the fee. Throws UnknownTicketException for an unknown or used ticket. */
synchronized int unpark(int ticketId, long now) {
Ticket t = open.remove(ticketId);
if (t == null) throw new UnknownTicketException(ticketId);
plates.remove(t.plate());
index.give(t.spot());
return pricing.fee(t, now);
}
}struct UnknownTicket : runtime_error {
explicit UnknownTicket(int id) : runtime_error("unknown ticket " + to_string(id)) {}
};
struct AlreadyParked : runtime_error {
explicit AlreadyParked(const string& plate) : runtime_error("already parked: " + plate) {}
};
class ParkingLot {
SpotIndex index;
Pricing pricing;
unordered_map<int, Ticket> open;
unordered_set<string> plates;
int nextId = 1;
mutex lock;
public:
ParkingLot(const vector<Spot>& spots, Pricing pricing)
: index(spots), pricing(std::move(pricing)) {}
// Returns a ticket, or nullopt when the lot is full.
optional<Ticket> park(const Vehicle& v, long now) {
lock_guard<mutex> guard(lock); // find + take + ticket is one step
if (plates.count(v.plate)) throw AlreadyParked(v.plate);
const Spot* spot = index.take(v);
if (!spot) return nullopt;
Ticket t{nextId++, v.plate, spot, now};
open[t.id] = t;
plates.insert(t.plate);
return t;
}
// Returns the fee. Throws UnknownTicket for an unknown or used ticket.
int unpark(int ticketId, long now) {
lock_guard<mutex> guard(lock);
auto it = open.find(ticketId);
if (it == open.end()) throw UnknownTicket(ticketId);
Ticket t = it->second;
open.erase(it);
plates.erase(t.plate);
index.give(t.spot);
return pricing.fee(t, now);
}
};unpark could move outside it if pricing became expensive.park returns none. An unknown ticket or a duplicate plate is a caller error, so it raises a named exception that a caller can catch by type.One test, three languages. It checks best fit with overflow into a larger spot and then “full”, 50 cars from 16 threads against 10 spots, the fees, the duplicate plate and the unknown ticket. The comments show what each line prints.
from concurrent.futures import ThreadPoolExecutor
rates = {Size.SMALL: 10, Size.MEDIUM: 20, Size.LARGE: 40}
pricing = Pricing([PerHour(rates), PeakSurcharge(), WeekendDiscount()])
# Best fit: a car takes the medium spot, the next car overflows into large, then full.
lot = ParkingLot([Spot(1, 1, Size.SMALL), Spot(2, 1, Size.MEDIUM), Spot(3, 2, Size.LARGE)], pricing)
first = lot.park(Vehicle("A", Size.MEDIUM), now=0)
second = lot.park(Vehicle("B", Size.MEDIUM), now=0)
third = lot.park(Vehicle("C", Size.LARGE), now=0)
print(first.spot.id, second.spot.id, third) # 2 3 None
# 50 cars, 10 spots, 16 threads: exactly 10 tickets, no spot twice.
lot = ParkingLot([Spot(i, 1, Size.MEDIUM) for i in range(1, 11)], pricing)
cars = [Vehicle(f"CAR-{i}", Size.MEDIUM) for i in range(50)]
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(lambda car: lot.park(car, now=0), cars))
got = [t for t in results if t]
print(len(got), len({t.spot.id for t in got})) # 10 10
print(lot.unpark(got[0].id, now=3600)) # 20: one hour, off-peak
late = lot.park(Vehicle("LATE", Size.MEDIUM), now=8 * 3600)
print(lot.unpark(late.id, now=8 * 3600 + 1800)) # 30: 1 h x 20, +50% rush hour
sat = 2 * 86400 # day 2 after the epoch is a Saturday
ticket = lot.park(Vehicle("WKND", Size.MEDIUM), now=sat)
try:
lot.park(Vehicle("WKND", Size.MEDIUM), now=sat)
except AlreadyParkedError:
print("already parked") # the same plate cannot enter twice
print(lot.unpark(ticket.id, now=sat + 3600)) # 16: 1 h x 20, -20% weekend
try:
lot.unpark(999, now=0)
except UnknownTicketError:
print("unknown ticket")import java.util.concurrent.*;
class Test {
public static void main(String[] args) throws Exception {
Pricing pricing = new Pricing(List.of(new PerHour(new int[]{10, 20, 40}), new PeakSurcharge(), new WeekendDiscount()));
// Best fit: a car takes the medium spot, the next car overflows into large, then full.
ParkingLot small = new ParkingLot(
List.of(new Spot(1, 1, Size.SMALL), new Spot(2, 1, Size.MEDIUM), new Spot(3, 2, Size.LARGE)), pricing);
Ticket first = small.park(new Vehicle("A", Size.MEDIUM), 0);
Ticket second = small.park(new Vehicle("B", Size.MEDIUM), 0);
Ticket third = small.park(new Vehicle("C", Size.LARGE), 0);
System.out.println(first.spot().id() + " " + second.spot().id() + " " + third); // 2 3 null
// 50 cars, 10 spots, 16 threads: exactly 10 tickets, no spot twice.
List<Spot> spots = new ArrayList<>();
for (int i = 1; i <= 10; i++) spots.add(new Spot(i, 1, Size.MEDIUM));
ParkingLot lot = new ParkingLot(spots, pricing);
List<Ticket> got = Collections.synchronizedList(new ArrayList<>());
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < 50; i++) {
Vehicle car = new Vehicle("CAR-" + i, Size.MEDIUM);
pool.submit(() -> {
Ticket t = lot.park(car, 0);
if (t != null) got.add(t);
});
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println(got.size() + " " + got.stream().map(t -> t.spot().id()).distinct().count()); // 10 10
System.out.println(lot.unpark(got.get(0).id(), 3600)); // 20: one hour, off-peak
Ticket late = lot.park(new Vehicle("LATE", Size.MEDIUM), 8 * 3600);
System.out.println(lot.unpark(late.id(), 8 * 3600 + 1800)); // 30: 1 h x 20, +50% rush hour
long sat = 2 * 86400; // day 2 after the epoch is a Saturday
Ticket ticket = lot.park(new Vehicle("WKND", Size.MEDIUM), sat);
try {
lot.park(new Vehicle("WKND", Size.MEDIUM), sat);
} catch (AlreadyParkedException e) {
System.out.println("already parked"); // the same plate cannot enter twice
}
System.out.println(lot.unpark(ticket.id(), sat + 3600)); // 16: 1 h x 20, -20% weekend
try {
lot.unpark(999, 0);
} catch (UnknownTicketException e) {
System.out.println("unknown ticket");
}
}
}#include <set>
#include <thread>
Pricing makePricing() {
vector<unique_ptr<PricingRule>> rules;
rules.push_back(make_unique<PerHour>(array<int, 4>{0, 10, 20, 40})); // small, medium, large
rules.push_back(make_unique<PeakSurcharge>());
rules.push_back(make_unique<WeekendDiscount>());
return Pricing(std::move(rules));
}
int main() {
// Best fit: a car takes the medium spot, the next car overflows into large, then full.
vector<Spot> three{{1, 1, Size::SMALL}, {2, 1, Size::MEDIUM}, {3, 2, Size::LARGE}};
ParkingLot small(three, makePricing());
Vehicle a{"A", Size::MEDIUM}, b{"B", Size::MEDIUM}, c{"C", Size::LARGE};
auto first = small.park(a, 0), second = small.park(b, 0), third = small.park(c, 0);
cout << first->spot->id << " " << second->spot->id << " " << (third ? "ticket" : "none") << "\n"; // 2 3 none
// 50 cars, 10 spots, 16 threads: exactly 10 tickets, no spot twice.
vector<Spot> spots;
for (int i = 1; i <= 10; i++) spots.push_back({i, 1, Size::MEDIUM});
ParkingLot lot(spots, makePricing());
vector<Vehicle> cars;
for (int i = 0; i < 50; i++) cars.push_back({"CAR-" + to_string(i), Size::MEDIUM});
vector<optional<Ticket>> results(50);
vector<thread> threads;
for (int i = 0; i < 50; i++)
threads.emplace_back([&, i] { results[i] = lot.park(cars[i], 0); });
for (thread& t : threads) t.join();
set<int> used;
int firstId = -1, n = 0;
for (auto& r : results)
if (r) { n++; used.insert(r->spot->id); if (firstId < 0) firstId = r->id; }
cout << n << " " << used.size() << "\n"; // 10 10
cout << lot.unpark(firstId, 3600) << "\n"; // 20: one hour, off-peak
Vehicle lateCar{"LATE", Size::MEDIUM};
auto late = lot.park(lateCar, 8 * 3600);
cout << lot.unpark(late->id, 8 * 3600 + 1800) << "\n"; // 30: 1 h x 20, +50% rush hour
long sat = 2 * 86400; // day 2 after the epoch is a Saturday
Vehicle wknd{"WKND", Size::MEDIUM};
auto ticket = lot.park(wknd, sat);
try {
lot.park(wknd, sat);
} catch (const AlreadyParked&) {
cout << "already parked\n"; // the same plate cannot enter twice
}
cout << lot.unpark(ticket->id, sat + 3600) << "\n"; // 16: 1 h x 20, -20% weekend
try {
lot.unpark(999, 0);
} catch (const UnknownTicket&) {
cout << "unknown ticket\n";
}
}“Find a free spot” and “mark it taken” are two steps. If two gates interleave them, both see the same spot as free. The first timeline is that bug. The second is the fix.
Without a lock: a bug
With a lock: correct
The fix is to make the check and the change one indivisible step. In this code that is the lock in ParkingLot: the gate that gets it first takes the spot, and the second gate then sees the next one.
How much to lock, and where, depends on where the state lives:
| Where the state lives | How to make “take a spot” atomic | Limit |
|---|---|---|
| One process | One lock around park and unpark (this code). | Correct and enough for this load. One lock serialises every gate. |
| One process, heavy load | A lock per size pool, so a motorcycle never waits behind a truck. | Taking a larger spot as overflow must lock two pools. Fix the lock order to avoid a deadlock. |
| Several servers | The database arbitrates: FOR UPDATE SKIP LOCKED, plus unique indexes as a safety net. | A process lock does not reach another machine. This is the data model, below. |
SpotIndex and Pricing take no locks of their own. The lock lives in one place, so there is one order of locking and nothing to deadlock on.
The in-memory code loses every ticket on a restart, and its lock does not reach a second server. In a real system the database holds the truth and the classes above become the logic in front of it. This is the same design, persisted.
PostgreSQL is the source of truth, because tickets are money and need transactions and constraints. Redis suits the free-spot counter on a gate display, where a few seconds of staleness is fine. It is a poor ledger.
hourly_rate is copied into the ticket at entry, so a price change never touches a car that is already parked.CREATE TABLE lot (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE spot (
id BIGSERIAL PRIMARY KEY,
lot_id BIGINT NOT NULL REFERENCES lot (id),
floor SMALLINT NOT NULL,
size SMALLINT NOT NULL CHECK (size BETWEEN 1 AND 3), -- 1 small, 2 medium, 3 large
status TEXT NOT NULL DEFAULT 'FREE' CHECK (status IN ('FREE', 'OCCUPIED'))
);
CREATE TABLE ticket (
id BIGSERIAL PRIMARY KEY,
spot_id BIGINT NOT NULL REFERENCES spot (id),
plate TEXT NOT NULL,
hourly_rate INT NOT NULL, -- rate copied at entry, so a price change cannot hit parked cars
entry_time TIMESTAMPTZ NOT NULL DEFAULT now(),
exit_time TIMESTAMPTZ,
fee INT,
status TEXT NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'CLOSED'))
);
CREATE TABLE rate_card (
lot_id BIGINT NOT NULL REFERENCES lot (id),
size SMALLINT NOT NULL,
hourly_rate INT NOT NULL,
PRIMARY KEY (lot_id, size)
);
-- Best fit is one index probe, because only free spots are in this index.
CREATE INDEX spot_free ON spot (lot_id, size, id) WHERE status = 'FREE';
-- The database refuses a double booking even if the application has a bug.
CREATE UNIQUE INDEX one_open_ticket_per_spot ON ticket (spot_id) WHERE status = 'OPEN';
CREATE UNIQUE INDEX one_open_ticket_per_plate ON ticket (plate) WHERE status = 'OPEN';
Two indexes carry the design. spot_free is a partial index: only free spots are in it, so best fit is one short index probe and the index stays small however many spots are taken. It is the database version of SpotIndex. one_open_ticket_per_spot makes a double booking impossible, and one_open_ticket_per_plate keeps a plate from being inside twice.
-- park($1 lot, $2 vehicle size, $3 plate): one statement, so it is atomic
WITH pick AS (
SELECT id, size FROM spot
WHERE lot_id = $1 AND status = 'FREE' AND size >= $2
ORDER BY size, id -- smallest size first = best fit, then lowest number
LIMIT 1
FOR UPDATE SKIP LOCKED -- row held by another gate? skip it, take the next one
), taken AS (
UPDATE spot SET status = 'OCCUPIED'
FROM pick WHERE spot.id = pick.id
RETURNING spot.id, spot.size
)
INSERT INTO ticket (spot_id, plate, hourly_rate)
SELECT t.id, $3, r.hourly_rate
FROM taken t JOIN rate_card r ON r.lot_id = $1 AND r.size = t.size
RETURNING id, spot_id; -- 0 rows = lot is full
ORDER BY size, id is best fit. FOR UPDATE SKIP LOCKED is the important part: if another gate has already locked the best row, this gate skips it and takes the next one instead of waiting, so concurrent gates take different spots in parallel. The update and the insert are one statement, so a crash cannot leave a spot marked taken with no ticket.
-- unpark($1 ticket id): one transaction
BEGIN;
-- 1. Read what the fee needs. No row = unknown ticket, or it was already used.
SELECT entry_time, hourly_rate, spot_id FROM ticket WHERE id = $1 AND status = 'OPEN';
-- 2. The application works out $2 (the fee) with its pricing rules, then closes the ticket.
-- "AND status = 'OPEN'" makes a repeated exit harmless: it updates 0 rows.
UPDATE ticket SET status = 'CLOSED', exit_time = now(), fee = $2
WHERE id = $1 AND status = 'OPEN'
RETURNING spot_id;
-- 3. Free the spot, so the index sees it again.
UPDATE spot SET status = 'FREE' WHERE id = $3;
COMMIT;
The AND status = 'OPEN' guard makes the exit idempotent. A retry or a ticket scanned twice updates zero rows the second time, so the spot is never freed twice. The free count for the gate display is SELECT size, count(*) FROM spot WHERE lot_id = $1 AND status = 'FREE' GROUP BY size, answered from spot_free alone.
These are the questions that usually come next. Open one to see an answer you could say out loud.
For 200 spots I would, because it is the simplest thing that works. At 10,000 spots with several gates, every entry would scan the whole lot while holding the lock. The heap keeps that work at O(log n) and still hands out the nearest spot.
The trade-off: the heap is a little more code, and it must be updated whenever a spot is taken or given back. That is why take and give are the only two ways in.
That is a check-then-act race: both see spot 7 free. I make the find-and-take one critical section. In one process that is a lock. Across servers it becomes a single database statement with FOR UPDATE SKIP LOCKED, and I add a unique index so the database refuses a double booking even if my code has a bug.
The trade-off: one lock serialises every gate, which is fine at 20 events a second. If it ever was not, I would use a lock per size pool and fix the lock order so overflow into a larger pool cannot deadlock.
| Scenario | What goes wrong | Fix |
|---|---|---|
| App dies between “spot taken” and “ticket written” | A spot is taken with no ticket, and nobody can free it. | One transaction or one statement. In memory, both steps sit inside the same lock. |
| Exit scanned twice, or retried | A spot someone else now holds gets freed. | Close the ticket with WHERE status = 'OPEN'. Zero rows means reject. |
| Prices change while cars are parked | A parked car is repriced. | Copy the rate into the ticket at entry. |
| App servers disagree on the time | Odd or negative durations. | Use the database now() for entry and exit. |
| Lost ticket | No way to find the spot. | Look up the open ticket by plate, and charge the maximum daily fee. |
The order matters more than the code. We asked questions until the lot was clear, turned the nouns into a handful of small classes, said what each one does, and only then wrote code, so every block was short because its shape was already decided. Then we looked at the two things that decide whether the design survives real use: two gates racing, and state that must outlive a process.
| Principle | Where it shows up |
|---|---|
| Single responsibility | SpotIndex searches, Pricing prices, ParkingLot coordinates and locks. Each can change for one reason only. |
| Open/closed | WeekendDiscount was added without editing Pricing, ParkingLot or any existing rule. |
| Liskov substitution | Any PricingRule can replace another: same input, same kind of output, no surprises. |
| Dependency inversion | Pricing depends on the PricingRule interface, not on PerHour. The lot depends on Pricing, not on any rule. |
| Composition over inheritance | One Vehicle with a size, and a fee built from a list of rules. There is no class tree to maintain. |
| Explicit failure | Expected outcomes (lot full) return normally. Caller errors (bad ticket, duplicate plate) raise a named exception. |
| Testability | Time is a parameter and the rules are pure, so the whole suite runs without sleeping or mocking. |
Strategy for pricing (one interface, interchangeable and stackable implementations) and Facade for ParkingLot (one entry point in front of the index, the tickets and pricing). Observer only if a gate display is asked for, and a cached counter is often simpler. Factory and Singleton are deliberately absent: there is one Vehicle class for a factory to choose, and a singleton hides a dependency and makes tests harder. Naming a pattern you do not need costs more than leaving it out.
Go back through your notes and tick them off: spot choice, tickets and rejection in park and unpark, no double booking with the lock and the unique indexes, fast search with SpotIndex, and new pricing without edits through the rules. Then name what you would do next: per-pool locks, a cached free count for the display, and reservations as a new spot state. Ending on “here is what I did not build, and why” is what makes a design sound finished.