System design / LLDBeginner to intermediate · 9 sections · 7 figures · 16 min read

Parking Lot Management

  • OOP
  • State machines
  • Concurrency
  • Designed · build in progress
The constraint

Two cars at two gates in the same second must never be given the same spot, and nobody is charged twice.

The decision

A small Java domain on PostgreSQL where every allocation is one conditional UPDATE, backed by partial unique indexes.

0
double allocations, by constraint
140/s
peak entries, 200 sites
300 ms
p99 target to a ticket

1. Problem and requirements

A parking operator runs one or more facilities. Each facility has floors, spots, and entry and exit gates. Drivers take a ticket at entry and pay at exit.

Assumptions

  • A facility has several floors and several entry and exit gates, each running a gate terminal that calls the backend.
  • Vehicles are MOTORCYCLE, CAR or TRUCK. A spot has a size, and a vehicle fits a spot of its own size or larger (a motorcycle may use a car spot when motorcycle spots are full; a truck needs a large spot).
  • Some spots have features: EV_CHARGING and ACCESSIBLE. A vehicle that requests a feature is only given a spot with it.
  • A vehicle is identified by its licence plate as read or typed at the gate.
  • Pricing depends on vehicle type, duration and the facility’s tariff: an hourly rate, a grace period and a daily cap.

Functional requirements

# Requirement
F1 Allocate a compatible spot at entry and issue a ticket.
F2 Show live availability per facility, floor and vehicle type.
F3 At exit, calculate the fee, take payment, close the ticket and free the spot.
F4 Let operators mark spots out of service and back.
F5 Configure tariffs per facility and vehicle type.
F6 Keep a full audit trail of tickets and payments.

Non-functional requirements

# Requirement Target
N1 No spot is ever held by two active tickets. Zero, enforced by the database
N2 A vehicle never holds two active tickets in the same facility. Zero, enforced by the database
N3 A payment is never taken twice for one ticket. Zero, enforced by idempotency keys
N4 Entry decision latency (ticket printed) p99 under 300 ms
N5 Availability during facility hours 99.9% per facility
N6 Availability counts are fresh Within 2 seconds

Out of scope for version one

Reservations in advance, dynamic pricing, automatic number-plate recognition hardware, and season passes. Each is a separate feature with its own consistency questions. Reservations, for example, introduce holds that expire. They are listed in scaling evolution with the trigger that would justify them.

2. Capacity estimation

Assumptions: a large facility has 1,000 spots and 4 entry gates. One gate can admit a car about every 6 seconds at the busiest time. Spots turn over 6 times a day. The operator runs 200 facilities.

Metric Working Result
Peak entries, one facility 4 gates × (1 car / 6 s) ≈ 0.7 entries/s
Peak entries, whole network 0.7 × 200 ≈ 140 entries/s
Peak exits, whole network Symmetric with entries ≈ 140 exits/s
Availability reads Display boards and apps poll every 5 s; about 20 readers per facility 200 × 20 / 5 ≈ 800 reads/s
Tickets per day 1,000 spots × 6 turnovers × 200 facilities 1.2 million
Ticket storage 1.2 M × about 300 bytes ≈ 360 MB/day, ≈ 130 GB/year raw

What this says: writes are small and the system is not throughput-bound. One PostgreSQL primary handles 300 writes/s comfortably. The real difficulty is contention: at a busy facility, many requests compete for the same few free spots on the nearest floor. The design therefore optimises for short transactions and conflict-free allocation, not raw scale.

3. Architecture and APIs

High-level architecture

FIG 01ArchitectureHigh-level architecture
Component Responsibility
Gate terminal Reads the plate and vehicle type, calls the API, prints or displays the ticket. Generates an idempotency key per physical action.
ParkingService Orchestrates entry and exit. Owns transaction boundaries.
AllocationService Chooses a candidate spot through a SpotAllocationStrategy and claims it atomically.
FeeCalculator Pure function from ticket, exit time and tariff to an amount.
PaymentService Talks to the payment provider and records every attempt.
PostgreSQL Source of truth for spots, tickets and payments. Constraints enforce N1 to N3.

API contracts

All unsafe requests carry an Idempotency-Key header. A repeated key returns the stored response of the first call.

Method and path Purpose Success Notable errors
POST /facilities/{fid}/entries Allocate a spot and issue a ticket 201 ticket 409 VEHICLE_ALREADY_PARKED, 503 NO_SPOT_AVAILABLE
GET /facilities/{fid}/availability Free spots by floor and type 200
POST /tickets/{tid}/quote Fee for leaving now 200 amount 404, 409 TICKET_CLOSED
POST /tickets/{tid}/payments Pay the quoted fee 201 payment 402 PAYMENT_DECLINED, 409 ALREADY_PAID
POST /tickets/{tid}/exit Close a paid ticket and free the spot 200 409 NOT_PAID, 409 TICKET_CLOSED
PATCH /spots/{sid} Operator: out of service or back in 200 409 SPOT_OCCUPIED

Example entry:

POST /facilities/F-12/entries
Idempotency-Key: 6f1c2a7e-gate-E2-000183

{ "plate": "OD02AB1234", "vehicleType": "CAR", "features": ["EV_CHARGING"] }
201 Created
{
  "ticketId": "T-9f2c",
  "spot": { "id": "S-2-047", "floor": 2, "label": "B-47", "features": ["EV_CHARGING"] },
  "enteredAt": "2026-10-22T08:14:03Z"
}

Errors use one shape everywhere (RFC 9457 problem details):

{ "type": "/errors/no-spot-available", "title": "No compatible spot is free",
  "status": 503, "code": "NO_SPOT_AVAILABLE", "retryable": true }

Deployment

FIG 02ArchitectureDeployment

Two stateless instances behind a load balancer give availability, not throughput. One instance could handle the load. The standby is promoted if the primary fails.

4. Internal design and workflows

Class diagram

FIG 03Class modelClass diagram

Interfaces exist only where there is a real variation point. SpotAllocationStrategy (nearest to entry today, balanced across floors tomorrow) and FeeCalculator (hourly with cap today, time-of-day tariffs later) will change. The repositories are interfaces so the domain can be tested without a database. There is no interface for ParkingService: it has one implementation and always will.

ParkingSpot.fits(vehicle) holds the compatibility rule in one place:

public boolean fits(Vehicle v) {
    return size.ordinal() >= v.type().minimumSize().ordinal()
        && features.containsAll(v.requires())
        && state == SpotState.AVAILABLE;
}

State machines

FIG 04State machineState machines
FIG 05State machineState machines

There is no RESERVED spot state in version one, because there are no advance reservations. A spot goes straight from AVAILABLE to OCCUPIED inside the entry transaction. If reservations are added later, RESERVED arrives with an expiry and a sweeper (see scaling).

Every transition is a method on the entity that checks the current state, so an illegal move throws instead of silently corrupting data:

void markPaid(Payment p) {
    if (state != TicketState.ACTIVE) throw new IllegalTransition(state, TicketState.PAID);
    state = TicketState.PAID;
    paidUntil = p.capturedAt().plus(EXIT_GRACE);
}

Data model and indexes

FIG 06Data modelData model and indexes

The constraints that carry the correctness requirements:

-- N1: a spot has at most one open ticket
CREATE UNIQUE INDEX one_open_ticket_per_spot
  ON ticket (spot_id) WHERE state IN ('ACTIVE', 'PAID', 'LOST');

-- N2: a vehicle has at most one open ticket per facility
CREATE UNIQUE INDEX one_open_ticket_per_vehicle
  ON ticket (facility_id, plate) WHERE state IN ('ACTIVE', 'PAID', 'LOST');

-- N3: one payment per idempotency key
ALTER TABLE payment ADD CONSTRAINT payment_idem UNIQUE (idempotency_key);

-- Fast candidate search: free spots by facility, size, nearest first
CREATE INDEX free_spots ON spot (floor_id, size, distance_rank) WHERE state = 'AVAILABLE';

The partial unique indexes are the real guarantee. Even if application code has a bug, the database refuses the second open ticket for a spot or a vehicle.

Entry sequence

FIG 07SequenceEntry sequence

The allocation algorithm

The claim is a conditional update. It affects one row only if the spot is still free at that instant, so two concurrent requests can both see spot B-47 as a candidate but only one can claim it:

@Transactional
public Ticket enter(EntryCommand cmd) {
    var vehicle = cmd.vehicle();
    List<SpotId> candidates = strategy.candidates(cmd.facility(), vehicle, 5);
    for (SpotId id : candidates) {
        if (spots.claimIfAvailable(id)) {           // UPDATE ... WHERE state = 'AVAILABLE'
            return tickets.open(cmd.facility(), id, vehicle, clock.instant());
        }                                          // lost the race: try the next candidate
    }
    throw new NoSpotAvailable(cmd.facility(), vehicle.type());
}
UPDATE spot SET state = 'OCCUPIED', version = version + 1
WHERE id = :id AND state = 'AVAILABLE';   -- 1 row: claimed. 0 rows: someone else won.

Why not SELECT ... FOR UPDATE? Row locking works, but it makes concurrent entries wait on each other for the same nearest spot. The conditional update never waits. A loser moves straight to the next candidate. Under heavy contention, SELECT ... FOR UPDATE SKIP LOCKED is the alternative that also avoids waiting. It is the right tool when there are many workers competing for a queue of rows, and it is noted as the fallback in decision record 1.

The fee calculation

FeeCalculator is pure: same inputs, same output, no clock or database inside it. That makes the boundaries easy to test.

Money fee(Ticket t, Instant exitAt, Tariff tariff) {
    long minutes = Duration.between(t.enteredAt(), exitAt).toMinutes();
    if (minutes <= tariff.graceMinutes()) return Money.ZERO;
    long days = minutes / (24 * 60), rest = minutes % (24 * 60);
    long hours = (rest + 59) / 60;                                   // started hours are charged
    Money partial = tariff.hourlyRate().times(hours).min(tariff.dailyCap());
    return tariff.dailyCap().times(days).plus(partial);
}

Money is held as an integer number of the smallest currency unit, never as double.

Transaction boundaries and data ownership

Operation One transaction contains Outside the transaction
Entry Claim spot, insert ticket, store idempotency response Printing the ticket at the gate
Payment Insert PENDING payment with its key (own transaction), then a second transaction to record the provider result The call to the payment provider
Exit Close ticket, release spot Opening the barrier

The payment provider call is never inside a database transaction. Holding a row lock while waiting on a network call is how one slow provider freezes a whole facility.

5. Failure modes and consistency

Scenario What would go wrong Design response
Two gates claim the last free car spot at once Double allocation Conditional UPDATE claims for exactly one; the other tries the next candidate or gets 503 NO_SPOT_AVAILABLE. The unique index is the backstop.
Gate retries an entry after a timeout Two tickets for one car Same Idempotency-Key returns the first response. The vehicle unique index blocks a second open ticket even without a key.
Payment succeeds, then the response to the gate times out Driver charged twice on retry Payment row is written PENDING with the key before calling the provider and the provider receives the same key. A retry finds the row and returns its outcome.
Payment provider is down Cars cannot leave Bounded retries with backoff, then the gate offers an alternative (attendant, pay later against the plate). The ticket stays ACTIVE, so nothing is lost.
Provider charge result unknown (timeout, no answer) Unknown whether money moved Payment stays PENDING; a reconciliation job queries the provider by idempotency key every minute and settles it. The driver is never asked to pay again while a PENDING exists.
Driver pays, then lingers past the grace period Free extra time Ticket returns from PAID to ACTIVE with paidUntil; the next quote charges only the extra time.
Gate loses connectivity Cannot issue tickets Gate degrades to issuing offline tickets: signed, numbered locally, no spot assigned. When it reconnects it uploads them; spots are reconciled from occupancy. This trades exact spot assignment for keeping traffic moving. Fraud risk is bounded by signatures and short offline windows.
Database primary fails All gates stop Promote the standby (RPO under a few seconds with synchronous commit for payments). Gates retry with the same keys, so in-flight requests are not duplicated.
A spot is marked out of service while occupied Car stranded or spot double-booked PATCH refuses with 409 SPOT_OCCUPIED; operators flag it to apply after exit.

Concurrency and idempotency, summarised

  • Spot claims: atomic conditional update, never a read-then-write in application code.
  • Uniqueness: partial unique indexes enforce one open ticket per spot and per vehicle.
  • Idempotency: every unsafe call stores (key, request hash, response). A replay with a different body under the same key is rejected with 422.
  • No in-JVM locks are used for correctness, because there are two instances.

Caching

Availability boards poll often but tolerate a 2-second staleness (N6). A per-facility count is computed with one indexed query and cached in memory for 1 second per instance. Allocation never reads the cache: it always goes to the database, because a stale cache there would cause failed claims.

Security and abuse

  • Gate terminals authenticate with mutual TLS and a per-device credential; operators use SSO with roles (OPERATOR, ADMIN).
  • Tariff changes and out-of-service changes are audited with who, when and the previous value.
  • Plates are personal data: masked in logs, and closed tickets are anonymised after the retention period.

6. Scaling evolution

Stage Trigger Design Why
1. One facility Launch One Spring Boot app, PostgreSQL, the conditional-update allocation Simplest thing that is correct
2. Many gates Availability matters Two instances, load balancer, hot standby Remove single points of failure; throughput is not the problem
3. Many facilities 200 facilities on one system Every table and query keyed by facility_id; indexes lead with it Keeps each facility’s working set small and queries local
4. High contention Measured claim failures over 5% at peak Spread candidates (random among the nearest 5), or SKIP LOCKED Stops every request fighting for the same nearest spot
5. Reservations Product asks for booking ahead RESERVED state with hold_until, a sweeper releasing expired holds, and capacity held back per time slot Holds need expiry, otherwise abandoned bookings drain capacity
6. Regional network Thousands of facilities Partition by facility: each region’s database owns its facilities; a separate analytics pipeline aggregates occupancy Facilities share no state, so they partition cleanly

The design never needs distributed transactions, because a parking event belongs to exactly one facility.

7. Operations and validation

Metrics and SLOs

Signal SLO or alert
Entry latency p99 under 300 ms; alert at 500 ms for 5 minutes
Claim retries per entry Alert if the mean exceeds 1.5 (contention)
NO_SPOT_AVAILABLE rate versus occupancy Alert if returned while occupancy is under 95% (allocation bug or stuck spots)
PENDING payments older than 10 minutes Alert at 1 or more (reconciliation stuck)
Spots OCCUPIED longer than 72 hours Daily report (lost tickets or abandoned cars)

Test plan (to build)

Test Proves
100 concurrent entries for 10 free spots (Testcontainers PostgreSQL) Exactly 10 tickets, 90 clean 503s, no spot held twice
Same idempotency key sent 20 times concurrently One ticket, 20 identical responses
Motorcycle, car and truck against every spot size and feature fits rules
Fee at 0, grace, grace + 1 minute, 23:59, 24:00, 24:01, 3 days Boundary arithmetic and the daily cap
Payment timeout, then retry One charge, PENDING reconciled
Out-of-service request on an occupied spot 409

Load test (to measure)

A k6 script drives entries and exits for 50 simulated facilities at 3× the estimated peak. To be recorded: entry p50/p95/p99, claim retries per entry, and database CPU. No results are claimed until this has been run.

8. Decision records

DR-1: Conditional update, not in-memory locks

  • Context: several application instances allocate spots in the same facility.
  • Decision: claim with UPDATE ... WHERE state = 'AVAILABLE' and back it with partial unique indexes.
  • Rejected: synchronized or ReentrantLock per facility (works only inside one JVM, so it is wrong as soon as a second instance runs); a Redis lock (adds a dependency whose failure and expiry semantics must then be reasoned about, for no gain over the database we already have); SELECT ... FOR UPDATE (correct but serialises requests on the same row).
  • Consequence: correctness survives any number of instances. Under extreme contention, switch candidates to SKIP LOCKED (stage 4).

DR-2: Monolith, not microservices

  • Context: allocation, tickets and payments are one workflow with one owner.
  • Decision: one modular application, modules separated by package and interface.
  • Rejected: separate allocation, ticket and payment services. They would turn a local transaction into a distributed workflow, adding sagas and failure modes for no scaling benefit at 140 writes/s.

DR-3: Write PENDING before calling the payment provider

  • Decision: persist the payment intent with its idempotency key first, then call the provider, then record the result.
  • Why: if the process dies after the provider charges but before we record it, the PENDING row and the shared key let reconciliation find and settle the charge. Calling first and writing after would lose that money silently.

9. Limitations, and implemented versus proposed

Known limitations

  • Offline gate operation trades exact spot assignment for flow; occupancy can be briefly wrong after a long outage.
  • Plate-based identity fails with misread plates. A ticket ID or QR code is the fallback key at exit.
  • Nearest-first allocation can wear out the ground floor; the balanced strategy is a planned alternative.

Status

Item Status
Requirements, capacity, HLD, APIs, deployment Designed here
Class, ER, sequence and state diagrams; allocation and fee algorithms Designed here
Failure scenarios, idempotency, caching, security, scaling, SLOs Designed here
Decision records, trade-offs, limitations Designed here
Java implementation of the domain and services To build
Unit and Testcontainers concurrency tests To build
Load test and its results To measure