Parking Lot
Advanced · 16 min read · ▶ live playground · ✦ checkpoint
“Design a parking lot.” The prompt sounds like a few boxes and a park() method, but the interview is really testing where you place fit rules, allocation, pricing, and concurrency boundaries. We will turn the prompt into a model, walk one vehicle through it, and leave every shortcut visible for the design review.
Requirements
Start by fixing the scope. A design cannot choose useful responsibilities while “parking lot” still means everything from a driveway to an airport complex.
| Kind | Requirement for this design |
|---|---|
| Functional | The parking lot contains multiple levels, and every level contains individually identified spots. |
| Functional | Spots have compact, large, handicapped, or motorcycle types. A vehicle may use only a compatible spot. |
| Functional | parkVehicle() assigns the nearest suitable free spot from the vehicle's entrance. |
| Functional | Entry creates a ticket containing the vehicle, spot, and entry time. |
| Functional | unpark() releases the spot, closes the ticket, and calculates a time-based fee. |
| Functional | The lot can report current free-space counts by spot type. |
| Non-functional | One spot must never be assigned to two vehicles, even when two gates submit requests together. |
| Non-functional | Fit and fee rules should be replaceable without rewriting the parking workflow. |
Payment-gateway integration, license-plate OCR, reservations, lost-ticket handling, and monthly passes are out of scope. Electric charging is also excluded from the core model; we will decide below where it would fit instead of quietly treating it as another vehicle size.
Clarifying questions to ask
A strong candidate asks these before drawing a class. Each answer changes either an invariant or an extension point.
- How is the fee calculated? We will charge per started hour, with a rate chosen by spot type and a minimum of one hour. The lot receives a
FeeStrategy, so a weekend, flat-rate, or progressive policy can replace the hourly rule. - Are there multiple entry and exit gates? Yes.
parkVehicle()receives anentranceId, while exit can happen at any gate. Ticket IDs therefore come from the lot boundary rather than from one gate's local counter in a production deployment. - What exactly does “nearest” mean across levels? Each spot stores a precomputed walking-distance score for every entrance, including ramps or lifts. Lower scores are nearer; ties go to the lower level and then the spot ID so tests stay deterministic.
- How are spot-to-vehicle fit rules defined? A car fits compact or large, a truck fits large, a motorcycle fits motorcycle, and a handicapped spot requires a car with an accessibility permit. These are business rules, not consequences of enum ordering.
- Can a permitted vehicle use an ordinary compact or large spot? Yes. The permit adds eligibility for a handicapped spot; it does not remove the vehicle's ordinary physical fit.
- Are electric charging spots another
SpotType? Not in this scope. Charging is an amenity independent of physical size. If it arrives later, model it as a capability or filter so “large + charging” does not become another growing enum combination. - What happens when a ticket is invalid or already closed? Exit is rejected. A ticket represents one stay and can close once. Lost-ticket policy is a separate requirement and remains excluded.
- Where does time come from? Entry and exit times are supplied to the core methods. A real gate service injects a clock; tests can pass fixed dates without waiting or changing global time.
- How fresh must availability be? The core answer is exact: derive counts from spot occupancy. A cached counter or display board may be added later, but it must update in the same transaction as the spot claim or accept documented staleness.
- What scale and concurrency should we assume? The code below is an in-memory, single-process skeleton. The production boundary must claim a spot atomically in shared storage and retry when another gate wins the race.
Entity extraction
Run the noun pass first. Preserve the domain words before deciding which ones deserve classes:
- A parking lot contains multiple levels with parking spots.
- A vehicle has a vehicle type, a license plate, and possibly an accessibility permit.
- A parking spot has a spot type, an occupant, and a distance from each entrance.
- An entry creates a ticket with an entry time; an exit time determines a fee.
- A fee policy calculates the price for one completed stay.
Now sort those nouns instead of making one class per word:
| Bucket | Nouns | Model choice |
|---|---|---|
| Domain entities | parking lot, level, parking spot, vehicle, ticket | Core classes with identity, state, or lifecycle |
| Attributes | license plate, permit, distance, entry time, exit time | Fields on the entity whose rule needs them |
| Actors / boundary inputs | driver, entrance gate, exit gate | Call the core model but do not become core classes yet |
| Enums | spot type, vehicle type | SpotType and VehicleType describe the current finite vocabulary |
| Replaceable policy | fee policy, fit rules | FeeStrategy and SpotFitPolicy interfaces |
| Excluded concepts | payment, reservation, OCR, lost ticket | No speculative classes in this model |
The verb pass assigns behavior to the owner of the data and invariant:
| Verb phrase | Owner | Reason |
|---|---|---|
| find the nearest suitable spot | ParkingLot coordinates; each Level searches its spots | The lot compares level candidates, while a level owns its collection |
| decide whether a vehicle fits | ParkingSpot.canFit() delegates to SpotFitPolicy | The spot protects admission without hard-coding a changing matrix |
| assign and release a vehicle | ParkingSpot | Occupancy is the state that must not be changed directly |
| issue and close a ticket | ParkingLot orchestrates; Ticket protects one-stay lifecycle | Creation joins vehicle, spot, and time; the ticket rejects a second close |
| calculate the exit fee | FeeStrategy | Pricing varies independently from allocation |
| report availability | ParkingLot aggregates level and spot state | The answer is derived from the same occupancy facts used for parking |
This is the course's “grammar proposes; ownership decides” rule in a larger model. ParkingLot receives the main verbs, but it does not inspect or mutate every collaborator's fields. It coordinates objects that protect their own rules.
Core class diagram
Build only the classes that carry the critical flow. Gate controllers, repositories, display boards, and payment objects belong in an extended production diagram after the core is understood.
class diagram
Core parking-lot model
- 1ParkingLot coordinates levels, active tickets, and the selected fee policy.
- 2A lot contains ordered Levels, giving search a boundary smaller than the whole building.
- 3Each Level owns its spots and can return its nearest candidate for one entrance.
- 4ParkingSpot delegates compatibility instead of growing its own type switch.
- 5RuleBasedSpotFit supplies the current spot-to-vehicle compatibility rules.
- 6An occupied spot retains the Vehicle it accepted until release.
- 7Ticket records the Vehicle whose stay began at entry.
- 8The same Ticket keeps the assigned spot and its pricing category.
- 9ParkingLot indexes open Tickets so exit can find and close the stay.
- 10The lot delegates exit pricing through FeeStrategy rather than owning a formula.
- 11HourlyFeeStrategy charges started hours and can be replaced at construction.
FeeStrategy is the Strategy pattern: the context delegates one replaceable algorithm through a stable contract. SpotFitPolicy uses the same composition shape for a different rule. Two interfaces are justified because pricing and physical eligibility change for different reasons.
The diagram keeps SpotType and VehicleType as fields rather than spending two more boxes on enum literals. It also omits the atomic storage adapter. That omission is deliberate: this is the core object model, not a claim that an in-memory array solves a multi-gate race.
Critical-flow sequence diagram
The flow worth drawing is “park a vehicle.” If ownership is correct here, exit and availability reuse the same state instead of inventing parallel truth.
sequence diagram
Park a vehicle from one entrance
- 1ParkingLot receives the entry request and asks each level for its best candidate.
- 2A level filters free spots through ParkingSpot.canFit(vehicle).
- 3The lot compares candidates, then asks the winning spot to protect its occupancy.
- 4Only after assignment succeeds does the lot issue and return the entry Ticket.
The single :Level lifeline represents the repeated level search. ParkingLot compares those candidates by gate-relative distance before calling assign(). In a production version, candidate selection and assignment become one atomic claim; otherwise, the reassuring sequence still contains a race between the candidate return and the assignment call.
TypeScript skeleton
The skeleton keeps the policy seams real and implements both entry and exit. Timestamps are parameters, so the domain code has no hidden clock.
export {};
enum SpotType {
Compact = "compact",
Large = "large",
Handicapped = "handicapped",
Motorcycle = "motorcycle",
}
enum VehicleType {
Car = "car",
Truck = "truck",
Motorcycle = "motorcycle",
}
class Vehicle {
public constructor(
public readonly licensePlate: string,
public readonly type: VehicleType,
public readonly hasAccessiblePermit = false,
) {
if (licensePlate.trim().length === 0) {
throw new Error("license plate is required");
}
}
}
interface SpotFitPolicy {
canFit(spotType: SpotType, vehicle: Vehicle): boolean;
}
class RuleBasedSpotFit implements SpotFitPolicy {
public canFit(spotType: SpotType, vehicle: Vehicle): boolean {
switch (spotType) {
case SpotType.Compact:
return vehicle.type === VehicleType.Car;
case SpotType.Large:
return (
vehicle.type === VehicleType.Car ||
vehicle.type === VehicleType.Truck
);
case SpotType.Handicapped:
return (
vehicle.type === VehicleType.Car &&
vehicle.hasAccessiblePermit
);
case SpotType.Motorcycle:
return vehicle.type === VehicleType.Motorcycle;
}
return false;
}
}
class ParkingSpot {
public readonly id: string;
public readonly type: SpotType;
private readonly distanceByEntrance: ReadonlyMap<string, number>;
private readonly fitPolicy: SpotFitPolicy;
private occupiedBy: Vehicle | null = null;
public constructor(
id: string,
type: SpotType,
distanceByEntrance: ReadonlyMap<string, number>,
fitPolicy: SpotFitPolicy,
) {
this.id = id;
this.type = type;
this.distanceByEntrance = new Map(distanceByEntrance);
this.fitPolicy = fitPolicy;
}
public get isFree(): boolean {
return this.occupiedBy === null;
}
public distanceFrom(entranceId: string): number {
return (
this.distanceByEntrance.get(entranceId) ??
Number.POSITIVE_INFINITY
);
}
public canFit(vehicle: Vehicle): boolean {
return (
this.isFree &&
this.fitPolicy.canFit(this.type, vehicle)
);
}
public assign(vehicle: Vehicle): void {
if (!this.canFit(vehicle)) {
throw new Error(`spot ${this.id} is unavailable or unsuitable`);
}
this.occupiedBy = vehicle;
}
public release(vehicle: Vehicle): void {
if (this.occupiedBy === null) {
throw new Error(`spot ${this.id} is already free`);
}
if (this.occupiedBy.licensePlate !== vehicle.licensePlate) {
throw new Error(`vehicle does not occupy spot ${this.id}`);
}
this.occupiedBy = null;
}
}
type Availability = Record<SpotType, number>;
function emptyAvailability(): Availability {
return {
[SpotType.Compact]: 0,
[SpotType.Large]: 0,
[SpotType.Handicapped]: 0,
[SpotType.Motorcycle]: 0,
};
}
class Level {
public readonly number: number;
private readonly spots: readonly ParkingSpot[];
public constructor(number: number, spots: readonly ParkingSpot[]) {
this.number = number;
this.spots = [...spots];
}
public nearestAvailable(
vehicle: Vehicle,
entranceId: string,
): ParkingSpot | null {
let nearest: ParkingSpot | null = null;
for (const spot of this.spots) {
const distance = spot.distanceFrom(entranceId);
if (!spot.canFit(vehicle) || !Number.isFinite(distance)) {
continue;
}
const nearestDistance =
nearest?.distanceFrom(entranceId) ?? Number.POSITIVE_INFINITY;
if (
distance < nearestDistance ||
(distance === nearestDistance &&
nearest !== null &&
spot.id.localeCompare(nearest.id) < 0)
) {
nearest = spot;
}
}
return nearest;
}
public availability(): Availability {
const counts = emptyAvailability();
for (const spot of this.spots) {
if (spot.isFree) {
counts[spot.type] += 1;
}
}
return counts;
}
}
class Ticket {
public readonly id: string;
public readonly vehicle: Vehicle;
public readonly spot: ParkingSpot;
private readonly entryTime: Date;
private exitTime: Date | null = null;
public constructor(
id: string,
vehicle: Vehicle,
spot: ParkingSpot,
enteredAt: Date,
) {
if (Number.isNaN(enteredAt.getTime())) {
throw new Error("entry time is invalid");
}
this.id = id;
this.vehicle = vehicle;
this.spot = spot;
this.entryTime = new Date(enteredAt);
}
public get enteredAt(): Date {
return new Date(this.entryTime);
}
public get isOpen(): boolean {
return this.exitTime === null;
}
public close(exitedAt: Date): void {
if (!this.isOpen) {
throw new Error(`ticket ${this.id} is already closed`);
}
if (Number.isNaN(exitedAt.getTime())) {
throw new Error("exit time is invalid");
}
if (exitedAt.getTime() < this.entryTime.getTime()) {
throw new Error("exit cannot be before entry");
}
this.exitTime = new Date(exitedAt);
}
}
interface FeeStrategy {
calculate(ticket: Ticket, exitedAt: Date): number;
}
class HourlyFeeStrategy implements FeeStrategy {
private static readonly hourMs = 60 * 60 * 1000;
private readonly rates: ReadonlyMap<SpotType, number>;
public constructor(rates: ReadonlyMap<SpotType, number>) {
this.rates = new Map(rates);
}
public calculate(ticket: Ticket, exitedAt: Date): number {
if (!ticket.isOpen) {
throw new Error("cannot price a closed ticket again");
}
if (Number.isNaN(exitedAt.getTime())) {
throw new Error("exit time is invalid");
}
const elapsedMs =
exitedAt.getTime() - ticket.enteredAt.getTime();
if (elapsedMs < 0) {
throw new Error("exit cannot be before entry");
}
const rate = this.rates.get(ticket.spot.type);
if (rate === undefined) {
throw new Error(`no rate for ${ticket.spot.type}`);
}
const startedHours = Math.max(
1,
Math.ceil(elapsedMs / HourlyFeeStrategy.hourMs),
);
return startedHours * rate;
}
}
interface ExitReceipt {
readonly ticketId: string;
readonly fee: number;
readonly exitedAt: Date;
}
class ParkingLot {
private readonly levels: readonly Level[];
private readonly feeStrategy: FeeStrategy;
private readonly activeTickets = new Map<string, Ticket>();
private ticketSequence = 0;
public constructor(
levels: readonly Level[],
feeStrategy: FeeStrategy,
) {
this.levels = [...levels].sort((a, b) => a.number - b.number);
this.feeStrategy = feeStrategy;
}
public parkVehicle(
vehicle: Vehicle,
entranceId: string,
enteredAt: Date,
): Ticket {
if (Number.isNaN(enteredAt.getTime())) {
throw new Error("entry time is invalid");
}
const alreadyParked = [...this.activeTickets.values()].some(
(ticket) =>
ticket.vehicle.licensePlate === vehicle.licensePlate,
);
if (alreadyParked) {
throw new Error("vehicle already has an active ticket");
}
const spot = this.findNearestSpot(vehicle, entranceId);
if (spot === null) {
throw new Error("no suitable spot is available");
}
spot.assign(vehicle);
const ticket = new Ticket(
`T-${++this.ticketSequence}`,
vehicle,
spot,
enteredAt,
);
this.activeTickets.set(ticket.id, ticket);
return ticket;
}
public unpark(ticketId: string, exitedAt: Date): ExitReceipt {
const ticket = this.activeTickets.get(ticketId);
if (ticket === undefined) {
throw new Error(`active ticket ${ticketId} was not found`);
}
const fee = this.feeStrategy.calculate(ticket, exitedAt);
ticket.spot.release(ticket.vehicle);
ticket.close(exitedAt);
this.activeTickets.delete(ticketId);
return {
ticketId,
fee,
exitedAt: new Date(exitedAt),
};
}
public availability(): Availability {
const total = emptyAvailability();
for (const level of this.levels) {
const counts = level.availability();
for (const type of Object.values(SpotType)) {
total[type] += counts[type];
}
}
return total;
}
private findNearestSpot(
vehicle: Vehicle,
entranceId: string,
): ParkingSpot | null {
let best: { spot: ParkingSpot; level: number } | null = null;
for (const level of this.levels) {
const spot = level.nearestAvailable(vehicle, entranceId);
if (spot === null) continue;
const distance = spot.distanceFrom(entranceId);
const bestDistance =
best?.spot.distanceFrom(entranceId) ?? Number.POSITIVE_INFINITY;
if (
distance < bestDistance ||
(distance === bestDistance &&
(best === null || level.number < best.level)) ||
(distance === bestDistance &&
best !== null &&
level.number === best.level &&
spot.id.localeCompare(best.spot.id) < 0)
) {
best = { spot, level: level.number };
}
}
return best?.spot ?? null;
}
}
const fitPolicy = new RuleBasedSpotFit();
const firstLevel = new Level(1, [
new ParkingSpot(
"L1-C01",
SpotType.Compact,
new Map([["north", 12]]),
fitPolicy,
),
new ParkingSpot(
"L1-M01",
SpotType.Motorcycle,
new Map([["north", 5]]),
fitPolicy,
),
]);
const rates = new Map<SpotType, number>([
[SpotType.Compact, 30],
[SpotType.Large, 50],
[SpotType.Handicapped, 20],
[SpotType.Motorcycle, 15],
]);
const lot = new ParkingLot(
[firstLevel],
new HourlyFeeStrategy(rates),
);
const car = new Vehicle("MH-12-AB-1234", VehicleType.Car);
const ticket = lot.parkVehicle(
car,
"north",
new Date("2026-07-13T09:00:00Z"),
);
console.log(`${ticket.id}: ${ticket.spot.id}`);
const receipt = lot.unpark(
ticket.id,
new Date("2026-07-13T10:10:00Z"),
);
console.log(`fee: ₹${receipt.fee}`);T-1: L1-C01 fee: ₹60
The seventy-minute stay touches two started hours. Entry selects and assigns the spot before the ticket enters activeTickets; exit prices the open ticket, releases the same vehicle, closes the lifecycle, and removes the active index.
The code still scans spots and keeps state in one process. That is appropriate for a skeleton: the responsibility map is executable, while the infrastructure trade-offs remain explicit instead of hiding behind a repository name.
Design-review checklist
This is where an interviewer stops admiring the diagram and starts applying pressure.
- Adding spot or vehicle types: The current enums and
RuleBasedSpotFitare clear for a stable four-by-three matrix. If categories arrive from configuration and core files must remain closed, replace enum identity with registered type IDs or capability objects and inject anotherSpotFitPolicy. Do not add a growingswitchtoParkingSpot. - Open/Closed Principle boundary:
ParkingSpot,Level, andParkingLotalready depend on the fit contract, so a new compatibility implementation does not change allocation. Be honest that adding an enum member still edits the closed vocabulary; OCP is about the promised axis of extension, not pretending no file ever changes. - Two cars, one spot:
findNearestSpot()followed byassign()is a check-then-act race across processes. Use a database transaction, row lock, or compare-and-set update such as “claim whereoccupied = false”; if zero rows change, choose the next candidate and retry. - Nearest-spot efficiency: A full scan is
O(number of spots)per entry and is often fine for the interview skeleton. At scale, keep a per-entrance, per-fit-category ordered index or priority queue. Release must reinsert the spot, and stale queue entries need version checks. - Availability consistency: Derived counts cannot drift but cost a scan. Cached counts are faster for display boards, yet the claim/release transaction must update them or the API must document eventual consistency.
- Fee changes:
FeeStrategycan switch hourly, flat, weekend, lost-ticket, or promotional pricing at construction. Persist the pricing version or calculated receipt if historical tickets must reproduce the rate that applied at entry. - Multiple gates: Every gate should call the same allocation service and shared store. A process-local lock protects only one server. Gate-relative distance is already an input, while globally unique ticket IDs and idempotent retry keys still belong at the boundary.
- Failure ordering: If ticket persistence fails after a spot claim, release or roll back the claim in the same transaction. On exit, do not free the spot and then discover that ticket closure could not be stored.
- Reservations and charging: Reservations add a time-bound claim state, not a boolean flag. Charging is an independent amenity, so model filters or capabilities instead of multiplying
SpotTypeintoCompactCharging,LargeCharging, and more. - Testing the critical invariants: Cover exact and overflow fits, permit rules, distance ties, no-capacity errors, duplicate plates, negative durations, double exits, and release by the wrong vehicle. Then add a concurrent allocation test against the real persistence mechanism.
- With more time: Add repositories, injected ID and clock providers, audit events, observability, a display projection, and the payment boundary. Each belongs around this model only after its consistency requirement is stated.
Checkpoint
Answer all three to mark this lesson complete
The parking lot turns a broad prompt into explicit ownership: spots protect occupancy, policies hold changeable rules, and the lot coordinates the use case. Next, tic-tac-toe applies the same extraction method to a smaller board whose real challenge is preserving turn and win invariants as the rules grow.