Skip to content

14 — Problem: Parking Lot

Understanding the Problem

A Parking Lot is the canonical LLD interview problem, and there's a reason it has survived twenty years of leetcode rotations: every extensibility dimension the interviewer cares about is naturally present. Spot sizing, vehicle compatibility, ticketing, pricing, payments, concurrency at the entry gate — you can't fake depth on any of them. The interviewer wants to watch you decide what to abstract, what to concretize, and where to draw the seams that will absorb tomorrow's requirements.

What is a Parking Lot?

A multi-level, multi-entry parking facility with heterogeneous spot types (compact, regular, large, handicap, EV-charging). A vehicle arrives at an entry gate, receives a ticket with the assigned spot, occupies that spot, and later exits through some gate after paying based on duration. The design problem is: how do you structure the code so that adding a new spot type, a new pricing model, or a new payment method doesn't cascade through the codebase? Every decision you make should be interrogated against "what if the next PM request is X?"

The trap is treating this as a data-modeling exercise. It is not. A senior design has three loud concerns:

  1. Extensibility — pricing, assignment, spot types, payment methods are all Strategy slots.
  2. Concurrency — the entry gate is a contended resource. Two vehicles racing for the last spot is the defining correctness problem.
  3. Singleton avoidance — every junior writes ParkingLot.getInstance(). Don't.

The bar between SDE2 and SDE3 is set by how well you handle concurrency and how explicitly you argue against Singleton.


Clarifying Questions

You: What spot types do we support, and how do vehicles map to them?

Interviewer: Compact, Regular, Large, Handicap, EV-charging. Motorcycles fit anywhere. Cars fit Compact and larger. Trucks/vans only fit Large. Handicap is a sub-type that requires a placard. EV-charging is optional — an EV can park in a regular spot but won't charge.

You: Is the facility multi-level and multi-entry?

Interviewer: Yes. 3-5 levels. 2-4 entry/exit gates. Entries and exits are distinct points.

You: Pricing — hourly, flat, tiered, surge?

Interviewer: Start with hourly. Design so that flat-rate, tiered (first hour free, next 2 hours at $X, etc.), and surge pricing can be added without touching the checkout flow.

You: What happens if a customer loses their ticket?

Interviewer: Flat maximum daily charge. You'll want to verify license plate at exit.

You: Payment methods?

Interviewer: Credit card and cash today. Assume a pluggable gateway for the credit card path.

You: Reservation ahead of time, or walk-up only?

Interviewer: Walk-up for this pass. Mention how reservations would change the design.

You: Monthly subscribers / tenant parking?

Interviewer: Out of scope for v1, but again — call out where it would slot in.

You: What's the scale? How many spots per lot, how many vehicles per day?

Interviewer: Single lot, ~2000 spots, peak 20 vehicles/minute entering. Design for a single process first; we'll discuss multi-lot and horizontal scale.

You: Can a vehicle be assigned the wrong-sized spot if its correct size is full?

Interviewer: No. If the correct size (or larger compatible) is full, the lot is effectively full for that vehicle. Reject at the gate.

You: Do we need to track which level a vehicle is on for display boards?

Interviewer: Yes. Each entry gate has a display showing spots-available-by-type-per-level.


Functional Requirements

  1. Issue a ticket on entry with: ticket ID, vehicle identifier (license plate), assigned spot reference, entry timestamp.
  2. Assign a compatible spot based on vehicle type, spot type rules, and an assignment strategy (nearest, first-fit, or size-match).
  3. Reject entry when no compatible spot exists. Surface the reason to the display.
  4. Release the spot and compute the fee on exit, keyed by duration and pricing strategy.
  5. Accept payment through a pluggable gateway. Only release the exit barrier on successful payment.
  6. Handle lost tickets — verify license plate, charge the flat max daily fee, release.
  7. Display live availability per level, per spot type, at every entry gate.
  8. Support multiple entry and exit gates operating concurrently on the same lot.
  9. Support multiple spot types with vehicle-compatibility rules governed by data, not hardcoded if/else.

Out of scope for v1: reservations, monthly subscriptions, valet mode, multi-lot search, dynamic/surge pricing, IoT sensor integration.


Non-Functional Requirements

NFRTargetWhy it matters
Entry-gate latencyp99 < 500 ms from "press button" to "ticket printed"Slow gates create a physical queue on the ramp. This is the most visible NFR.
Correctness under concurrent entryZero double-assignmentsTwo tickets for the same spot is a physical collision. Hard constraint, not a soft SLA.
FairnessFIFO within a gate; no gate is starvedA vehicle that arrived first at gate A shouldn't wait while gate B assigns the last spot.
AvailabilityGate operates offline-tolerant for 60sCentral service blip shouldn't lock customers in. Degrade gracefully.
Scalability2000 spots, 20 entries/minute peak, headroom to 5×Not webscale, but entry-gate contention is the hot spot.
AuditabilityEvery ticket and receipt persistedDisputes, fraud, tax. Non-negotiable in production.
RecoverabilityRestart without losing active ticketsIn-memory tickets are unacceptable; back by persistence.

Latency and correctness are the two NFRs interviewers actually test you on. Fairness is where SDE3 shows up.


Core Entities and Relationships

EntityResponsibilityOwnsReferenced by
ParkingLotAggregate root. Owns levels. Entry point for parkVehicle / exitVehicle.Level[], PricingStrategy, AssignmentStrategy, TicketStoreGates
LevelA single floor. Owns spots and per-level availability counters.Spot[]ParkingLot
SpotA physical parking space. Has a type and a state machine.Level, Ticket
SpotTypeEnum-ish value: COMPACT, REGULAR, LARGE, HANDICAP, EV_CHARGING.Spot, compatibility table
VehicleLicense plate + vehicle type + flags (placard, EV).Ticket
VehicleTypeEnum: MOTORCYCLE, CAR, VAN, TRUCK.Vehicle, compatibility table
TicketIssued at entry. Holds ticket ID, vehicle, spot ref, entry timestamp, status.ParkingLot, payment flow
ReceiptIssued at exit. Ticket ref + duration + amount + payment status.Caller
PricingStrategyComputes price(ticket, exitTime). Interchangeable.ParkingLot
AssignmentStrategyPicks a spot for a vehicle at a gate. Interchangeable.ParkingLot
PaymentMethodcharge(amount) -> PaymentResult. Interchangeable (card gateway, cash).Exit flow
EntryGate / ExitGatePhysical devices. Hold a display, a printer, a barrier. Delegate to ParkingLot.ParkingLot
TicketStorePersistent store of tickets. Look up by ID or license plate (lost-ticket path).ParkingLot

Relationship summary: ParkingLot has-many Level has-many Spot. Ticket references Vehicle and Spot. ParkingLot has-a pricing and assignment strategy (both swappable). Gates depend on ParkingLot via a narrow interface.


Interfaces

typescript
type SpotType = "COMPACT" | "REGULAR" | "LARGE" | "HANDICAP" | "EV_CHARGING";
type VehicleType = "MOTORCYCLE" | "CAR" | "VAN" | "TRUCK";
type SpotState = "AVAILABLE" | "OCCUPIED" | "RESERVED" | "OUT_OF_SERVICE";
type TicketStatus = "ACTIVE" | "PAID" | "LOST";

interface IAssignmentStrategy {
  /**
   * Pick a spot compatible with the vehicle. Returns null if none available.
   * Implementations MUST be safe to call under the lot's lock discipline;
   * they may read availability state but must not mutate spots directly.
   */
  pick(lot: ParkingLot, vehicle: Vehicle, entryGateId: string): Spot | null;
}

interface IPricingStrategy {
  /** Pure function of ticket + exit time. No I/O, no mutation. */
  price(ticket: Ticket, exitTime: Date): Money;
}

interface IPaymentMethod {
  charge(amount: Money, context: PaymentContext): Promise<PaymentResult>;
  readonly name: string;
}

interface ITicket {
  readonly id: string;
  readonly vehicle: Vehicle;
  readonly spotId: string;
  readonly levelId: string;
  readonly entryTime: Date;
  readonly entryGateId: string;
  status: TicketStatus;
}

interface Money {
  readonly amount: number; // cents / minor units
  readonly currency: string;
}

interface PaymentResult {
  readonly ok: boolean;
  readonly txnId?: string;
  readonly error?: string;
}

interface PaymentContext {
  readonly ticketId: string;
  readonly vehiclePlate: string;
}

Why IAssignmentStrategy takes the whole lot? A strategy may need per-level availability counters, spatial information (distance from gate), or EV-capability filters. Passing the lot is wider than we'd like but simpler than inventing a LotView read-model up front — and a clean refactor once that view actually helps.

Why is pricing pure? Concurrency. The pricing call is the hot path on exit. If it reaches out to a rate table in a DB, every exit becomes a DB round-trip. Keep the strategy pure, inject the rate table through the constructor, and you can parallelize exits freely.


Class Diagram

                         +----------------------+
                         |     ParkingLot       |
                         |----------------------|
                         | - levels: Level[]    |
                         | - pricing            |<>-- IPricingStrategy
                         | - assignment         |<>-- IAssignmentStrategy
                         | - ticketStore        |<>-- TicketStore
                         |----------------------|
                         | + parkVehicle()      |
                         | + exitVehicle()      |
                         | + availability()     |
                         +----------+-----------+
                                    |
                       1 .. *       |       1
          +-------------------------+-------------------------+
          |                         |                         |
    +----------+              +------------+           +-------------+
    |  Level   |              | EntryGate  |           |  ExitGate   |
    |----------|              |------------|           |-------------|
    | - spots  |              | - display  |           | - printer   |
    | - avail  |              |------------|           |-------------|
    +----+-----+              | +onArrive()|           | +onDepart() |
         | 1..*               +------------+           +-------------+
         v
    +---------+                       (state)
    |  Spot   |---------------+   +-------------+
    |---------|               |   |  SpotState  |
    | - type  |               +-->| AVAIL/OCC/  |
    | - state |                   | RES/OOS     |
    +---------+                   +-------------+

    +------------+     issued-on-entry     +----------+
    |  Vehicle   |<------------------------| Ticket   |
    +------------+                         +----------+
                                                 ^
                                                 |  owned by
                                                 |
                                           +------------+
                                           | TicketStore|
                                           +------------+

    Strategies (swappable):
      IAssignmentStrategy  -->  NearestSpot, FirstFit, SizeMatch
      IPricingStrategy     -->  HourlyPricing, FlatRatePricing, TieredPricing
      IPaymentMethod       -->  CreditCardPayment, CashPayment

Class Design

ParkingLot — the aggregate root

Holds levels, strategies, and the ticket store. Exposes only parkVehicle, exitVehicle, and read-only availability. Every mutation goes through these methods so that locking discipline is centralized. This is also where we refuse to add a static getInstance() — see Design Decisions.

Level

Owns a Spot[] and maintains per-type availability counters (availableByType: Map<SpotType, number>). Counters are redundant with iterating spots but cheap to maintain and turn availability checks from O(N) into O(1) — critical when every entry gate checks them.

Spot — state machine

A Spot has exactly four states. Transitions are explicit:

  AVAILABLE  --assign-->  OCCUPIED   (on entry)
  OCCUPIED   --release--> AVAILABLE  (on exit)
  AVAILABLE  --reserve--> RESERVED   (reservation system, future)
  RESERVED   --claim-->   OCCUPIED   (arrival within hold window)
  RESERVED   --expire-->  AVAILABLE  (hold expired)
  *          --disable--> OUT_OF_SERVICE
  OUT_OF_SERVICE --enable--> AVAILABLE

Any other transition is a bug. Enforce in code; don't trust callers.

Vehicle and compatibility

Compatibility is data, not if/else:

typescript
const COMPATIBILITY: Record<VehicleType, SpotType[]> = {
  MOTORCYCLE: ["COMPACT", "REGULAR", "LARGE", "HANDICAP", "EV_CHARGING"],
  CAR:        ["COMPACT", "REGULAR", "LARGE", "EV_CHARGING"],
  VAN:        ["LARGE"],
  TRUCK:      ["LARGE"],
};

Handicap access is gated additionally by vehicle.hasPlacard. EV-charging spots are preferred (not required) for EV vehicles — a strategy concern, not a rule. Keeping the rule in a table means adding a "bus" means one new map entry plus a new enum value, not ten switch statements.

Ticket

Issued on entry, carries entry context for pricing. The status transitions ACTIVE -> PAID (normal exit) or ACTIVE -> LOST (lost-ticket path). Never deleted — receipts and audits need them.

PricingStrategy implementations

  • HourlyPricing$X per hour, fractional rounded up. Simple, the interviewer's starter.
  • FlatRatePricing — fixed fee regardless of duration. Used for events, lost-ticket charges.
  • TieredPricing — breakpoints: first hour free, next 2 hours $3/hr, thereafter $5/hr, with a daily cap.

All three conform to IPricingStrategy. Swapping is a constructor change.

AssignmentStrategy implementations

  • FirstFit — return the first compatible available spot. Fastest, worst utilization on large lots.
  • NearestSpot — compatible spots ranked by distance from the entry gate. Best UX; slower.
  • SizeMatch — pick the smallest compatible spot (don't give a motorcycle a truck spot). Best utilization; worst UX for the customer (longer walks).

Real lots use a hybrid. The point of the Strategy pattern here is that you can A/B test which one maximizes revenue without editing ParkingLot.


Key Methods

typescript
class ParkingLot {
  constructor(
    public readonly id: string,
    private readonly levels: Level[],
    private readonly assignment: IAssignmentStrategy,
    private readonly pricing: IPricingStrategy,
    private readonly ticketStore: TicketStore,
    private readonly clock: Clock = systemClock,
  ) {}

  /**
   * Entry flow. Thread-safety contract:
   *  - Acquires the lot-level lock for spot selection and state transition.
   *  - Holds the lock only for the critical section (spot mutation).
   *  - Ticket creation and persistence happen outside the lock.
   */
  parkVehicle(vehicle: Vehicle, entryGateId: string): Ticket {
    // 1) Pick + claim a spot atomically. No ticket yet — we don't want a
    //    ticket for a spot we couldn't claim.
    const spot = this.claimSpotAtomically(vehicle, entryGateId);
    if (!spot) {
      throw new LotFullError(vehicle.type, this.availability());
    }

    // 2) Ticket creation is lock-free. Claimed spot is already OCCUPIED,
    //    so no other flow can race for it.
    const ticket: Ticket = {
      id: generateTicketId(this.id, entryGateId),
      vehicle,
      spotId: spot.id,
      levelId: spot.levelId,
      entryTime: this.clock.now(),
      entryGateId,
      status: "ACTIVE",
    };

    // 3) Persist. If persistence fails, we must release the spot to avoid
    //    a phantom occupied state. This is a subtle cleanup path — tested
    //    aggressively in production.
    try {
      this.ticketStore.save(ticket);
    } catch (err) {
      this.releaseSpot(spot);
      throw err;
    }

    return ticket;
  }

  /**
   * The atomic inner step. Pulled out because testing concurrency is easier
   * when the critical section is a named method.
   */
  private claimSpotAtomically(vehicle: Vehicle, entryGateId: string): Spot | null {
    return withLock(this.lock, () => {
      const spot = this.assignment.pick(this, vehicle, entryGateId);
      if (!spot) return null;
      if (spot.state !== "AVAILABLE") {
        // Strategy returned a stale spot — treat as contention, caller retries
        // or surfaces lot-full.
        return null;
      }
      spot.transitionTo("OCCUPIED");
      this.levelById(spot.levelId).decrementAvailable(spot.type);
      return spot;
    });
  }

  /**
   * Exit flow. Pricing computation is pure and can happen outside the lock.
   * Only the spot release is locked.
   */
  exitVehicle(ticketId: string, payment: IPaymentMethod): Promise<Receipt> {
    const ticket = this.ticketStore.findById(ticketId);
    if (!ticket) throw new TicketNotFoundError(ticketId);
    if (ticket.status !== "ACTIVE") throw new TicketAlreadyUsedError(ticketId);

    const exitTime = this.clock.now();
    const amount = this.pricing.price(ticket, exitTime); // pure, lock-free

    return this.finalizeExit(ticket, exitTime, amount, payment);
  }

  private async finalizeExit(
    ticket: Ticket,
    exitTime: Date,
    amount: Money,
    payment: IPaymentMethod,
  ): Promise<Receipt> {
    const result = await payment.charge(amount, {
      ticketId: ticket.id,
      vehiclePlate: ticket.vehicle.plate,
    });
    if (!result.ok) {
      throw new PaymentFailedError(result.error ?? "unknown", ticket.id);
    }

    // Release spot + mark ticket paid atomically.
    withLock(this.lock, () => {
      const spot = this.spotById(ticket.spotId);
      spot.transitionTo("AVAILABLE");
      this.levelById(ticket.levelId).incrementAvailable(spot.type);
      ticket.status = "PAID";
    });
    this.ticketStore.save(ticket);

    return {
      ticketId: ticket.id,
      vehicle: ticket.vehicle,
      entryTime: ticket.entryTime,
      exitTime,
      amount,
      txnId: result.txnId!,
      paymentMethod: payment.name,
    };
  }

  availability(): AvailabilitySnapshot {
    // Cheap: each Level keeps running counters.
    return this.levels.map((l) => l.availabilitySnapshot());
  }

  private releaseSpot(spot: Spot): void {
    withLock(this.lock, () => {
      spot.transitionTo("AVAILABLE");
      this.levelById(spot.levelId).incrementAvailable(spot.type);
    });
  }

  private levelById(id: string): Level { /* ... */ return this.levels.find((l) => l.id === id)!; }
  private spotById(id: string): Spot { /* ... */ return this.levels.flatMap((l) => l.spots).find((s) => s.id === id)!; }
  private readonly lock = new Mutex();
}

Assignment strategies

typescript
class FirstFitAssignment implements IAssignmentStrategy {
  pick(lot: ParkingLot, vehicle: Vehicle, _entryGateId: string): Spot | null {
    const allowed = new Set(COMPATIBILITY[vehicle.type]);
    for (const level of lot.levelsView()) {
      for (const spot of level.spots) {
        if (spot.state !== "AVAILABLE") continue;
        if (!allowed.has(spot.type)) continue;
        if (spot.type === "HANDICAP" && !vehicle.hasPlacard) continue;
        return spot;
      }
    }
    return null;
  }
}

class SizeMatchAssignment implements IAssignmentStrategy {
  // Smallest compatible wins. Preserves larger spots for larger vehicles.
  private static readonly SIZE_ORDER: SpotType[] = [
    "COMPACT", "REGULAR", "LARGE", "HANDICAP", "EV_CHARGING",
  ];

  pick(lot: ParkingLot, vehicle: Vehicle, _entryGateId: string): Spot | null {
    const allowed = new Set(COMPATIBILITY[vehicle.type]);
    for (const type of SizeMatchAssignment.SIZE_ORDER) {
      if (!allowed.has(type)) continue;
      if (type === "HANDICAP" && !vehicle.hasPlacard) continue;
      for (const level of lot.levelsView()) {
        const spot = level.firstAvailableOfType(type);
        if (spot) return spot;
      }
    }
    return null;
  }
}

class NearestSpotAssignment implements IAssignmentStrategy {
  constructor(private readonly distance: (gateId: string, spot: Spot) => number) {}

  pick(lot: ParkingLot, vehicle: Vehicle, entryGateId: string): Spot | null {
    const allowed = new Set(COMPATIBILITY[vehicle.type]);
    let best: Spot | null = null;
    let bestD = Infinity;
    for (const level of lot.levelsView()) {
      for (const spot of level.spots) {
        if (spot.state !== "AVAILABLE") continue;
        if (!allowed.has(spot.type)) continue;
        if (spot.type === "HANDICAP" && !vehicle.hasPlacard) continue;
        const d = this.distance(entryGateId, spot);
        if (d < bestD) { bestD = d; best = spot; }
      }
    }
    return best;
  }
}

Pricing strategies

typescript
class HourlyPricing implements IPricingStrategy {
  constructor(private readonly rates: Record<SpotType, number>, private readonly currency = "USD") {}
  price(ticket: Ticket, exitTime: Date): Money {
    const hours = Math.ceil((exitTime.getTime() - ticket.entryTime.getTime()) / 3_600_000);
    const rate = this.rates[ticket.spotTypeUsed ?? "REGULAR"];
    return { amount: hours * rate, currency: this.currency };
  }
}

class FlatRatePricing implements IPricingStrategy {
  constructor(private readonly flatAmount: number, private readonly currency = "USD") {}
  price(_t: Ticket, _e: Date): Money {
    return { amount: this.flatAmount, currency: this.currency };
  }
}

class TieredPricing implements IPricingStrategy {
  // tiers: array of { upToHours, ratePerHour }; last tier applies to remainder.
  // dailyCap caps the daily charge (rolling 24h).
  constructor(
    private readonly tiers: Array<{ upToHours: number; ratePerHour: number }>,
    private readonly dailyCap: number,
    private readonly currency = "USD",
  ) {}
  price(ticket: Ticket, exitTime: Date): Money {
    let hours = Math.ceil((exitTime.getTime() - ticket.entryTime.getTime()) / 3_600_000);
    let total = 0;
    let prevBreakpoint = 0;
    for (const tier of this.tiers) {
      const hoursInTier = Math.min(hours, tier.upToHours - prevBreakpoint);
      if (hoursInTier <= 0) break;
      total += hoursInTier * tier.ratePerHour;
      hours -= hoursInTier;
      prevBreakpoint = tier.upToHours;
      if (hours === 0) break;
    }
    if (hours > 0) {
      const last = this.tiers[this.tiers.length - 1];
      total += hours * last.ratePerHour;
    }
    total = Math.min(total, this.dailyCap);
    return { amount: total, currency: this.currency };
  }
}

Design Decisions & Tradeoffs

Assignment: first-fit vs best-fit

CriterionFirstFitNearestSpotSizeMatch
Selection costO(N) worst caseO(N) alwaysO(T × L) where T = types, L = levels
UtilizationPoor (small vehicles take large spots)MiddlingBest
Customer UXRandomBest (short walk)Worst (small spot in back corner)
Revenue on busy daysWorst (lot fills too early)NeutralBest
When to pickEmpty lot, latency-sensitive gatesCustomer-facing flagship lotsUrban lots at capacity

The right answer in production is often a hybrid: SizeMatch during peak hours (>70% full) and NearestSpot below that, flipped by a config flag. This is exactly the reason the strategy is pluggable.

Pricing: Strategy vs enum with switch

Everyone reaches for switch (pricingType) { case HOURLY: ... }. It works for three pricing models. It breaks on the fourth. Every new model means a new case, and the pricing function accretes knowledge of every other model in the codebase.

Strategy pattern wins cleanly here for three reasons:

  1. Open/Closed. Adding SurgePricing creates one new class; no existing file is edited.
  2. Testability. Each strategy has its own unit tests. No shared state.
  3. Runtime config. The lot can swap strategies per shift, per event, per day-of-week. A switch hardcodes that decision at compile time.

The argument for a switch is "it's less code." In an interview setting, the interviewer explicitly wants to see you reach for the pattern that keeps the code closed to modification. This is table stakes at SDE2 and explicit at SDE3.

Ticket: in memory vs persisted

In-memory tickets lose everything on restart. That's fine in a homework demo, unacceptable in production — a customer paying a parking fee and then being told "your ticket doesn't exist" is a support call and a refund. Persist tickets in durable storage (Postgres, DynamoDB). Keep a hot cache for active tickets.

Ticket ID generation: UUID vs sequential per entry

SchemeProsCons
UUID v4No coordination, globally unique, safe across multi-gateLong, hard to type if customer needs to read it to agent
Sequential per gate (e.g., GATE3-000421)Short, human-friendly, monotone audit trail per gateNeeds gate-local counter (fine — no cross-gate coordination needed)
Global sequentialShortest, cleanest auditNeeds a distributed sequence (hotspot at scale)

The right answer is per-gate sequential with a gate prefix. Each gate owns its counter, no cross-gate coordination, humans can read the ticket over the phone. A UUID inside the system for referential integrity, printed ID on the ticket is the short form.

Singleton ParkingLot — why you usually shouldn't

The first instinct is to make ParkingLot a Singleton. Don't. Three specific reasons:

  1. Testability. A Singleton hides its dependencies. Every test either imports the global or doesn't run. Parallel tests corrupt each other. Mocking becomes gymnastic.
  2. Multi-lot future. The moment the company opens a second parking lot, the Singleton becomes a bug. You now have to refactor every call site that reached the global.
  3. It hides concurrency assumptions. A Singleton reads like "there is one lot everywhere," which subtly encourages callers to skip locking — "it's the only one, what's to race?" — exactly the bug we're designing against.

What to do instead: inject a ParkingLot into gates and services. Compose the lot at the application's entry point (a main() or a DI container). If the operator runs one lot today, they still get one instance — they just don't get a global.

If the interviewer insists on Singleton, the acceptable middle ground is a Registry keyed by lot ID, which at least admits the multi-lot future without rewriting call sites.


Patterns Used

PatternWhereWhy
StrategyPricingStrategy, AssignmentStrategy, PaymentMethodAlgorithms vary at runtime and evolve independently.
FactorySpotFactory.create(type), VehicleFactory.fromPlateScan(...)Centralizes construction rules and validation.
ObserverLevel-availability updates → entry-gate displays, full-lot notificationsDecouples state change from side effects.
StateSpot state machine (AVAILABLE/OCCUPIED/RESERVED/OUT_OF_SERVICE)Guards illegal transitions; makes the flow explicit.
CompositeParkingLotLevelSpotUniform traversal for availability and assignment.
FacadeParkingLot.parkVehicle / exitVehicleCallers don't touch levels or spots directly.
Command (optional, future)Exit flow with retryable paymentPayment retries become ChargeCommand objects in a queue.

The Strategy pattern is the load-bearing one. Every junior implementation has one pricing rule; every senior design has pricing as an interchangeable component.


Concurrency Considerations

The defining correctness case: two vehicles arrive at different gates, one spot remains. Exactly one must be issued a ticket; the other must be told the lot is full. Under no circumstances can both be issued a ticket for the same spot.

Options

StrategyHow it worksProsCons
Lot-wide lockSingle mutex around parkVehicle's critical sectionSimplest, correctSerializes all entries; throughput limited by critical-section length
Per-level lockOne mutex per level; lot-level counter optimisticGates on different levels don't contendCross-level strategies (NearestSpot) need multiple locks → deadlock risk
Per-spot optimistic (CAS)Assignment strategy picks a candidate; spot.compareAndSet(AVAILABLE, OCCUPIED) commitsHighest throughputStrategy must be re-runnable; "last spot" case still needs fallback
Assign-then-commit (reservation intent)Strategy returns a candidate, gate issues intent, barrier opens only after commit; intent expires on timeoutClean at high scaleMore states, more machinery
Queue + single assignerAll gates push "park" requests to a queue; one thread processesTrivially correctQueue becomes the bottleneck; gates block on response

For a 2000-spot lot with peak 20 entries/minute, a lot-wide lock around the critical section is correct and well within budget. Lock-held time is microseconds (a state transition and a counter decrement). The gate's actual latency budget is in the hundreds of milliseconds — dominated by ticket printing and persistence, not lock contention.

Scale up 10×-100× and the right move is per-spot CAS with the strategy re-run on conflict:

typescript
class OptimisticParkingLot {
  parkVehicle(vehicle: Vehicle, entryGateId: string): Ticket {
    for (let attempt = 0; attempt < 5; attempt++) {
      const candidate = this.assignment.pick(this, vehicle, entryGateId);
      if (!candidate) throw new LotFullError(vehicle.type, this.availability());
      // Atomic compare-and-set on the spot's state slot.
      if (candidate.tryClaim()) {
        this.levelById(candidate.levelId).decrementAvailable(candidate.type);
        return this.issueTicket(vehicle, candidate, entryGateId);
      }
      // Someone beat us. Retry — strategy will pick a different spot.
    }
    throw new LotFullError(vehicle.type, this.availability()); // contention fallback
  }
}

When to pick which:

  • Lot lock — single-process, single-lot, < 100 entries/second. Code is ~5 lines, correctness is obvious.
  • Per-spot CAS — multi-process or > 100 entries/second. Requires atomic storage (Redis SET NX, Postgres UPDATE ... WHERE state = 'AVAILABLE' with affected-row count, or in-JVM AtomicReference).
  • Queue+single-assigner — multi-region consistency or audit requirements ("every entry is replayable"). Throughput bounded by the assigner's single-threaded rate.

Entry gate is contended, exit gate is not

Entries race for the same pool of spots. Exits release to their own spot — the only "contention" is on the availability counter, which can be atomic or approximate. Pricing computation is stateless (pure function of ticket + exit time) and parallelizable.

This asymmetry matters: you can run many exit gates at once with minimal coordination. Entry gate design is where the concurrency work lives.

Failure modes the lock doesn't fix

  • Ticket persistence fails after spot is claimed. Spot is OCCUPIED with no ticket → phantom. Fix: compensating release in the catch block (shown in parkVehicle above).
  • Vehicle drives in, ticket printer jams. Spot claimed, no physical ticket. Fix: gate barrier opens only after print success; release spot on print failure.
  • Process crash between claim and persist. Spot is stuck OCCUPIED forever. Fix: on startup, reconcile spots with persisted active tickets — any OCCUPIED spot without a ticket gets released.

These are the SDE3-level failure modes. An SDE2 gets the lock right; an SDE3 walks through the crash-recovery story unprompted.


Scale & Extensibility

New spot type (EV charging with kW rating)

Add "EV_CHARGING" to SpotType (already there). Extend Spot with an optional chargerKw: number for EV spots, add a compatibility entry, and surface it in the assignment strategy if the vehicle requests charging. Pricing may bill separately for the charge (a second pricing strategy composed into the exitVehicle flow, or a ChargingSession domain object paid alongside the parking fee).

No existing class changes except where the enum is declared. This is the extension the Strategy/State pattern was paid for.

Reservation ahead of time

A Reservation is a hold on a future Spot for a time window. Spot gains RESERVED state (already modeled). The assignment strategy excludes RESERVED spots unless the arriving vehicle presents the matching reservation ID. A background sweeper expires unclaimed reservations after the hold window.

Implication: parkVehicle gains an optional reservationId parameter; the ticket links to it for audit.

Dynamic / surge pricing

SurgePricing implements IPricingStrategy. It takes a LoadReader dependency that returns current occupancy; it multiplies the base rate by a surge factor when occupancy > threshold. Zero changes to ParkingLot.

Multi-lot (find nearest available across lots)

A ParkingNetwork service owns many ParkingLot instances. findLot(vehicle, origin) ranks lots by distance and compatibility. This is where the Singleton anti-pattern would have killed you — with injection, ParkingNetwork just holds a Map<LotId, ParkingLot>.

IoT sensor integration

A per-spot occupancy sensor emits occupied | vacant events. Today we trust our software model. With sensors, we reconcile: if a sensor says OCCUPIED but our model says AVAILABLE, alarm and sync. This is an Observer over the spot state.

Add SpotStateObserver:

typescript
interface SpotStateObserver {
  onSpotStateChanged(spot: Spot, from: SpotState, to: SpotState): void;
}

The IoT integration is one observer. The availability display is another. The analytics pipeline is a third. None of them modify Spot or ParkingLot.

Monthly passes

A Subscription grants its holder free or discounted entry within a window. On entry, if the vehicle's plate matches an active subscription, the pricing strategy is temporarily overridden with FlatRatePricing(0) for the ticket. Implementation: a SubscriptionAwarePricing decorator around the base pricing strategy — the Decorator pattern, cleanly composable.

Valet mode

A valet takes the ticket, parks the car, and returns it on request. The domain changes: Spot isn't assigned at entry, the vehicle is queued for a valet, and the valet picks an assignment themselves. This is enough of a different flow that I'd model it as a separate ValetParkingLot that composes the regular lot for non-valet customers.

Scaling to 1M entries/day

1M entries/day is ~12/second average, with peaks perhaps 100/second. A well-designed single-process lot with per-spot CAS handles this. Beyond that:

  • Shard by lot. Each lot is its own process; a router sends gates to their lot.
  • Persistence behind a write-through cache. Redis for active tickets, Postgres for durable record. Reads hit Redis; writes go to both.
  • Async events for observers. Display updates, IoT reconciliation, analytics — all asynchronous via an event bus. The entry path stays synchronous and fast.

Edge Cases

CaseWhat must happen
Lot full for this vehicle typeReject at gate with specific reason ("Large spots full"). Display updates to reflect the compartmentalized shortage. Don't report "lot full" when compact is full but large is available.
Wrong-sized vehicle attempts a spotCaught at assignment time — the strategy will never return an incompatible spot. If a physical car is driven into a motorcycle spot despite not being assigned there, that's an operations problem, not a software one.
Ticket lost at exitVerify license plate against active tickets. If match, charge the flat max daily fee (or the base pricing, whichever is more permissive) and mark ticket LOST. If no match, escalate to attendant.
Vehicle not foundEither the ticket is fake/stale, or the plate in our system doesn't match what the driver provides. Attendant intervention. Don't let the barrier open.
Concurrent entry when 1 spot remainsExactly one vehicle gets the spot (lock / CAS). The other gets LotFullError. Both gates' displays update to reflect the new state.
Payment failure at exitTicket stays ACTIVE, spot stays OCCUPIED, barrier stays down. Offer retry or alternate payment. Never release on failed payment — that's free parking by accident.
Vehicle stays past max duration (abandoned)After N days without exit, mark ticket ABANDONED. Operations receives a report. Spot stays OCCUPIED until physically cleared (business decision, not software).
Clock skew across gatesAll gates pull entry/exit timestamps from a single source (the ParkingLot's clock) or a synchronized NTP source. Do not trust the gate's local clock for pricing.
Persistence fails after spot claimCompensating release; surface error to the gate; customer tries again. The spot must not stay claimed.
Re-entry with an active ticketA car physically can't re-enter without exiting (gate arms), but if the abstraction leaks (e.g., mobile pre-pay), reject the second entry with TicketAlreadyActive.

Follow-up Questions

  1. How would you add reservations? A Reservation owns a spot-hold for a time window; Spot gains the RESERVED state; assignment excludes RESERVED unless the arriving vehicle presents a matching reservation. A background sweeper expires unclaimed holds. What are the tradeoffs between strict spot-assignment-at-reservation-time versus type-only reservations (any LARGE spot)?

  2. How would you support multiple lots and route a driver to the nearest one with availability? A ParkingNetwork service. Rank by distance, filter by compatibility, pick the best. Discuss staleness of the availability signal across lots — is a 30-second-old count acceptable?

  3. How would you add surge pricing? A new SurgePricing strategy that reads current occupancy and applies a multiplier. Where do you read occupancy from — an in-memory counter per lot, or a central metric service? What if the strategy disagrees with the gate's view?

  4. How would you handle lost tickets? Verify plate against active tickets. Charge flat daily max. Discuss the fraud surface — someone claims "lost" to skip a long stay.

  5. How would you A/B test pricing strategies? Wrap IPricingStrategy in an experiment router keyed by ticket ID. Half the tickets see strategy A, half B. Persist assignment for audit and disputes. Crucially: no experiment changes pricing mid-stay — the strategy that priced the ticket at entry is the strategy that prices it at exit.

  6. How would you integrate with IoT sensors? SpotStateObserver over the spot state machine. Sensors emit sync/async events; the lot reconciles. On disagreement, source-of-truth question: software model, or physical sensor? Usually sensor wins but raises an alarm.

  7. How would you scale to 1M entries/day? Shard by lot, move to per-spot CAS, cache active tickets in Redis, persist to Postgres, async events for observers. Discuss where the bottleneck moves as you scale — printer throughput, database write fanout, barrier actuation.

  8. How would you roll out a new spot type? Data migration (existing Spot rows gain a new enum value), compatibility table update, assignment strategy may need a hint if the new type is preferred (e.g., EV-charging for EVs). Feature-flag the new type so the rollout is controlled.

  9. What if a vehicle's ticket is still active but the car physically left (tailgating)? This is where sensors + exit cameras catch it. Detect "empty spot with active ticket," alert operations. Software can't fix physical theft; it can surface it quickly.

  10. How would you handle cross-lot subscriptions? Subscription lives at the ParkingNetwork layer. The lookup happens at the gate before the lot-specific pricing. Discuss caching strategy — subscriptions change rarely, so a 5-minute TTL cache per gate is fine.


SDE2 vs SDE3 — How the Bar Rises

DimensionSDE2 barSDE3 bar
Assignment strategyPicks one (usually FirstFit), implements it, acknowledges others exist.Implements multiple, argues the tradeoffs by workload, proposes a hybrid with a runtime-config toggle, and knows which one maximizes which metric.
Pricing extensibilityStrategy pattern with two or three implementations. Open/Closed articulated.Adds surge and A/B testing as a strategy decorator. Discusses the "strategy at entry vs strategy at exit" correctness issue. Proposes a pricing registry with versioning for audit.
ConcurrencyLot-wide lock around the critical section. Correct, sufficient for the stated scale.Walks through lot-lock → per-level → per-spot CAS as a scaling ladder. Articulates the failure modes (persistence failure mid-claim, crash recovery) unprompted.
Singleton avoidanceKnows not to use Singleton when asked.Proactively calls it out as an anti-pattern, explains the testability and multi-lot consequences, and designs injection from the start.
Observability / metricsLogs entry and exit.Emits structured events for every spot transition, gate action, and payment attempt. Talks about alarms on anomalies (spots stuck OCCUPIED, repeated payment failures, sensor disagreement).
Failure modesHandles happy path, lot full, and payment failure.Enumerates: persistence fail after claim, crash mid-transaction, clock skew, printer jam, sensor/software disagreement, abandoned vehicles. Each has a named recovery path.
Data modelEntities as classes; compatibility in if/else.Compatibility as data; state machine enforced; strategies injected; dependencies composed at the entry point rather than looked up.
Scope disciplineCovers everything asked.Explicitly declares v1 scope, places reservations / valet / multi-lot into v2 with a one-line integration sketch, and moves on.

The interview signal for SDE3 isn't "more features." It's naming the failure modes before the interviewer asks, treating concurrency as a design concern rather than an implementation detail, and declining to take shortcuts (Singleton, enum-switch pricing, in-memory tickets) that will bite the next person who touches the codebase.

Frontend interview preparation reference.