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.

KindRequirement for this design
FunctionalThe parking lot contains multiple levels, and every level contains individually identified spots.
FunctionalSpots have compact, large, handicapped, or motorcycle types. A vehicle may use only a compatible spot.
FunctionalparkVehicle() assigns the nearest suitable free spot from the vehicle's entrance.
FunctionalEntry creates a ticket containing the vehicle, spot, and entry time.
Functionalunpark() releases the spot, closes the ticket, and calculates a time-based fee.
FunctionalThe lot can report current free-space counts by spot type.
Non-functionalOne spot must never be assigned to two vehicles, even when two gates submit requests together.
Non-functionalFit 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 an entranceId, 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:

  1. A parking lot contains multiple levels with parking spots.
  2. A vehicle has a vehicle type, a license plate, and possibly an accessibility permit.
  3. A parking spot has a spot type, an occupant, and a distance from each entrance.
  4. An entry creates a ticket with an entry time; an exit time determines a fee.
  5. A fee policy calculates the price for one completed stay.

Now sort those nouns instead of making one class per word:

BucketNounsModel choice
Domain entitiesparking lot, level, parking spot, vehicle, ticketCore classes with identity, state, or lifecycle
Attributeslicense plate, permit, distance, entry time, exit timeFields on the entity whose rule needs them
Actors / boundary inputsdriver, entrance gate, exit gateCall the core model but do not become core classes yet
Enumsspot type, vehicle typeSpotType and VehicleType describe the current finite vocabulary
Replaceable policyfee policy, fit rulesFeeStrategy and SpotFitPolicy interfaces
Excluded conceptspayment, reservation, OCR, lost ticketNo speculative classes in this model

The verb pass assigns behavior to the owner of the data and invariant:

Verb phraseOwnerReason
find the nearest suitable spotParkingLot coordinates; each Level searches its spotsThe lot compares level candidates, while a level owns its collection
decide whether a vehicle fitsParkingSpot.canFit() delegates to SpotFitPolicyThe spot protects admission without hard-coding a changing matrix
assign and release a vehicleParkingSpotOccupancy is the state that must not be changed directly
issue and close a ticketParkingLot orchestrates; Ticket protects one-stay lifecycleCreation joins vehicle, spot, and time; the ticket rejects a second close
calculate the exit feeFeeStrategyPricing varies independently from allocation
report availabilityParkingLot aggregates level and spot stateThe 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

Core parking-lot model
  1. 1ParkingLot coordinates levels, active tickets, and the selected fee policy.
  2. 2A lot contains ordered Levels, giving search a boundary smaller than the whole building.
  3. 3Each Level owns its spots and can return its nearest candidate for one entrance.
  4. 4ParkingSpot delegates compatibility instead of growing its own type switch.
  5. 5RuleBasedSpotFit supplies the current spot-to-vehicle compatibility rules.
  6. 6An occupied spot retains the Vehicle it accepted until release.
  7. 7Ticket records the Vehicle whose stay began at entry.
  8. 8The same Ticket keeps the assigned spot and its pricing category.
  9. 9ParkingLot indexes open Tickets so exit can find and close the stay.
  10. 10The lot delegates exit pricing through FeeStrategy rather than owning a formula.
  11. 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

Park a vehicle from one entrance
  1. 1ParkingLot receives the entry request and asks each level for its best candidate.
  2. 2A level filters free spots through ParkingSpot.canFit(vehicle).
  3. 3The lot compares candidates, then asks the winning spot to protect its occupancy.
  4. 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 RuleBasedSpotFit are 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 another SpotFitPolicy. Do not add a growing switch to ParkingSpot.
  • Open/Closed Principle boundary: ParkingSpot, Level, and ParkingLot already 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 by assign() is a check-then-act race across processes. Use a database transaction, row lock, or compare-and-set update such as “claim where occupied = 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: FeeStrategy can 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 SpotType into CompactCharging, 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.

+50 XP on completion