All problems (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · Low Level Design · Model the Domain

Design a Parking Lot

BeginnerEntitiesEnumsStrategyPricing⏱ 1-2 h

The most-asked LLD warm-up: spots of different sizes, tickets, a pricing strategy you can swap, and the full class design with code.

Learn mode: the full solution is shown below.

Problem introduction

DifficultyBeginner
Time1–2 hours to design and code; about 45 minutes in an interview
PrerequisitesClasses and objects, interfaces, enums, a little about locks
ConceptsFinding entities, encapsulation, composition, Strategy, injecting a clock, a mutex around shared state
Real worldShopping mall and hospital parking, airport car parks, apps that show "12 spots free" at the gate

The parking lot is the most-asked LLD warm-up for a reason. Anyone can picture it, so the interview isn't about the domain. It's about whether you can turn a fuzzy story into a small set of classes with clear jobs, and whether your design survives the interviewer's next question: "what if we change the pricing?"

Problem statement

Think of the car park under a shopping mall in Dhaka. It has a few floors. Each floor has spots of different sizes: tight ones for motorbikes, normal ones for cars, wide ones for pickups and small trucks.

A car drives up to the gate. The system has to find it a spot, give the driver a ticket, and later, when the car leaves, charge for the time it stayed and free the spot for the next car.

What we are building: the software inside the gate machine. Not the barrier motor, not the camera: the part that decides who parks where and how much they pay.

Why it exists: without it, guards walk around looking for empty spots, drivers circle the floors, and the money at the exit depends on who is at the counter.

What it must do, in one line: given a vehicle, give it the best free spot and a ticket; given a ticket, charge the right fee and free the spot.

Requirements

Functional requirements

  1. The lot has one or more levels. Each level has spots of three sizes: small, medium and large.
  2. Three vehicle types: motorcycle (needs small), car (needs medium), truck (needs large). A vehicle can use a bigger spot than it needs, never a smaller one.
  3. Park: find a free spot for the vehicle and issue a ticket with an ID, the spot and the entry time. Prefer the lowest level, and on that level the smallest spot that fits.
  4. Leave: take a ticket, compute the fee, mark the ticket paid and free the spot.
  5. Pricing: per started hour, with a different rate per vehicle type, minimum one hour. The lot owner must be able to change the pricing rule (for example a flat fee on match days) without changing the parking code.
  6. Free count: show how many spots of each size are free, for the board at the gate.
  7. Reject bad input: a vehicle with no plate, a plate that is already inside, an unknown ticket, a ticket that was already paid.

Non-functional requirements

RequirementWhat it means here
Correctness under concurrencyA lot has several gates. Two cars arriving at the same moment must never get the same spot.
ExtensibilityNew pricing rules, new vehicle types and new spot rules should be additions, not rewrites.
TestabilityFees depend on time. Tests must check "two and a half hours later" without sleeping.
PerformanceA mall lot has a few hundred spots, so a linear scan per arrival is fine. We note where it stops being fine.
MaintainabilityEach class has one job you can say in a sentence.

Assumptions and out of scope

  • One building, in memory. No database, no network: that's the HLD version of this problem.
  • Payment always succeeds. Card, bKash and cash handling is a whole design of its own.
  • No reservations, no EV charging spots, no lost-ticket flow. We come back to these in the design review as "what if" questions.

What to hand in

  • A list of entities with one sentence each about their job.
  • A class diagram.
  • Code for Park and Leave, with the pricing rule swappable.
  • Tests that cover the fee rules and two gates racing for the last spot.
Hint 1: where do the nouns lead?

Underline the nouns in the requirements: lot, level, spot, vehicle, ticket, fee. Most become classes. "Fee" is the odd one out: it isn't a thing, it's a rule. Rules that change are a hint for an interface.

Hint 2: who decides where a car goes?

Not the spot, and not the vehicle. If Spot decided, every spot would need to know about every other spot. Put the decision in the one object that can see all levels.

Hint 3: "the smallest spot that fits"

If each level keeps its spots sorted smallest first, the first free spot that is big enough is automatically the best fit. No sorting at park time.

Hint 4: testing time

Don't call time.Now() deep inside the lot. Let the lot take a function that returns the time, and pass a fake one in tests.

Core entities

Read the requirements and pull out the nouns. Then ask of each one: does it hold state, does it have behaviour, and would the design be worse without it?

EntityJob in one sentenceHoldsDoes
VehicleTypeSays what kind of vehicle this isan enum valuemaps to the spot size it needs
VehicleThe thing at the gateplate, typenothing: it's a plain value
SpotSizeAn ordered size, so "bigger fits smaller" is one comparisonan enum value>= comparison
SpotOne painted rectangle on the floorID, level, size, who is in itknows if it's free
LevelOne floorits spots, smallest firstfinds the best free spot that fits
TicketThe driver's receiptID, plate, spot, entry, exit, fee, statusnothing: data the lot fills in
PricingStrategyThe rule that turns a stay into moneydepends on the ruleFee(vehicle, stay)
LotThe one entry point, and the only object that sees everythinglevels, tickets, who is parked where, the clock, the lockPark, Leave, Free

Two things are deliberately not classes:

  • Gate. In this scope a gate does nothing except call Lot.Park and Lot.Leave. A class that only forwards calls adds a layer without adding a decision. If gates later get their own behaviour (a camera that reads plates, a printer that can jam), that's when it earns a class.
  • Payment. Out of scope, and it would drag in a whole other design.

Relationships:

  • Lot owns its Levels, and each Level owns its Spots. This is composition: a spot doesn't exist outside its level, and a level doesn't exist outside its lot.
  • Lot creates Tickets and keeps the originals. Drivers only ever get copies.
  • Lot uses a PricingStrategy through an interface. It doesn't know or care which rule is behind it.
  • Vehicle is passed in and never stored as an object. The lot keeps the plate and the type, which is all it needs.

Diagrams

Class diagram

Vehicle+ Plate: string+ Type: VehicleTypeLot- levels: []*Level- tickets: map[string]*Ticket- parked: map[string]*Ticket- pricing: PricingStrategy- mu: sync.Mutex+ Park(v Vehicle) (Ticket, error)+ Leave(ticketID) (Ticket, error)+ Free() map[SpotSize]intTicket+ ID, Plate, SpotID: string+ Entry, Exit: time.Time+ Fee: int+ Status: TicketStatusLevel+ Number: int- spots: []*Spot- findSpot(need) *SpotSpot+ ID: string+ Size: SpotSize- plate: string- free() bool«interface»PricingStrategy+ Fee(v, stay) intHourlyPricing+ Rates: map[VehicleType]int+ Fee(v, stay) intFlatPricing+ Amount: int+ Fee(v, stay) intparksticketslevels1*spots1*pricingLot owns its levels, and each level owns its spots (◆ = owns)Park takes a Vehicle; Lot keeps every Ticket it hands outLot only knows the PricingStrategy interfaceHourlyPricing and FlatPricing implement it (dashed ▷)
Press play. First the levels and spots that Lot owns light up, then the Vehicle it parks and the Tickets it keeps, then the pricing interface it depends on, then the two strategies that implement it.

How to read it: each box is a class: its name, then its fields, then its methods. The left side is ownership: one Lot owns many Levels, and each Level owns many Spots. Lot also keeps every Ticket it hands out. On the right, Lot holds a PricingStrategy, which is an interface; HourlyPricing and FlatPricing are two answers to it. That one arrow is why you can switch to flat pricing on a match day without editing Lot.

Notation: + is public, - is private. A filled diamond ◆ means owns (the part lives and dies with its owner), a plain arrow means holds a reference to, a dashed arrow means uses, and a dashed arrow with a hollow triangle means implements this interface.

Sequence: a car parks

CAR-CLotLevel 1Level 2Park(CAR-C, car)lock, check platefindSpot(medium)nil: nothing big enoughfindSpot(medium)spot S1mark S1, ticket T0005Ticket T0005 (L2-S1)
CAR-C arrives. Level 1 has nothing big enough left, Level 2 does, so the ticket says L2-S1.

How to read it: each vertical dashed line is one object, and time runs downwards. A solid arrow is a call, a dashed arrow is the answer coming back, and the thin bars show when an object is busy. CAR-C calls Park on the Lot. The lot asks each level in order. Level 1 answers "nil": its medium spots are taken, and its large one too. Level 2 has S1 free. The lot marks the spot, makes ticket T0005 and hands a copy back.

The failure path: if every level answers nil, the lot returns ErrLotFull and no ticket exists. Nothing is half-done, because the spot is only marked once one is found.

State: a ticket's life

Park()Activespot is takenPaidfee set, spot freeT0002Leave(): 150 takaLeave() againErrTicketPaid: no going back
A ticket starts Active, becomes Paid on Leave, and a second Leave on the same ticket is refused.

How to read it: a ticket has only two states. Park creates it as Active (its spot is taken). Leave moves it to Paid, sets the fee and frees the spot. There is no arrow back. Trying Leave on a paid ticket returns ErrTicketPaid, which is what stops a buggy exit gate from freeing someone else's spot twice.

Concurrency: two gates, one spot

Gate ACAR-XGate BCAR-YLot.muone lockL1-S1the last spotCAR-XCAR-Ywaits for the locklot fullwithout the lock, both gates could see L1-S1 as free
Gate A and Gate B both want the last spot. The lock lets one in at a time, so B finds the lot full instead of sharing L1-S1.

How to read it: both gates call Park at the same moment for the last free spot. Gate A gets the lock first, takes L1-S1, and releases the lock. Gate B was waiting; when it gets in, there is no spot left, so it gets "lot full". Without the lock, both could read "L1-S1 is free" before either wrote "taken", and two cars would be sent to one rectangle. The test in the Testing section shows this really happens.

As requirements grow: with one lock, gates take turns for the whole Park call. For a few hundred spots that takes microseconds and nobody notices. For a huge lot, you would lock per level instead. The review section covers this.

Design principles

Only the principles that actually shaped this design:

  • Single responsibility. Spot knows if it's free. Level finds a spot on one floor. Lot coordinates. PricingStrategy prices. When the pricing rule changes, exactly one type changes.
  • Encapsulation. A spot's plate field is private (lowercase in Go). Nothing outside the package can mark a spot taken without going through Lot.Park, so the lock can't be skipped. Park and Leave return copies of the ticket, so a caller who sets Status = Paid on its copy changes nothing.
  • Open/closed. The lot is closed for modification but open for new pricing: add a type with a Fee method and pass it in.
  • Dependency inversion. Lot depends on the PricingStrategy interface and on a now func() time.Time, not on a concrete pricing type or on the system clock. That's what makes it testable.
  • Composition over inheritance. There is no Spot base class with SmallSpot, MediumSpot and LargeSpot subclasses. Sizes differ only in data, not behaviour, so a Size field is enough. Subclasses here would add three classes and zero decisions.
  • KISS. No Gate, no Floor interface, no spot-finder strategy. Each of those is a reasonable later step, and none is needed for the requirements we have.

Design patterns

Strategy: for pricing

  1. Why it's needed: the owner wants to change how fees are computed (hourly, flat, night rate, weekend rate) without touching the parking logic.
  2. What it solves: it moves "how much" out of Lot and behind an interface with one method, Fee(vehicle, stay).
  3. Without it: Leave grows an if isMatchDay { ... } else if isNight { ... } chain. Every new rule edits Lot, which risks breaking parking to change pricing.
  4. Trade-offs: one more type and one indirection. If pricing were truly fixed forever, a plain function would do. Here the requirement says "change without changing the parking code", so the interface pays for itself.
  5. Where it is: pricing.go defines PricingStrategy, HourlyPricing and FlatPricing. Lot.Leave calls l.pricing.Fee(...). The test TestSwapPricing builds a lot with FlatPricing and nothing else changes.

Functional options: for the clock

NewLot(levels, pricing, WithClock(fake)) is Go's usual way to give optional settings without a constructor with eight parameters. In Java you'd use a builder or an overloaded constructor; in Python a keyword argument with a default. The pattern matters less than the idea: time is a dependency, so inject it.

Patterns we did not use, and why

  • Singleton for the lot. Tempting ("there is only one lot"), but it makes tests share state and makes a second building impossible. Create one Lot in main and pass it around.
  • Factory for vehicles. A vehicle is two fields. Vehicle{Plate: "A", Type: Car} is already as clear as it gets.
  • State pattern for tickets. Two states and one transition. An enum and one if say it more plainly. The Vending Machine is where State earns its place.

Complete solution in Go

Project structure

parking/
├── go.mod
├── vehicle.go      VehicleType, Vehicle
├── spot.go         SpotSize, Spot, sizeFor
├── level.go        Level and best-fit search
├── ticket.go       Ticket, TicketStatus
├── pricing.go      PricingStrategy, HourlyPricing, FlatPricing
├── lot.go          Lot: Park, Leave, Free, the lock and the errors
├── lot_test.go     tests, including the concurrency test
└── cmd/demo/
    └── main.go     a morning at the lot

Step 1: vehicles and sizes

Start with the vocabulary. SpotSize is an ordered enum, so "does this spot fit?" is just spot.Size >= need.

// vehicle.go
package parking

// VehicleType is what drives in. It decides which spots fit and what the hour costs.
type VehicleType int

const (
	Motorcycle VehicleType = iota
	Car
	Truck
)

func (v VehicleType) String() string {
	return [...]string{"motorcycle", "car", "truck"}[v]
}

// Vehicle is a value: two vehicles with the same plate and type are the same vehicle.
type Vehicle struct {
	Plate string
	Type  VehicleType
}
// spot.go
package parking

// SpotSize is ordered: a bigger spot can always take a smaller vehicle.
type SpotSize int

const (
	Small SpotSize = iota
	Medium
	Large
)

func (s SpotSize) String() string {
	return [...]string{"small", "medium", "large"}[s]
}

// sizeFor is the smallest spot a vehicle type fits in.
func sizeFor(t VehicleType) SpotSize {
	switch t {
	case Motorcycle:
		return Small
	case Car:
		return Medium
	default:
		return Large
	}
}

// Spot knows only itself: its size and who is in it. The lot decides who goes where.
type Spot struct {
	ID    string
	Level int
	Size  SpotSize
	plate string // "" means free
}

func (s *Spot) free() bool { return s.plate == "" }

Step 2: a level that finds the best spot

The trick is in the constructor: spots are added smallest first. So the first free spot that's big enough is also the smallest one that fits. No sorting at park time, and no separate "best fit" logic.

// level.go
package parking

import "fmt"

// Level is one floor. Its spots are kept smallest first, so the first free spot that
// fits is also the best fit: a motorcycle never takes a truck's spot while a small one
// is free.
type Level struct {
	Number int
	spots  []*Spot
}

func NewLevel(number, small, medium, large int) *Level {
	l := &Level{Number: number}
	n := 0
	add := func(count int, size SpotSize) {
		for range count {
			n++
			l.spots = append(l.spots, &Spot{ID: fmt.Sprintf("L%d-S%d", number, n), Level: number, Size: size})
		}
	}
	add(small, Small)
	add(medium, Medium)
	add(large, Large)
	return l
}

func (l *Level) findSpot(need SpotSize) *Spot {
	for _, s := range l.spots {
		if s.free() && s.Size >= need {
			return s
		}
	}
	return nil
}

Step 3: the ticket and the pricing rule

// ticket.go
package parking

import "time"

type TicketStatus int

const (
	Active TicketStatus = iota // car is inside
	Paid                       // car has left; the ticket is closed for good
)

// Ticket is the receipt the driver holds. Callers get copies, so nobody outside the lot
// can flip a ticket to Paid without going through Leave.
type Ticket struct {
	ID      string
	Plate   string
	Vehicle VehicleType
	SpotID  string
	Entry   time.Time
	Exit    time.Time
	Fee     int // taka
	Status  TicketStatus
}

HourlyPricing rounds up with integer arithmetic: adding time.Hour - 1 before dividing turns "1 hour 1 minute" into 2 and leaves exactly 1 hour as 1. Floats would work too, but integer maths can't surprise you with 0.9999.

// pricing.go
package parking

import "time"

// PricingStrategy turns a stay into money. The lot doesn't know how; it just asks.
type PricingStrategy interface {
	Fee(v VehicleType, stay time.Duration) int
}

// HourlyPricing charges per started hour, at least one hour.
type HourlyPricing struct {
	Rates map[VehicleType]int // taka per hour
}

func (p HourlyPricing) Fee(v VehicleType, stay time.Duration) int {
	hours := int((stay + time.Hour - 1) / time.Hour) // round up
	if hours < 1 {
		hours = 1
	}
	return hours * p.Rates[v]
}

// FlatPricing charges one amount per visit, e.g. on a match day.
type FlatPricing struct {
	Amount int
}

func (p FlatPricing) Fee(VehicleType, time.Duration) int { return p.Amount }

Step 4: the lot, where the decisions live

Everything that changes (which spot holds which plate, which tickets exist) is touched only while mu is held. Park checks the plate first, then walks the levels in order, so "lowest level first" falls out of the loop for free.

// lot.go
package parking

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrInvalidVehicle = errors.New("vehicle needs a plate")
	ErrLotFull        = errors.New("no free spot for this vehicle")
	ErrAlreadyParked  = errors.New("vehicle is already parked")
	ErrUnknownTicket  = errors.New("unknown ticket")
	ErrTicketPaid     = errors.New("ticket already paid")
)

// Lot is the one entry point: Park, Leave, Free. One mutex guards every spot and ticket,
// so two gates can never hand out the same spot.
type Lot struct {
	mu      sync.Mutex
	levels  []*Level
	pricing PricingStrategy
	now     func() time.Time
	spots   map[string]*Spot   // spot ID → spot
	tickets map[string]*Ticket // ticket ID → ticket
	parked  map[string]*Ticket // plate → active ticket
	nextID  int
}

type Option func(*Lot)

// WithClock swaps the clock, so tests can say "two and a half hours later" instantly.
func WithClock(now func() time.Time) Option {
	return func(l *Lot) { l.now = now }
}

func NewLot(levels []*Level, pricing PricingStrategy, opts ...Option) *Lot {
	l := &Lot{
		levels:  levels,
		pricing: pricing,
		now:     time.Now,
		spots:   map[string]*Spot{},
		tickets: map[string]*Ticket{},
		parked:  map[string]*Ticket{},
	}
	for _, lv := range levels {
		for _, s := range lv.spots {
			l.spots[s.ID] = s
		}
	}
	for _, o := range opts {
		o(l)
	}
	return l
}

// Park finds the best spot on the lowest level that has one and issues a ticket.
func (l *Lot) Park(v Vehicle) (Ticket, error) {
	if v.Plate == "" {
		return Ticket{}, ErrInvalidVehicle
	}
	l.mu.Lock()
	defer l.mu.Unlock()

	if _, ok := l.parked[v.Plate]; ok {
		return Ticket{}, ErrAlreadyParked
	}
	need := sizeFor(v.Type)
	for _, lv := range l.levels {
		s := lv.findSpot(need)
		if s == nil {
			continue
		}
		s.plate = v.Plate
		l.nextID++
		t := &Ticket{
			ID:      fmt.Sprintf("T%04d", l.nextID),
			Plate:   v.Plate,
			Vehicle: v.Type,
			SpotID:  s.ID,
			Entry:   l.now(),
			Status:  Active,
		}
		l.tickets[t.ID] = t
		l.parked[v.Plate] = t
		return *t, nil
	}
	return Ticket{}, ErrLotFull
}

// Leave closes the ticket, charges for the stay and frees the spot.
func (l *Lot) Leave(ticketID string) (Ticket, error) {
	l.mu.Lock()
	defer l.mu.Unlock()

	t, ok := l.tickets[ticketID]
	if !ok {
		return Ticket{}, ErrUnknownTicket
	}
	if t.Status == Paid {
		return Ticket{}, ErrTicketPaid
	}
	t.Exit = l.now()
	t.Fee = l.pricing.Fee(t.Vehicle, t.Exit.Sub(t.Entry))
	t.Status = Paid
	l.spots[t.SpotID].plate = ""
	delete(l.parked, t.Plate)
	return *t, nil
}

// Free counts free spots by size, for the board at the gate.
func (l *Lot) Free() map[SpotSize]int {
	l.mu.Lock()
	defer l.mu.Unlock()
	free := map[SpotSize]int{}
	for _, s := range l.spots {
		if s.free() {
			free[s.Size]++
		}
	}
	return free
}

Step 5: run it

The demo opens a lot with 6 spots on two levels and sends 7 vehicles in. The clock is a variable, so "two and a half hours later" is one line.

// cmd/demo/main.go
package main

import (
	"fmt"
	"time"

	"parking"
)

func main() {
	clock := time.Date(2026, 11, 7, 9, 0, 0, 0, time.UTC)
	lot := parking.NewLot(
		[]*parking.Level{
			parking.NewLevel(1, 1, 2, 1), // 1 small, 2 medium, 1 large
			parking.NewLevel(2, 0, 1, 1),
		},
		parking.HourlyPricing{Rates: map[parking.VehicleType]int{
			parking.Motorcycle: 20, parking.Car: 50, parking.Truck: 100,
		}},
		parking.WithClock(func() time.Time { return clock }),
	)

	arrivals := []parking.Vehicle{
		{Plate: "BIKE-1", Type: parking.Motorcycle},
		{Plate: "CAR-A", Type: parking.Car},
		{Plate: "CAR-B", Type: parking.Car},
		{Plate: "TRUCK-1", Type: parking.Truck},
		{Plate: "CAR-C", Type: parking.Car},
		{Plate: "CAR-D", Type: parking.Car},
		{Plate: "TRUCK-2", Type: parking.Truck},
	}
	tickets := map[string]string{}
	for _, v := range arrivals {
		t, err := lot.Park(v)
		if err != nil {
			fmt.Printf("%-8s %-10s -> %v\n", v.Plate, v.Type, err)
			continue
		}
		tickets[v.Plate] = t.ID
		fmt.Printf("%-8s %-10s -> %s (ticket %s)\n", v.Plate, v.Type, t.SpotID, t.ID)
	}

	clock = clock.Add(2*time.Hour + 30*time.Minute)
	t, _ := lot.Leave(tickets["CAR-A"])
	fmt.Printf("CAR-A left after %v, fee %d taka\n", t.Exit.Sub(t.Entry), t.Fee)

	if _, err := lot.Leave(tickets["CAR-A"]); err != nil {
		fmt.Println("CAR-A again:", err)
	}

	// A medium spot is free now, but a truck needs a large one, and CAR-D is in it.
	if _, err := lot.Park(parking.Vehicle{Plate: "TRUCK-2", Type: parking.Truck}); err != nil {
		fmt.Println("TRUCK-2 retry ->", err)
	}

	free := lot.Free()
	fmt.Printf("free: small=%d medium=%d large=%d\n", free[parking.Small], free[parking.Medium], free[parking.Large])
}
$ go run ./cmd/demo
BIKE-1   motorcycle -> L1-S1 (ticket T0001)
CAR-A    car        -> L1-S2 (ticket T0002)
CAR-B    car        -> L1-S3 (ticket T0003)
TRUCK-1  truck      -> L1-S4 (ticket T0004)
CAR-C    car        -> L2-S1 (ticket T0005)
CAR-D    car        -> L2-S2 (ticket T0006)
TRUCK-2  truck      -> no free spot for this vehicle
CAR-A left after 2h30m0s, fee 150 taka
CAR-A again: ticket already paid
TRUCK-2 retry -> no free spot for this vehicle
free: small=0 medium=1 large=0

Reading the output:

  • BIKE-1 takes the small spot L1-S1, the only one built for it.
  • CAR-A and CAR-B fill Level 1's medium spots. TRUCK-1 takes the large one.
  • CAR-C goes to Level 2, because nothing on Level 1 fits a car any more.
  • CAR-D finds Level 2's medium spot taken, so it takes the large spot L2-S2. That's allowed: a car fits in a truck's spot.
  • TRUCK-2 is turned away: no large spot is left anywhere.
  • CAR-A leaves after 2h30m and pays for 3 started hours: 3 × 50 = 150 taka. A second Leave on the same ticket is refused.
  • TRUCK-2 tries again. A medium spot is free now, but a truck needs a large one, and CAR-D is in it. Look at that line in the design review: it's the best "what if" in this problem.

The other five languages

Same design, same demo, same output, written the way each language likes. Every program below was run, and its output matched the Go output line for line.

from __future__ import annotations

import threading
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from enum import IntEnum
from typing import Callable, Protocol


class VehicleType(IntEnum):
    MOTORCYCLE = 0
    CAR = 1
    TRUCK = 2


class SpotSize(IntEnum):  # ordered: a bigger spot takes a smaller vehicle
    SMALL = 0
    MEDIUM = 1
    LARGE = 2


SIZE_FOR = {VehicleType.MOTORCYCLE: SpotSize.SMALL, VehicleType.CAR: SpotSize.MEDIUM,
            VehicleType.TRUCK: SpotSize.LARGE}


@dataclass(frozen=True)
class Vehicle:
    plate: str
    type: VehicleType


@dataclass
class Spot:
    id: str
    size: SpotSize
    plate: str = ""  # "" means free


@dataclass(frozen=True)  # frozen: callers can't flip a ticket to paid
class Ticket:
    id: str
    plate: str
    vehicle: VehicleType
    spot_id: str
    entry: datetime
    exit: datetime | None = None
    fee: int = 0
    paid: bool = False


class LotFull(Exception): ...
class AlreadyParked(Exception): ...
class UnknownTicket(Exception): ...
class TicketPaid(Exception): ...


class PricingStrategy(Protocol):
    def fee(self, v: VehicleType, stay: timedelta) -> int: ...


class HourlyPricing:
    def __init__(self, rates: dict[VehicleType, int]):
        self.rates = rates

    def fee(self, v: VehicleType, stay: timedelta) -> int:
        hours = max(1, -(-int(stay.total_seconds()) // 3600))  # round up, min 1
        return hours * self.rates[v]


class Level:
    def __init__(self, number: int, small: int, medium: int, large: int):
        sizes = [SpotSize.SMALL] * small + [SpotSize.MEDIUM] * medium + [SpotSize.LARGE] * large
        self.spots = [Spot(f"L{number}-S{i + 1}", s) for i, s in enumerate(sizes)]

    def find_spot(self, need: SpotSize) -> Spot | None:
        return next((s for s in self.spots if not s.plate and s.size >= need), None)


class Lot:
    def __init__(self, levels: list[Level], pricing: PricingStrategy,
                 now: Callable[[], datetime] = datetime.now):
        self._lock = threading.Lock()
        self._levels, self._pricing, self._now = levels, pricing, now
        self._spots = {s.id: s for lv in levels for s in lv.spots}
        self._tickets: dict[str, Ticket] = {}
        self._parked: dict[str, str] = {}  # plate -> ticket id
        self._next = 0

    def park(self, v: Vehicle) -> Ticket:
        if not v.plate:
            raise ValueError("vehicle needs a plate")
        with self._lock:
            if v.plate in self._parked:
                raise AlreadyParked("vehicle is already parked")
            for lv in self._levels:
                spot = lv.find_spot(SIZE_FOR[v.type])
                if spot:
                    spot.plate = v.plate
                    self._next += 1
                    t = Ticket(f"T{self._next:04d}", v.plate, v.type, spot.id, self._now())
                    self._tickets[t.id] = t
                    self._parked[v.plate] = t.id
                    return t
            raise LotFull("no free spot for this vehicle")

    def leave(self, ticket_id: str) -> Ticket:
        with self._lock:
            t = self._tickets.get(ticket_id)
            if t is None:
                raise UnknownTicket("unknown ticket")
            if t.paid:
                raise TicketPaid("ticket already paid")
            exit_at = self._now()
            t = replace(t, exit=exit_at, fee=self._pricing.fee(t.vehicle, exit_at - t.entry), paid=True)
            self._tickets[t.id] = t
            self._spots[t.spot_id].plate = ""
            del self._parked[t.plate]
            return t

    def free(self) -> dict[SpotSize, int]:
        with self._lock:
            out = {s: 0 for s in SpotSize}
            for s in self._spots.values():
                if not s.plate:
                    out[s.size] += 1
            return out


if __name__ == "__main__":
    clock = [datetime(2026, 11, 7, 9, 0)]
    lot = Lot([Level(1, 1, 2, 1), Level(2, 0, 1, 1)],
              HourlyPricing({VehicleType.MOTORCYCLE: 20, VehicleType.CAR: 50, VehicleType.TRUCK: 100}),
              now=lambda: clock[0])
    M, C, T = VehicleType.MOTORCYCLE, VehicleType.CAR, VehicleType.TRUCK
    tickets = {}
    for v in [Vehicle("BIKE-1", M), Vehicle("CAR-A", C), Vehicle("CAR-B", C), Vehicle("TRUCK-1", T),
              Vehicle("CAR-C", C), Vehicle("CAR-D", C), Vehicle("TRUCK-2", T)]:
        name = v.type.name.lower()
        try:
            t = lot.park(v)
            tickets[v.plate] = t.id
            print(f"{v.plate:<8} {name:<10} -> {t.spot_id} (ticket {t.id})")
        except LotFull as e:
            print(f"{v.plate:<8} {name:<10} -> {e}")

    clock[0] += timedelta(hours=2, minutes=30)
    t = lot.leave(tickets["CAR-A"])
    h, m = divmod(int((t.exit - t.entry).total_seconds()) // 60, 60)
    print(f"CAR-A left after {h}h{m}m0s, fee {t.fee} taka")
    try:
        lot.leave(tickets["CAR-A"])
    except TicketPaid as e:
        print("CAR-A again:", e)
    try:
        lot.park(Vehicle("TRUCK-2", T))
    except LotFull as e:
        print("TRUCK-2 retry ->", e)
    f = lot.free()
    print(f"free: small={f[SpotSize.SMALL]} medium={f[SpotSize.MEDIUM]} large={f[SpotSize.LARGE]}")

How to run each one: python3 parking.py, node parking.ts (Node 23+ runs TypeScript directly), rustc --edition 2021 parking.rs && ./parking, javac Parking.java && java Parking (Java 21), g++ -std=c++20 parking.cpp -o parking && ./parking.

What changes from language to language, and why:

How "pricing is swappable" is writtenHow the ticket is kept safeHow the lock works
Goan interface, satisfied implicitlyreturn a struct value, so the caller gets a copysync.Mutex + defer Unlock()
Pythona Protocol: any object with a fee method fits@dataclass(frozen=True); replace() makes the paid copythreading.Lock with with
TypeScriptan interfacereadonly fields; Leave returns a new objectno lock: JS runs this on one thread, and park has no await, so nothing can cut in halfway
Rusta trait object Box<dyn PricingStrategy>, marked Send + Syncreturn a clone(); nobody gets a reference into the lotall changing state lives inside the Mutex, so the compiler won't let you touch a spot without the lock
Javaan interfacea record is immutable; Leave builds a new onesynchronized methods share the object's lock
C++an abstract class with a pure virtual feereturn by valuestd::lock_guard (RAII): unlocked on every return and every throw

The Rust row is worth a second look. In Go, Java and C++ the lock and the data it protects are two separate fields, and only discipline keeps them together. In Rust the data is inside the mutex, so forgetting the lock is a compile error, not a bug you find in production.

Testing

// lot_test.go
package parking

import (
	"errors"
	"fmt"
	"sync"
	"testing"
	"time"
)

var rates = HourlyPricing{Rates: map[VehicleType]int{Motorcycle: 20, Car: 50, Truck: 100}}

// fakeClock is a clock the test moves by hand.
type fakeClock struct{ t time.Time }

func (c *fakeClock) now() time.Time          { return c.t }
func (c *fakeClock) add(d time.Duration)     { c.t = c.t.Add(d) }
func newClock() *fakeClock                   { return &fakeClock{time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)} }
func newLot(c *fakeClock, lv ...*Level) *Lot { return NewLot(lv, rates, WithClock(c.now)) }

func TestBestFitSpot(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 1, 1, 1))
	cases := []struct {
		v    Vehicle
		spot string
	}{
		{Vehicle{"B1", Motorcycle}, "L1-S1"}, // small, its own size
		{Vehicle{"B2", Motorcycle}, "L1-S2"}, // small is taken: medium is next best
		{Vehicle{"C1", Car}, "L1-S3"},        // only large is left, a car still fits
	}
	for _, c := range cases {
		tk, err := lot.Park(c.v)
		if err != nil || tk.SpotID != c.spot {
			t.Fatalf("%s: got %q, %v; want %s", c.v.Plate, tk.SpotID, err, c.spot)
		}
	}
}

func TestLowerLevelFirst(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 0, 1, 0), NewLevel(2, 0, 1, 0))
	a, _ := lot.Park(Vehicle{"A", Car})
	b, _ := lot.Park(Vehicle{"B", Car})
	if a.SpotID != "L1-S1" || b.SpotID != "L2-S1" {
		t.Fatalf("got %s, %s", a.SpotID, b.SpotID)
	}
}

func TestTruckNeedsLarge(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 3, 3, 0))
	if _, err := lot.Park(Vehicle{"T", Truck}); !errors.Is(err, ErrLotFull) {
		t.Fatalf("want ErrLotFull, got %v", err)
	}
}

func TestInvalidInput(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 1, 1, 1))
	if _, err := lot.Park(Vehicle{"", Car}); !errors.Is(err, ErrInvalidVehicle) {
		t.Fatalf("empty plate: %v", err)
	}
	if _, err := lot.Leave("T9999"); !errors.Is(err, ErrUnknownTicket) {
		t.Fatalf("unknown ticket: %v", err)
	}
}

func TestSamePlateTwice(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 0, 2, 0))
	lot.Park(Vehicle{"A", Car})
	if _, err := lot.Park(Vehicle{"A", Car}); !errors.Is(err, ErrAlreadyParked) {
		t.Fatalf("want ErrAlreadyParked, got %v", err)
	}
}

func TestFeeRoundsUpHours(t *testing.T) {
	cases := []struct {
		stay time.Duration
		fee  int
	}{
		{0, 50},                        // minimum one hour
		{time.Hour, 50},                // exactly one hour
		{time.Hour + time.Minute, 100}, // a started hour counts
		{2*time.Hour + 30*time.Minute, 150},
	}
	for _, c := range cases {
		clk := newClock()
		lot := newLot(clk, NewLevel(1, 0, 1, 0))
		tk, _ := lot.Park(Vehicle{"A", Car})
		clk.add(c.stay)
		out, err := lot.Leave(tk.ID)
		if err != nil || out.Fee != c.fee {
			t.Errorf("stay %v: fee %d, %v; want %d", c.stay, out.Fee, err, c.fee)
		}
	}
}

func TestLeaveFreesSpotOnce(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 0, 1, 0))
	tk, _ := lot.Park(Vehicle{"A", Car})
	if _, err := lot.Leave(tk.ID); err != nil {
		t.Fatal(err)
	}
	if _, err := lot.Leave(tk.ID); !errors.Is(err, ErrTicketPaid) {
		t.Fatalf("second leave: %v", err)
	}
	if again, err := lot.Park(Vehicle{"B", Car}); err != nil || again.SpotID != "L1-S1" {
		t.Fatalf("spot not reused: %q, %v", again.SpotID, err)
	}
}

func TestReturnedTicketIsACopy(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 0, 1, 0))
	tk, _ := lot.Park(Vehicle{"A", Car})
	tk.Status = Paid // the caller scribbles on its copy
	if _, err := lot.Leave(tk.ID); err != nil {
		t.Fatalf("lot's ticket changed from outside: %v", err)
	}
}

func TestSwapPricing(t *testing.T) {
	clk := newClock()
	lot := NewLot([]*Level{NewLevel(1, 0, 1, 0)}, FlatPricing{Amount: 200}, WithClock(clk.now))
	tk, _ := lot.Park(Vehicle{"A", Car})
	clk.add(5 * time.Hour)
	if out, _ := lot.Leave(tk.ID); out.Fee != 200 {
		t.Fatalf("flat fee: %d", out.Fee)
	}
}

// 100 cars race for 50 spots through many gates at once: exactly 50 get in, and no spot
// is handed out twice. Run with -race.
func TestConcurrentGates(t *testing.T) {
	lot := newLot(newClock(), NewLevel(1, 0, 25, 0), NewLevel(2, 0, 25, 0))
	var wg sync.WaitGroup
	var mu sync.Mutex
	taken := map[string]string{}
	full := 0
	for i := range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			tk, err := lot.Park(Vehicle{fmt.Sprintf("CAR-%d", i), Car})
			mu.Lock()
			defer mu.Unlock()
			if errors.Is(err, ErrLotFull) {
				full++
				return
			}
			if other, dup := taken[tk.SpotID]; dup {
				t.Errorf("%s given to %s and %s", tk.SpotID, other, tk.Plate)
			}
			taken[tk.SpotID] = tk.Plate
		}()
	}
	wg.Wait()
	if len(taken) != 50 || full != 50 {
		t.Fatalf("parked %d, turned away %d", len(taken), full)
	}
}
$ go test -race -v ./...
=== RUN   TestBestFitSpot
--- PASS: TestBestFitSpot (0.00s)
=== RUN   TestLowerLevelFirst
--- PASS: TestLowerLevelFirst (0.00s)
=== RUN   TestTruckNeedsLarge
--- PASS: TestTruckNeedsLarge (0.00s)
=== RUN   TestInvalidInput
--- PASS: TestInvalidInput (0.00s)
=== RUN   TestSamePlateTwice
--- PASS: TestSamePlateTwice (0.00s)
=== RUN   TestFeeRoundsUpHours
--- PASS: TestFeeRoundsUpHours (0.00s)
=== RUN   TestLeaveFreesSpotOnce
--- PASS: TestLeaveFreesSpotOnce (0.00s)
=== RUN   TestReturnedTicketIsACopy
--- PASS: TestReturnedTicketIsACopy (0.00s)
=== RUN   TestSwapPricing
--- PASS: TestSwapPricing (0.00s)
=== RUN   TestConcurrentGates
--- PASS: TestConcurrentGates (0.00s)
PASS
ok  	parking	1.012s
?   	parking/cmd/demo	[no test files]

What each test proves:

TestChecks
TestBestFitSpotSmallest fitting spot first, then the next size up when that's taken
TestLowerLevelFirstLevel 1 fills before Level 2
TestTruckNeedsLargeA vehicle never gets a smaller spot, even when many small ones are free
TestInvalidInputEmpty plate and unknown ticket are rejected with the right error
TestSamePlateTwiceOne plate can't hold two spots
TestFeeRoundsUpHoursBoundaries: 0 minutes, exactly 1 hour, 1 hour 1 minute, 2h30m
TestLeaveFreesSpotOnceLeave frees the spot, a second Leave is refused, and the spot is reused
TestReturnedTicketIsACopyChanging the returned ticket doesn't change the lot's ticket
TestSwapPricingA different strategy works with no change to Lot
TestConcurrentGates100 goroutines, 50 spots: exactly 50 park and no spot is given twice

Does the concurrency test actually catch anything? We checked: delete the two lock lines from Park and run it again.

$ go test -race -run Concurrent .
WARNING: DATA RACE
...
FAIL	parking	0.015s

The race detector reported data races on every run (5 to 8 in our runs) and the test failed. Put the lock back and it passes. A test that passes with and without the fix proves nothing, so it's worth checking this once.

Design review

Reviewing this as if a teammate sent it for review:

What's good

  • Each type has one job, and the only place with decisions is Lot.
  • Swapping the pricing rule or the clock needs zero edits to Lot.
  • The lock can't be skipped by accident: spot state is private, and every public method takes the lock.
  • Tests run in milliseconds and cover the money edge cases and the race.

What could break, and what happens when requirements change

"What if…"What breaksWhat to change
Cars keep taking large spots (look at CAR-D and TRUCK-2 in the demo)Trucks get turned away while cars sit in truck spotsMake spot choice a second strategy: "exact size only for trucks", or keep the last N large spots for trucks. This is the moment a SpotFinder interface earns its place, not before.
10,000 spots and 20 gatesOne lock makes every gate wait for the whole scanKeep a free list per size per level (a queue of free spot IDs), and lock per level. Park becomes O(levels) instead of O(spots).
Electric car charging spotsSpotSize is a single ordered line; "EV" isn't bigger or smallerAdd spot features (a set: ev, handicapped) next to size, and match on both.
Lost ticketLeave needs a ticket IDLook up the active ticket by plate (we already keep parked[plate]), and charge a lost-ticket fee as a pricing rule.
ReservationsA free spot might be promised to someoneA spot gets a third state, reserved, with an expiry. Now it's a small state machine.
Power cutEverything is in memory; all tickets vanishPersist tickets and spot state. That's the HLD version: a database, and the lot becomes a service.

What is deliberately simple

  • Ticket IDs are a counter, which is fine in one process. Across several servers you'd use UUIDs or a central ID service.
  • The free count scans every spot. For a gate board refreshed every few seconds, that's fine.

What I'd change before production: the spot-choice rule from the first row (it's a real complaint in real car parks), and logging every Park and Leave with the ticket ID, so a disputed fee can be traced.

Interview questions

Practice questions

Beginner

  1. Why is Spot not responsible for finding a free spot?
  2. Why does Park return a Ticket value instead of a pointer?
  3. How does the code guarantee "smallest spot that fits"?

Intermediate

  1. How would you add a night rate, cheaper between 10 p.m. and 6 a.m.?
  2. Two gates call Park at once for the last spot. Walk through what happens.
  3. How do you test that a 2h30m stay costs 150 taka without waiting 2h30m?

Advanced

  1. The lot grows to 10,000 spots and 20 gates. Where is the bottleneck, and how do you fix it?
  2. Trucks are being turned away while cars use large spots. Change the design so the rule is configurable.
  3. Why would a Singleton Lot make this code worse?

Follow-ups interviewers like

  • "Add a motorcycle-only floor." · "Show free spots per level, not just per size." · "Make the fee depend on the level (rooftop is cheaper)."

Common design mistakes

  • Subclass per spot size (SmallSpot, LargeSpot) when sizes differ only in data.
  • Pricing written inside Leave with if chains, so every rule change edits parking code.
  • Calling the system clock directly, which makes fee tests slow or flaky.
  • Forgetting concurrency entirely, or locking only the "find" step and not the "mark taken" step, which leaves the race in place.
  • Returning internal pointers, so callers can change a ticket or a spot without the lock.
  • A Gate class that only forwards calls, which adds a layer with no decision in it.

Answers

1. Why isn't Spot responsible for finding a free spot?

Finding needs to see all spots and compare them. A single spot only knows itself. If Spot searched, it would need a reference to its siblings, and the "best fit" rule would be spread over many objects. The search belongs to whoever can see the whole floor (Level) and the whole lot (Lot).

2. Why does Park return a value, not a pointer?

A pointer would let the caller change the lot's own ticket, for example setting Status = Paid, without the lock and without freeing the spot. A value is a copy: the caller can read it and scribble on it, and the lot's original stays correct. TestReturnedTicketIsACopy checks exactly this.

3. How does the code guarantee smallest-fit?

NewLevel adds spots smallest first. findSpot returns the first free spot with Size >= need. Because of the order, the first match is also the smallest one that fits. The guarantee lives in the constructor's ordering, so a comment there matters.

4. How would you add a night rate?

Write a new type with a Fee method that splits the stay into night and day hours and prices each, then pass it to NewLot. Lot doesn't change. One catch: Fee gets only the duration, and a night rate needs the actual times, so you'd change the interface to Fee(vehicle, entry, exit). Saying that out loud in an interview is a plus: it shows you noticed the interface is missing information.

5. Two gates, one spot: what happens?

Both call Park. One gets mu first, finds the spot, marks it taken, creates the ticket and unlocks. The other was blocked on Lock(); it now scans, finds nothing and returns ErrLotFull. The key point is that "find" and "mark taken" happen inside the same critical section. Locking them separately would let both find the same free spot.

6. How do you test a 2h30m stay instantly?

The lot never calls time.Now() directly; it calls l.now(), which defaults to time.Now but can be replaced with WithClock. The test passes a fake clock, parks, moves the fake clock forward 150 minutes and calls Leave. No sleeping, and the result is the same on every run.

7. 10,000 spots and 20 gates: where's the bottleneck?

Two things: the scan is O(spots) per arrival, and it all happens under one lock, so gates queue. Fix the scan with a free list per size per level: a queue of free spot IDs, so finding a spot is popping from a queue. Fix contention by locking per level instead of per lot. A gate only blocks other gates working on the same level. The free count becomes a few counters, kept up to date on Park and Leave.

8. Make the spot rule configurable

Pull "which spot for this vehicle" behind a second interface, say SpotFinder with Find(levels, vehicle) *Spot. The current behaviour becomes BestFitFinder. Add ExactSizeForTrucks or ReserveLargeSpots{Keep: 2} as new types. This is the same Strategy move as pricing, made when a real need appears rather than up front.

9. Why would a Singleton lot be worse?

Every test would share one global lot, so tests could no longer run in parallel or start clean. A second building would need a rewrite. And the dependency becomes hidden: functions grab the global instead of receiving a *Lot, so you can't see from a signature what touches parking state. Creating one Lot in main and passing it gives the "only one" property without any of that.

What interviewers look for: clear entities with one job each, the pricing rule behind an interface, a real answer for concurrency (where the lock goes and why), and how you handle "what if" questions without rewriting everything.

Designed it yourself and understood the solution? Mark it to track your progress.