Storage: 1000 per day for 300 bytes that is 300k a day , ~ 10MB a month, 100MB a year with projection of 500MB in 5 years
Bandwidth: average request is 300 bytes 1000 cars with 5 requests on average 1.5 MB inbound traffic a day.
The design of user profile, registration, etc. is out of scope
Get a map of all the available spots with their status
GET /spots?from={from}&to={to}
Params:
Statuses
Response
{
spots: [
{
"id": $spotId,
"position": {"x": $x, "y": $y},
"vehicleType": $vehicleType,
"availability": [ {
"from": $from,
"to": $to
}
//...
]
}
//...
]
}
// error
{
"code": code // number
"message": message // can be the key for i18n
}
GET /spots/{id}?from={from}&to={to}
Params:
Statuses
Response
{ "id": spotId, "vehicleType": vehicleType, "availability": [ { "from": from, "to": to } //... ] } // error { "code": code // number "message": message // can be the key for i18n }GET /users/{id}/reservations
Params:
Statuses
Response
[ { "id": spotId, "vehicleType": vehicleType, "from": from, "to": to, "status": "BOOKED|CANCELLED|DELETED|STOPPED" "billed": true|false } ] // error { "code": code // number "message": message // can be the key for i18n }GET /users/{id}/reservations/{rid}
Statuses
Response
{ "id": spotId, "vehicleType": vehicleType, "from": from, "to": to, "status": "BOOKED|CANCELLED|DELETED|STOPPED", "billed": true|false } // error { "code": code // number "message": message // can be the key for i18n }POST /users/{id}/reservations
Statuses
Body:
{ "spotId": spotId, "startTimestamp": startAt, "endTimestamp": endAt, } </pre><p></p><h3>Create a reservation</h3><p>PATCH /users/{id}/reservations/{rid}</p><p><strong>Statuses</strong></p><ul><li>201 no Content</li><li>404: the reservation doesn't exist</li><li>401: if the user is not authenticated</li><li>403: if the user is not authorized to see the reservations</li><li>500 internal server error</li></ul><p><strong>Body:</strong></p><pre data-language="javascript"> { "spotId": spotId, "startTimestamp": startAt, "endTimestamp": endAt, }Response
// error { "code": code // number "message": message // can be the key for i18n }DELETE /users/{id}/reservations/{rid}
Statuses
Response
// error { "code": code // number "message": message // can be the key for i18n }PATCH /users/{id}/reservations/{rid}/stop
Statuses
Body:
{ "timestamp": timestamp } </pre><p><strong>Response</strong></p><pre data-language="javascript"> // error { "code": code // number "message": message // can be the key for i18n } </pre><p></p><h3>Gate Check-In</h3><p>PATCH /users/{id}/reservations/{rid}/check-in</p><p><strong>Statuses</strong></p><ul><li>201 no content</li><li>404 the reservation doesn't exist</li><li>401: if the user is not authenticated</li><li>403: if the user is not authorized to see the reservations</li><li>500 internal server error</li></ul><p><strong>Body:</strong></p><pre data-language="javascript"> { "timestamp": timestamp }Response
// error { "code": code // number "message": message // can be the key for i18n }PATCH /users/{id}/reservations/{rid}/check-out
Statuses
Body:
{ "timestamp": timestamp } </pre><p><strong>Response</strong></p><pre data-language="javascript"> // error { "code": code // number "message": message // can be the key for i18n } </pre><p></p><h3>Finalize reservation</h3><p>POST /users/{id}/reservations/{rid}/finalize</p><p><strong>Statuses</strong></p><ul><li>201 no content</li><li>404 the reservation doesn't exist</li><li>401: if the user is not authenticated</li><li>403: if the user is not authorized to see the reservations</li><li>500 internal server error</li></ul><p><strong>Body</strong></p><pre data-language="javascript"> { "timestamp": timestamp }Response
// error { "code": code // number "message": message // can be the key for i18n }POST /users/{id}/reservations/{rid}/pay
Statuses
Body
{ "timestamp": timestamp } </pre><p><strong>Response</strong></p><pre data-language="javascript"> // error { "code": code // number "message": $message // can be the key for i18n }Notes on Design:
Design at scale, multi-location:
With the introduction of multi-location, we need to allow a new functionality for geolocation search, which makes something like Elasticsearch for full-text search very appealing.
All the components are horizontally scalable, but the real challenge is how to make the changes in the parking spot hold linearizable so we don't get conflicts and double bookings.
A solution is to use the geo-location sharding and make sure that the writes happen only on the
leader of this shard, or at least introduce read repair across multi location cluster.
The database is pretty straightforward: we are using a structured database because we are not dealing with unstructured data
lots:
slots:
user_info:
id: unique, primary key
user_vehicles:
reservations:
payment:
Once we scale to multi-location, the sharding should be done based on geo locations for the slots and reservations.
Users can be sharded in multiple ways:
The five questions all point at the same design tension: search needs to be cheap and can tolerate staleness, but the commit path cannot let two people hold the same spot. Here's how the reservation flow resolves that.
The core mechanism is a Postgres exclusion constraint on the reservations table:
sql
ALTER TABLE reservations
ADD CONSTRAINT no_overlapping_reservations
EXCLUDE USING gist (
spot_id WITH =,
tstzrange(from_ts, to_ts) WITH &&
) WHERE (status IN ('HOLD', 'RESERVED', 'CHECKED_IN'));
For a given spot_id, no two active rows can have overlapping time ranges, enforced atomically at insert time by the database engine. Two transactions racing to book the same spot and window: one commits, the other fails with a constraint violation, which the Reservation service turns into a 409.
This beats SELECT ... FOR UPDATE plus an application-level overlap check, because that pattern requires correctly identifying which rows to lock, and range overlaps are an easy place to get that subtly wrong. The exclusion constraint also works under READ COMMITTED; it doesn't need SERIALIZABLE, which would cost throughput the system doesn't need to spend.
Linearizability only actually matters across replicas. If the Reservation service's database has read replicas or the system spans regions, every write for a given spot needs to land on the same leader, or two leaders could each locally accept conflicting holds before replication catches up. That's why sharding by spot_id (or lot_id) matters: writes route to that shard's single leader, reads can go anywhere.
The flow:
A client searches and gets back spots that are probably free, served from a read replica or, at multi-location scale, from an eventually-consistent search index. The client picks one and sends POST /reservations with spotId, from, to. The Reservation service opens a transaction and inserts with status = HOLD. The exclusion constraint evaluates against the leader's current committed state, not against whatever the client saw during search. Success returns 201; failure returns 409, optionally with a few alternative spots for that vehicle type.
The client's search-time view is never trusted for the actual decision. Staleness in search results can only produce a false positive (a spot looked free and wasn't), never a real double-booking, because the constraint catches it before commit. This is also why the multi-location Elasticsearch index doesn't need cache-invalidation machinery: it's discovery-only. It can lag behind the DB by however long pub/sub propagation takes, and the worst case is a user clicking a spot that just got taken and seeing alternatives instead. A UX hiccup, not a consistency violation. Same argument covers the "no cache for 500 spots" call in the base design: any cache or index anywhere in this system only ever feeds the search step, never the commit.
Contention on a popular lot doesn't mean contention on a popular spot. Fifty users hitting reserve on the same lot but different spots produce inserts that don't conflict at all, since the exclusion constraint is per spot_id. Real contention only happens when many users target the same spot and time, which is a much smaller number of concurrent writers than "everyone wants this lot right now."
A few things keep that narrow case cheap:
Losing transactions fail fast rather than blocking behind a lock, so p99 latency stays low under contention; the cost lands on the client, which needs to retry against a different spot. The 10-minute HOLD TTL limits how long a losing attempt keeps a spot looking unavailable to everyone else. Rate limiting per user at the gateway stops a client from turning its own retries into a self-inflicted spike. The API and Reservation service tiers scale horizontally to absorb request fan-in; the actual bottleneck is the single-leader shard, which is fine because the write itself is a single small row insert, not compute-heavy.
For something like a stadium lot before a game, a lightweight in-memory queue per spot at the Reservation service could smooth bursts before they hit the DB. At 500 spots and roughly a thousand cars a day, that's more machinery than the load justifies; the exclusion constraint alone handles it.
Duplicate requests show up wherever a client action crosses a flaky network and gets retried: HOLD/reserve, and payment.
Write endpoints take an Idempotency-Key header, generated client-side per logical action. A small table maps key to reservation ID:
idempotency_keys: key (PK), reservation_id, created_at
On POST /reservations, the service checks for the key first. If it exists, it returns the original result instead of re-executing. If not, it executes and stores the mapping in the same transaction as the insert, so there's no window where a retry slips through.
Payment reuses the pattern, keyed on reservation_id (a unique constraint on payment.reservation_id is sufficient, since a reservation is only ever paid once). Paired with the payment provider's own idempotency-key support, that gives exactly-once semantics end to end even though the event log delivers at-least-once. Every event consumer, payment trigger, analytics, no-show handler, tracks processed event IDs and skips duplicates for the same reason: a redelivered "checked-out" event shouldn't be able to trigger a second payment attempt even with idempotent logic downstream.
Single leader per shard is simple and correct, but it caps a lot's write availability at that one leader's uptime. Higher write availability per shard would mean consensus (Raft-backed leader election with fast failover) instead of a static leader. Out of scope for a 99% SLA, but worth having an answer ready for "what happens if the leader dies mid-peak-hour."