A useful parking design answers one hard question: what prevents two arrivals from receiving the same spot? Ticket printing, gates, payments, and dashboards can distract from that invariant. Begin with allocation and release, then explain where other concerns would connect. The following implementation is intentionally small enough to run, inspect, and modify during an interview.
Fix the rules first
Assume one lot with individually identified spots. Vehicles and spots have sizes: small, medium, and large. A vehicle fits a spot of its own size or any larger size. Motorcycles can therefore use larger spaces, but the default allocation prefers the smallest compatible free spot. When size ties, choose the nearer spot; identifiers settle any remaining tie. This is an explicit local policy, not a universal parking regulation.
Each active vehicle has one ticket, and each active ticket owns one spot. License plates are trimmed and uppercased so obvious formatting differences do not bypass duplicate detection. This simplification assumes plates identify vehicles uniquely within this lot; a production system may need issuing jurisdiction too. Empty plates are rejected. Entry and exit times are integer minutes on a caller-provided monotonic timeline, not local wall-clock strings.
Our example charges ten integer currency units per started hour, with a one-hour minimum. An immediate departure therefore costs ten, sixty minutes costs ten, and sixty-one costs twenty. The ticket stores the computed fee on exit. Charging money is outside this model: a payment provider could fail, so a real system needs a separate decision about whether an unpaid vehicle may leave.
Keep data separate from policy
Spot holds identity, size, and distance from the gate. Ticket holds the plate, spot identity, vehicle size, entry minute, and optional exit and fee. Frozen records make issued tickets snapshots rather than writable handles into internal state. Python's dataclass documentation describes this behavior; frozen fields do not make the whole application tamper-proof.
Two small functions implement allocation strategies. The default preserves large spots when smaller ones suffice; the alternative chooses nearest distance first. Both receive only compatible free candidates. A pricing function is another injected policy, because changing a tariff should not change occupancy logic. Function injection avoids creating a hierarchy of nearly empty strategy classes. The functions have contracts that can still be described and tested.
The lot validates the chosen spot again before issuing a ticket. A buggy strategy must not bypass compatibility or select a space that was never offered. Strategies are expected to be quick, deterministic, and free of side effects. Calling a remote service while holding this lock would block all arrivals and departures; handle such work through a different orchestration boundary.
Make a transition atomic
The service derives occupied spots and active plates from open tickets. That avoids independently maintained counters drifting from ticket state. Entering means checking the plate, identifying compatible free spots, selecting a valid candidate, and inserting a ticket. The complete sequence happens under one lock. Exiting validates the ticket and time, computes the charge, and only then replaces the ticket with its closed snapshot.
Do not rely on individual dictionary operations or the interpreter to protect that sequence. Python's threading reference documents explicit locking. This lock coordinates callers of one instance; it is not a distributed lock or a database transaction. The concurrent test proves a conflict rule in this scope rather than claiming protection across servers.
Runnable implementation and tests
Save the full block as parking_lot.py; run python3 parking_lot.py on Python 3.9 or later. No packages or external services are required. Rejected requests raise ValueError, and the executable tests check ordinary transitions, boundaries, strategy behavior, and competing arrivals.
from dataclasses import dataclass, replace
from enum import IntEnum
from threading import Lock
from concurrent.futures import ThreadPoolExecutor
import unittest
class Size(IntEnum):
SMALL = 1
MEDIUM = 2
LARGE = 3
@dataclass(frozen=True)
class Spot:
id: str
size: Size
distance: int = 0
@dataclass(frozen=True)
class Ticket:
id: int
plate: str
spot_id: str
size: Size
entered: int
exited: int = None
fee: int = None
def smallest_first(spots):
return min(spots, key=lambda s: (s.size, s.distance, s.id))
def nearest_first(spots):
return min(spots, key=lambda s: (s.distance, s.size, s.id))
def hourly_fee(minutes):
return 10 * max(1, (minutes + 59) // 60)
class ParkingLot:
def __init__(self, spots, allocate=smallest_first, price=hourly_fee):
self._spots = {}
for spot in spots:
if (not spot.id or spot.id in self._spots or
spot.distance < 0 or not isinstance(spot.size, Size)):
raise ValueError("invalid spot")
self._spots[spot.id] = spot
self._allocate = allocate
self._price = price
self._tickets = {}
self._next = 1
self._lock = Lock()
def enter(self, plate, size, minute):
plate = plate.strip().upper()
if not plate or not isinstance(size, Size) or type(minute) is not int or minute < 0:
raise ValueError("invalid arrival")
with self._lock:
active = [t for t in self._tickets.values() if t.exited is None]
if any(t.plate == plate for t in active):
raise ValueError("vehicle already parked")
occupied = {t.spot_id for t in active}
candidates = tuple(s for s in self._spots.values()
if s.id not in occupied and s.size >= size)
if not candidates:
raise ValueError("no compatible free spot")
chosen = self._allocate(candidates)
if chosen not in candidates:
raise ValueError("strategy returned an invalid spot")
ticket = Ticket(self._next, plate, chosen.id, size, minute)
self._tickets[ticket.id] = ticket
self._next += 1
return ticket
def exit(self, ticket_id, minute):
with self._lock:
ticket = self._tickets.get(ticket_id)
if ticket is None or ticket.exited is not None:
raise ValueError("unknown or closed ticket")
if type(minute) is not int or minute < ticket.entered:
raise ValueError("invalid exit time")
fee = self._price(minute - ticket.entered)
if type(fee) is not int or fee < 0:
raise ValueError("invalid fee")
closed = replace(ticket, exited=minute, fee=fee)
self._tickets[ticket_id] = closed
return closed
class ParkingTests(unittest.TestCase):
def setUp(self):
self.spots = [Spot("S", Size.SMALL, 10), Spot("L", Size.LARGE, 1)]
self.lot = ParkingLot(self.spots)
def test_strategy_and_release(self):
ticket = self.lot.enter(" ab1 ", Size.SMALL, 100)
self.assertEqual((ticket.plate, ticket.spot_id), ("AB1", "S"))
self.assertEqual(self.lot.exit(ticket.id, 161).fee, 20)
self.assertEqual(self.lot.enter("AB1", Size.SMALL, 162).spot_id, "S")
nearest = ParkingLot(self.spots, allocate=nearest_first)
self.assertEqual(nearest.enter("X", Size.SMALL, 0).spot_id, "L")
def test_fee_boundaries_and_custom_policy(self):
self.assertEqual([hourly_fee(n) for n in [0, 1, 59, 60, 61]],
[10, 10, 10, 10, 20])
lot = ParkingLot(self.spots, price=lambda minutes: minutes * 2)
t = lot.enter("X", Size.SMALL, 0)
self.assertEqual(lot.exit(t.id, 3).fee, 6)
def test_conflicts_and_compatibility(self):
t = self.lot.enter("A", Size.LARGE, 0)
for plate, size in [(" a ", Size.SMALL), ("B", Size.LARGE)]:
with self.assertRaises(ValueError):
self.lot.enter(plate, size, 1)
self.assertEqual(self.lot.enter("C", Size.SMALL, 1).id, 2)
self.lot.exit(t.id, 2)
self.assertEqual(self.lot.enter("B", Size.LARGE, 3).spot_id, "L")
def test_invalid_exits_preserve_occupancy(self):
t = self.lot.enter("A", Size.LARGE, 10)
for key, minute in [(999, 11), (t.id, 9), (t.id, 10.5)]:
with self.assertRaises(ValueError):
self.lot.exit(key, minute)
with self.assertRaises(ValueError):
self.lot.enter("B", Size.LARGE, 11)
self.lot.exit(t.id, 11)
with self.assertRaises(ValueError):
self.lot.exit(t.id, 12)
def test_invalid_configuration_and_arrival(self):
for spots in [[self.spots[0], self.spots[0]],
[Spot("", Size.SMALL)], [Spot("N", Size.SMALL, -1)]]:
with self.assertRaises(ValueError):
ParkingLot(spots)
for plate, size, minute in [(" ", Size.SMALL, 0),
("A", Size.SMALL, -1), ("A", 99, 0)]:
with self.assertRaises(ValueError):
self.lot.enter(plate, size, minute)
def test_bad_policies_do_not_mutate(self):
lot = ParkingLot(self.spots, allocate=lambda spots: Spot("fake", Size.LARGE))
with self.assertRaises(ValueError):
lot.enter("A", Size.SMALL, 0)
lot = ParkingLot(self.spots, price=lambda minutes: -1)
t = lot.enter("A", Size.LARGE, 0)
with self.assertRaises(ValueError):
lot.exit(t.id, 1)
with self.assertRaises(ValueError):
lot.enter("B", Size.LARGE, 2)
def test_concurrent_arrivals(self):
lot = ParkingLot([Spot("only", Size.SMALL)])
def arrive(plate):
try:
return lot.enter(plate, Size.SMALL, 0)
except ValueError:
return None
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(arrive, ["A", "B"]))
self.assertEqual(sum(t is not None for t in results), 1)
if __name__ == "__main__":
unittest.main()
Explain the failure paths
A full lot rejects an arrival before consuming a ticket number. A large vehicle cannot take the remaining small spot. An already parked plate fails even if another compatible space exists. An invalid exit leaves the ticket active, and a second exit cannot release a spot now occupied by someone else. These examples matter more than naming a design pattern because they establish what callers can safely assume after failure.
A closed ticket is historical evidence. Reopening it would undermine auditability; a later arrival receives a new identifier. The service does not authenticate possession of a ticket, so an application must authorize exit commands before invoking it. Adding authentication to this small model would mix transport identity with occupancy policy, but omitting that responsibility from a deployment would be a mistake.
Discuss scale without rebuilding the model
Entering scans history to derive active records, then scans spots and chooses a minimum. Its work is linear in stored tickets and spots. Exiting is a dictionary lookup plus policy evaluation. For a larger installation, index active plates and spot occupancy or persist them with uniqueness constraints. Keep all checks and mutations in one transaction; a pre-check alone cannot prevent two servers from booking the same spot.
This model has no crash recovery, payment settlement, or cross-process coordination. Those are concrete next requirements, not reasons to insert factories, event buses, or abstract repositories into every line now. Add a second gate by preserving the same allocation contract and changing the synchronization and persistence boundary. Retain the tests as executable examples of the business rules while expanding failure coverage for storage and payments.
Next practice
Propose reserved accessible spots only after defining eligibility and fallback behavior. Then compare allocation strategies on a mixed fleet. Review OOP, operating systems, and databases and SQL. Try occupancy constraints in the SQL playground, then connect them to system design fundamentals.
Continue in the SDE preparation track with SQL interview challenges, core CS interview answers, library system design.