A production-grade parking-lot service (Spring Boot 3 / Java 21 / PostgreSQL) built as a reference answer for the classic "Design a Parking Lot" MAANG interview.
This README is written in the order you would actually present at a whiteboard: Requirements → APIs → HLD → LLD → Patterns → Concurrency → Schema → Flows → Trade-offs → Scale.
- Problem Statement
- Requirements
- Core Entities & Assumptions
- API Design
- High-Level Design (HLD)
- Low-Level Design (LLD) — Class Diagram
- Design Patterns Used
- Concurrency — The Heart of the Problem
- Database Schema (ER Diagram)
- Key Flows (Sequence Diagrams)
- State Machines
- Pricing Model
- Trade-offs & Decisions
- Scaling to Millions (HLD extension)
- How to Run
- Project Structure
Design a parking lot system that supports a multi-floor garage with different spot types, admits vehicles, issues tickets, computes parking fees on exit, accepts payment, and reports live availability — correctly under concurrent traffic (many entry gates racing for the same spots).
Interview tip: Always split functional vs non-functional first, and explicitly call out what is out of scope to bound the problem.
| # | Requirement |
|---|---|
| F1 | A lot has multiple floors; each floor has multiple spots. |
| F2 | Spots have types: Motorcycle, Compact, Large, Electric, Handicapped. |
| F3 | Vehicles have types: Motorcycle, Car, Electric, Truck. |
| F4 | A vehicle can only park in a compatible spot; assignment is best-fit (tightest fitting spot first). |
| F5 | On entry, issue a ticket (entry time, assigned spot). |
| F6 | On exit, compute the fee based on duration and spot type. |
| F7 | Accept payment; release the spot only after payment succeeds. |
| F8 | Report real-time availability per spot type. |
| F9 | Admin can provision lots, floors, and spots. |
| # | Requirement | How it's addressed |
|---|---|---|
| N1 | Correctness under concurrency — never assign one spot to two vehicles. | SELECT … FOR UPDATE SKIP LOCKED (pessimistic lock). |
| N2 | Consistency — ticket/spot/payment never drift apart. | DB transactions; spot freed only inside the payment txn. |
| N3 | Low latency entry/exit (< 100 ms). | Single indexed locking query; no full scans. |
| N4 | Extensibility — new pricing / assignment rules without touching core. | Strategy pattern behind interfaces. |
| N5 | Availability — degrade gracefully, horizontally scalable app tier. | Stateless services; DB is the single source of truth. |
| N6 | Auditability — every entity carries created/updated timestamps + version. | BaseEntity with @Version, @CreationTimestamp. |
Reservations, dynamic/surge pricing, license-plate OCR / ANPR, real payment-gateway integration (we stub it), multi-tenant operators, valet, EV charge metering.
1 large garage ≈ 1,000–5,000 spots
Peak entry/exit ≈ a few requests/sec per gate, ~50–100 gates → ~hundreds of req/s
=> A single relational DB easily handles this. No sharding needed for one lot.
(Section 14 covers the "1000s of lots / national operator" extension.)
- ParkingLot → has many ParkingFloor → has many ParkingSpot.
- Vehicle is identified by license plate (natural key).
- Ticket links a Vehicle to the Spot it occupies for one parking session.
- Payment settles one Ticket.
- A vehicle may have at most one open ticket at a time (can't be parked twice).
- Time is read through an injectable
Clock(deterministic + testable). - Money is
BigDecimalin minor units; neverdouble.
REST, versioned under
/api/v1. Resources are nouns; state transitions are sub-resource POSTs (/exit,/payment).
| Method | Path | Description | Success |
|---|---|---|---|
POST |
/api/v1/lots |
Create a lot | 201 |
GET |
/api/v1/lots/{lotId} |
Fetch a lot | 200 |
POST |
/api/v1/lots/{lotId}/floors |
Add a floor | 201 |
POST |
/api/v1/floors/{floorId}/spots |
Bulk-provision spots | 201 |
GET |
/api/v1/lots/{lotId}/availability |
Live availability by type | 200 |
| Method | Path | Description | Success |
|---|---|---|---|
POST |
/api/v1/lots/{lotId}/entries |
Park a vehicle → issue ticket | 201 |
POST |
/api/v1/tickets/{ticketNumber}/exit |
Compute fee, await payment | 200 |
POST |
/api/v1/tickets/{ticketNumber}/payment |
Pay → release spot | 201 |
GET |
/api/v1/tickets/{ticketNumber} |
Ticket status | 200 |
| Condition | HTTP | Exception |
|---|---|---|
| Lot/floor/ticket not found | 404 |
ResourceNotFoundException |
| Lot full for vehicle type | 409 |
NoSpotAvailableException |
| Invalid state (double-park, pay non-awaiting ticket) | 409 |
InvalidParkingStateException |
| Validation / malformed body / bad enum | 400 |
MethodArgumentNotValid / HttpMessageNotReadable |
// Example 409 body
{
"timestamp": "2026-06-07T16:03:03Z",
"status": 409,
"error": "Conflict",
"message": "No available spot for vehicle type TRUCK in lot 1",
"path": "/api/v1/lots/1/entries",
"fieldErrors": null
}flowchart LR
subgraph Clients
EG["Entry Gate / Kiosk"]
XG["Exit Gate / Kiosk"]
ADM["Admin Console"]
end
LB["Load Balancer"]
subgraph App["Parking Service (stateless, horizontally scalable)"]
direction TB
CTRL["REST Controllers"]
SVC["Services: Parking / Payment / Availability / Admin"]
STRAT["Strategies: SpotAssignment + Pricing"]
REPO["Repositories (Spring Data JPA)"]
end
DB[("PostgreSQL\n(source of truth)")]
PG["Payment Gateway (stubbed)"]
EG --> LB
XG --> LB
ADM --> LB
LB --> CTRL --> SVC --> STRAT
SVC --> REPO --> DB
SVC -.-> PG
Why this shape:
- The app tier is stateless → scale out behind a load balancer; any instance can serve any gate.
- PostgreSQL is the single source of truth for spot state. Correctness (N1) is enforced in the database with row locks, not in app memory — so it holds even with many app instances.
- Strategies are injected, so pricing/assignment policy is swappable without touching controllers or DB code.
classDiagram
class BaseEntity {
<<MappedSuperclass>>
+Long id
+long version
+Instant createdAt
+Instant updatedAt
}
class ParkingLot {
+String name
+String address
}
class ParkingFloor {
+int floorNumber
+String name
}
class ParkingSpot {
+String spotNumber
+SpotType spotType
+SpotStatus status
+occupy()
+free()
+isAvailable() bool
}
class Vehicle {
+String licensePlate
+VehicleType vehicleType
}
class Ticket {
+String ticketNumber
+Instant entryTime
+Instant exitTime
+TicketStatus status
+BigDecimal amount
}
class Payment {
+BigDecimal amount
+PaymentMethod method
+PaymentStatus status
+Instant paidAt
}
BaseEntity <|-- ParkingLot
BaseEntity <|-- ParkingFloor
BaseEntity <|-- ParkingSpot
BaseEntity <|-- Vehicle
BaseEntity <|-- Ticket
BaseEntity <|-- Payment
ParkingLot "1" o-- "*" ParkingFloor
ParkingFloor "1" o-- "*" ParkingSpot
Ticket "1" --> "1" Vehicle
Ticket "1" --> "1" ParkingSpot
Payment "1" --> "1" Ticket
class SpotAssignmentStrategy {
<<interface>>
+assignSpot(lotId, vehicleType) Optional~ParkingSpot~
}
class BestFitSpotAssignmentStrategy
SpotAssignmentStrategy <|.. BestFitSpotAssignmentStrategy
class PricingStrategy {
<<interface>>
+calculateFee(ticket, exitTime) BigDecimal
}
class HourlyPricingStrategy
PricingStrategy <|.. HourlyPricingStrategy
class ParkingService {
+parkVehicle(lotId, plate, type) Ticket
+initiateExit(ticketNumber) Ticket
}
class PaymentService {
+pay(ticketNumber, method) Payment
}
class AvailabilityService {
+availableByType(lotId) Map
}
ParkingService --> SpotAssignmentStrategy
ParkingService --> PricingStrategy
PaymentService --> Ticket
VehicleType owns its compatibility rules, ordered by best fit:
MOTORCYCLE → [MOTORCYCLE, COMPACT, LARGE]
CAR → [COMPACT, LARGE]
ELECTRIC → [ELECTRIC, COMPACT, LARGE]
TRUCK → [LARGE]
The assignment strategy simply walks this list and claims the first type with a free spot — so a Car takes a Compact before consuming a Large, leaving Large spots for Trucks.
| Pattern | Where | Why |
|---|---|---|
| Strategy | SpotAssignmentStrategy, PricingStrategy |
Swap assignment (best-fit / nearest / random) and pricing (hourly / flat / surge) without touching callers. Open/Closed Principle. |
| Factory (static) | DTO from(...) / of(...) methods |
Centralize entity→DTO mapping. |
| Dependency Injection | Everywhere (Spring constructor injection) | Testability, loose coupling. |
| Repository | *Repository interfaces |
Abstract persistence; mock in unit tests. |
| Singleton | Spring beans (services, strategies, Clock) |
One shared, stateless instance. |
| Template Method (implicit) | BaseEntity |
Shared identity/versioning/audit for all entities. |
| Value Object | record DTOs |
Immutable request/response payloads. |
Extension story for the interviewer: "To add surge pricing, I implement a new
PricingStrategyand inject it — zero changes toParkingService. To park nearest-to-elevator instead of best-fit, I implement a newSpotAssignmentStrategy. That's the Open/Closed Principle in action."
This is what the interview is really testing. Two cars arrive at two gates at the same millisecond and there is one Compact spot left. Exactly one must win.
Gate A: SELECT free spot -> spot #42
Gate B: SELECT free spot -> spot #42 (B reads before A writes)
Gate A: UPDATE #42 OCCUPIED
Gate B: UPDATE #42 OCCUPIED ❌ two cars, one spot
The repository query takes a PESSIMISTIC_WRITE lock and uses the lock-timeout
hint -2, which Hibernate maps to SKIP LOCKED. On PostgreSQL this emits:
SELECT * FROM parking_spot
WHERE parking_lot_id = ? AND spot_type = ? AND status = 'AVAILABLE'
ORDER BY spot_number
LIMIT 1
FOR UPDATE SKIP LOCKED; -- ← the magicsequenceDiagram
participant A as Gate A txn
participant B as Gate B txn
participant DB as PostgreSQL
A->>DB: SELECT ... FOR UPDATE SKIP LOCKED (LIMIT 1)
DB-->>A: row #42 (now row-locked by A)
B->>DB: SELECT ... FOR UPDATE SKIP LOCKED (LIMIT 1)
Note over DB: #42 is locked → SKIP it
DB-->>B: next free row #43 (or none → lot full)
A->>DB: UPDATE #42 = OCCUPIED; COMMIT (lock released)
B->>DB: UPDATE #43 = OCCUPIED; COMMIT
Note over A,B: ✅ Two cars, two different spots. No deadlock, no blocking.
| Approach | Problem |
|---|---|
App-level synchronized / single lock |
Doesn't work across multiple app instances; kills throughput. |
FOR UPDATE (plain, blocking) |
Gate B waits for A to commit, then re-reads — serializes all entries, adds latency. |
FOR UPDATE SKIP LOCKED ✅ |
Gate B instantly skips the locked row and grabs the next free one. No blocking, no deadlock, max throughput. |
Optimistic locking (@Version) only |
Works, but causes retry storms under contention for the same hot rows. |
We still keep
@Versionon every entity for general optimistic locking on ordinary updates;SKIP LOCKEDis the specialized tool for the hot spot-claim path.
parkVehicle() runs in one @Transactional method: claim-lock the spot →
mark OCCUPIED → create ticket → commit. The lock is held for the whole (short)
transaction, so no other txn can grab the same row in between.
erDiagram
PARKING_LOT ||--o{ PARKING_FLOOR : has
PARKING_FLOOR ||--o{ PARKING_SPOT : has
VEHICLE ||--o{ TICKET : "parks via"
PARKING_SPOT ||--o{ TICKET : "occupied by"
TICKET ||--|| PAYMENT : "settled by"
PARKING_LOT {
bigint id PK
string name
string address
bigint version
}
PARKING_FLOOR {
bigint id PK
bigint parking_lot_id FK
int floor_number
string name
}
PARKING_SPOT {
bigint id PK
bigint parking_floor_id FK
string spot_number
string spot_type
string status
}
VEHICLE {
bigint id PK
string license_plate UK
string vehicle_type
}
TICKET {
bigint id PK
string ticket_number UK
bigint vehicle_id FK
bigint parking_spot_id FK
timestamptz entry_time
timestamptz exit_time
string status
numeric amount
}
PAYMENT {
bigint id PK
bigint ticket_id FK,UK
numeric amount
string method
string status
timestamptz paid_at
}
parking_spot (parking_lot_id, spot_type, status, spot_number)— composite index that makes the hotfindClaimableSpotsquery an index range scan, not a table scan.vehicle (license_plate)— UNIQUE; fast lookup + "already parked" check.ticket (ticket_number)— UNIQUE; the customer-facing handle.- Unique constraints:
(parking_lot_id, floor_number),(parking_floor_id, spot_number)prevent duplicate floors/spots.
sequenceDiagram
actor Driver
participant API as ParkingController
participant PS as ParkingService
participant AS as BestFitAssignment
participant DB as PostgreSQL
Driver->>API: POST /lots/1/entries {plate, type}
API->>PS: parkVehicle(lotId, plate, type)
PS->>DB: already has open ticket? (reject if yes → 409)
PS->>AS: assignSpot(lotId, type)
AS->>DB: SELECT ... FOR UPDATE SKIP LOCKED (best-fit order)
DB-->>AS: locked spot (or empty → 409 NoSpotAvailable)
PS->>DB: spot.OCCUPIED + save Ticket(ACTIVE)
PS-->>API: Ticket
API-->>Driver: 201 { ticketNumber, spot, entryTime }
sequenceDiagram
actor Driver
participant PC as ParkingController
participant PMC as PaymentController
participant PS as ParkingService
participant PMS as PaymentService
participant PR as PricingStrategy
participant DB as PostgreSQL
Driver->>PC: POST /tickets/{tn}/exit
PC->>PS: initiateExit(tn)
PS->>PR: calculateFee(ticket, now)
PR-->>PS: amount
PS->>DB: ticket.exitTime, amount, status=AWAITING_PAYMENT
PS-->>Driver: 200 { amount due }
Driver->>PMC: POST /tickets/{tn}/payment {method}
PMC->>PMS: pay(tn, method)
PMS->>DB: save Payment(COMPLETED)
PMS->>DB: ticket.status=PAID, spot.free() ← spot released here
PMS-->>Driver: 201 { payment }
Design choice: the spot is freed only after payment completes, inside the payment transaction — so a driver can't vacate the system's record of the spot without paying.
stateDiagram-v2
[*] --> ACTIVE: park (spot OCCUPIED)
ACTIVE --> AWAITING_PAYMENT: initiateExit (fee computed)
AWAITING_PAYMENT --> PAID: pay (spot FREED)
ACTIVE --> LOST: ticket lost (penalty path)
PAID --> [*]
stateDiagram-v2
[*] --> AVAILABLE
AVAILABLE --> OCCUPIED: claimed on entry
OCCUPIED --> AVAILABLE: released on payment
AVAILABLE --> OUT_OF_SERVICE: maintenance
OUT_OF_SERVICE --> AVAILABLE: repaired
HourlyPricingStrategy: fee = baseFee + ceil(hours) × hourlyRate(spotType),
with a configurable grace period.
- Stays within
freeMinutes(default 15) → charged only the base fee. - Partial hours round up (garage convention): 1h1m → 2 billable hours.
- Rates are externalized in
application.properties(parking.pricing.*), bound via@ConfigurationProperties— change pricing without recompiling.
COMPACT=20/hr, LARGE=30/hr, ELECTRIC=25/hr ...
e.g. Truck (LARGE) parked 2h1m, baseFee 5 → 5 + 3×30 = 95
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Spot-claim concurrency | FOR UPDATE SKIP LOCKED |
App lock / optimistic retry | Correct across instances, non-blocking, high throughput. |
| Source of truth | Relational DB | In-memory grid | One lot's load fits a single DB; ACID for free. |
| Spot release timing | After payment | At exit scan | Prevents "leave without paying" record drift. |
| Assignment policy | Best-fit | First-available | Preserves large spots for large vehicles. |
| Money type | BigDecimal |
double |
No floating-point rounding errors. |
| Time source | Injected Clock |
Instant.now() |
Deterministic, testable. |
| ID generation | DB identity | UUID PK | Simpler, sequential, index-friendly (ticket gets a separate opaque ticket_number). |
When the interviewer says "now make it work for a national operator with 10,000 lots."
flowchart TB
C["Clients / Gates"] --> CDN["API Gateway + LB"]
CDN --> S1["Parking Svc (N replicas)"]
S1 --> Cache[("Redis: availability counters")]
S1 --> DBP[("PostgreSQL primary\n(writes, row locks)")]
DBP --> DBR[("Read replicas\n(availability, ticket reads)")]
S1 --> MQ["Kafka: ticket/payment events"]
MQ --> AN["Analytics / Billing / Notifications"]
- Shard by
lotId(or region) — each lot's spots are independent, so the natural partition key is the lot. No cross-lot transactions. - Read replicas for availability + ticket lookups (read-heavy); writes (claim/release) go to primary where the row locks live.
- Redis counters for
availableByTypeto avoid hitting the DB on every availability poll; reconciled from the DB (source of truth). - Event stream (Kafka) for payments/tickets → analytics, billing, notifications, without coupling the hot path.
- App tier already stateless → just add replicas behind the LB.
- Idempotency keys on entry/payment so client retries don't double-charge or double-issue.
Requires JDK 21 and Docker (PostgreSQL is auto-started via
compose.yaml).
# Run all tests (no Docker needed — uses in-memory H2)
./mvnw test
# Run the app (auto-starts PostgreSQL 17 via Spring Docker Compose support)
# Note: port 8080 may be busy on this machine → use 8081
./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8081A sample lot (id=1, 2 floors, 50 spots) is seeded on first start
(parking.seed.enabled=true).
B=http://localhost:8081/api/v1
curl -s $B/lots/1/availability
curl -s -X POST $B/lots/1/entries -H 'Content-Type: application/json' \
-d '{"licensePlate":"KA01AB1234","vehicleType":"CAR"}'
# → take the ticketNumber from the response, then:
curl -s -X POST $B/tickets/<TN>/exit
curl -s -X POST $B/tickets/<TN>/payment -H 'Content-Type: application/json' \
-d '{"method":"CARD"}'src/main/java/com/sds/parkinglotsystem/
├── config/ # ApplicationConfig (Clock bean), DataInitializer (seed)
├── domain/
│ ├── enums/ # SpotType, VehicleType, SpotStatus, TicketStatus, Payment*
│ └── model/ # BaseEntity + JPA entities
├── exception/ # ResourceNotFound, NoSpotAvailable, InvalidParkingState
├── repository/ # Spring Data JPA repos (incl. SKIP LOCKED query)
├── service/ # ParkingLotAdmin, Parking, Payment, Availability
├── strategy/
│ ├── spot/ # SpotAssignmentStrategy + BestFit impl
│ └── pricing/ # PricingStrategy + Hourly impl + PricingProperties
└── web/
├── controller/ # Admin, Parking, Payment controllers
├── dto/ # Request/response records
└── error/ # ApiError + GlobalExceptionHandler
src/test/java/... # Unit (pricing, enums) + Mockito service tests + context test
- Best-fit assignment via a
VehicleType → [SpotType...]preference list. SELECT … FOR UPDATE SKIP LOCKEDis how two gates never get the same spot — non-blocking, multi-instance safe.- Strategy pattern for pricing & assignment → Open/Closed, easy to extend.
- Spot released only inside the payment transaction → no "leave without paying" drift.
- Stateless app + DB as source of truth → shard by
lotIdto scale horizontally.