Two cars at two gates in the same second must never be given the same spot, and nobody is charged twice.
Parking Lot Management
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,CARorTRUCK. 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_CHARGINGandACCESSIBLE. 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
flowchart LR
subgraph Facility
EG[Entry gate terminal]
XG[Exit gate terminal]
DB1[Display boards]
end
EG -->|HTTPS + Idempotency-Key| API
XG -->|HTTPS + Idempotency-Key| API
DB1 -->|GET availability| API
subgraph Backend [Parking service · Spring Boot]
API[REST controllers]
PS[ParkingService]
AL[AllocationService]
FC[FeeCalculator]
PAY[PaymentService]
end
API --> PS
PS --> AL
PS --> FC
PS --> PAY
AL --> PG[(PostgreSQL)]
PS --> PG
PAY -->|charge / status| PSP[Payment provider]
| 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
flowchart LR G[Gate terminals] --> LB[Load balancer] LB --> A1[parking-service #1] LB --> A2[parking-service #2] A1 --> P[(PostgreSQL primary)] A2 --> P P -. streaming replication .-> R[(Hot standby)]
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
classDiagram
class ParkingLot {
+id
+name
+floors List~ParkingFloor~
}
class ParkingFloor {
+number
+spots List~ParkingSpot~
}
class ParkingSpot {
+id
+size SpotSize
+features Set~Feature~
+state SpotState
+fits(Vehicle) bool
}
class Vehicle {
+plate
+type VehicleType
+requires Set~Feature~
}
class ParkingTicket {
+id
+enteredAt
+exitedAt
+state TicketState
}
class Payment {
+id
+amount
+idempotencyKey
+providerRef
+status
}
class Tariff {
+hourlyRate
+graceMinutes
+dailyCap
}
class SpotAllocationStrategy {
<<interface>>
+candidates(lot, vehicle, n) List~SpotId~
}
class NearestToEntryStrategy
class FeeCalculator {
<<interface>>
+fee(ticket, exitAt, tariff) Money
}
class HourlyWithCapCalculator
class ParkingService {
+enter(cmd) Ticket
+quote(ticketId) Money
+pay(ticketId, key) Payment
+exit(ticketId)
}
class AllocationService {
+claim(lot, vehicle) ParkingSpot
}
class SpotRepository {
<<interface>>
+claimIfAvailable(spotId) bool
}
class TicketRepository {
<<interface>>
+open(facility, spot, vehicle, at) Ticket
}
class PaymentService {
+charge(ticket, amount, key) Payment
}
ParkingLot "1" *-- "1..*" ParkingFloor
ParkingFloor "1" *-- "1..*" ParkingSpot
ParkingTicket "*" --> "1" ParkingSpot
ParkingTicket "*" --> "1" Vehicle
ParkingTicket "1" --> "0..*" Payment
SpotAllocationStrategy <|.. NearestToEntryStrategy
FeeCalculator <|.. HourlyWithCapCalculator
ParkingService --> AllocationService
ParkingService --> FeeCalculator
ParkingService --> PaymentService
ParkingService --> TicketRepository
AllocationService --> SpotAllocationStrategy
AllocationService --> SpotRepository
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
stateDiagram-v2 [*] --> AVAILABLE AVAILABLE --> OCCUPIED: entry claims spot OCCUPIED --> AVAILABLE: exit releases spot AVAILABLE --> OUT_OF_SERVICE: operator OUT_OF_SERVICE --> AVAILABLE: operator OCCUPIED --> OCCUPIED: operator cannot disable (409)
stateDiagram-v2 [*] --> ACTIVE: ticket issued ACTIVE --> PAID: payment captured PAID --> ACTIVE: grace period expired before exit (fee for extra time) PAID --> CLOSED: exit gate opened ACTIVE --> LOST: operator marks ticket lost (flat fee) LOST --> CLOSED: lost-ticket fee paid CLOSED --> [*]
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
erDiagram
FACILITY ||--o{ FLOOR : has
FLOOR ||--o{ SPOT : has
FACILITY ||--o{ TARIFF : prices
SPOT ||--o{ TICKET : "occupied by"
TICKET ||--o{ PAYMENT : "paid by"
FACILITY {
uuid id PK
text name
text timezone
}
FLOOR {
uuid id PK
uuid facility_id FK
int number
}
SPOT {
uuid id PK
uuid floor_id FK
text size
text_array features
text state
int distance_rank
bigint version
}
TICKET {
uuid id PK
uuid facility_id FK
uuid spot_id FK
text plate
text vehicle_type
timestamptz entered_at
timestamptz exited_at
timestamptz paid_until
text state
}
PAYMENT {
uuid id PK
uuid ticket_id FK
bigint amount_minor
text idempotency_key UK
text provider_ref
text status
timestamptz created_at
}
TARIFF {
uuid id PK
uuid facility_id FK
text vehicle_type
bigint hourly_rate_minor
int grace_minutes
bigint daily_cap_minor
daterange valid
}
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
sequenceDiagram
participant G as Entry gate
participant P as ParkingService
participant A as AllocationService
participant D as PostgreSQL
G->>P: POST /entries (plate, type, Idempotency-Key)
P->>D: lookup idempotency key
alt key seen before
D-->>P: stored response
P-->>G: same 201 as the first call
else new request
P->>A: claim(facility, vehicle)
A->>D: SELECT candidates (free, fits, nearest) LIMIT 5
loop each candidate until one succeeds
A->>D: UPDATE spot SET state='OCCUPIED' WHERE id=? AND state='AVAILABLE'
end
A-->>P: claimed spot (or none: 503)
P->>D: INSERT ticket + idempotency record (same transaction)
D-->>P: commit
P-->>G: 201 ticket
end
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 with422. - 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:
synchronizedorReentrantLockper 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
PENDINGrow 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 |