Design a Booking System
Design a booking system step by step: searchable hotel inventory, atomic multi-night holds, payments, confirmations, cancellations, refunds, idempotency, hot inventory, failures, and multi-region evolution.
Page content
We are designing a generic Booking System. You are the candidate and I am the interviewer.
Alice searches for Hotel A, sees one room left, and begins checkout for August 20–23, 2026. At almost the same instant, Bob selects the same room type and dates. Search should remain fast for both people, but only one can acquire the final room.
That small race contains the central design problem. Browsing can tolerate a stale hint. Selling inventory cannot.
Before drawing services, I would ask the questions that change the answer:
- Are resources interchangeable counts, such as 20 deluxe rooms, or named items, such as seat 12A?
- Is inventory consumed for one time slot or for every date in a range?
- Do we need temporary holds while payment runs?
- Which steps are synchronous from the customer’s perspective?
- Can price or availability change between search and checkout?
- What cancellation and refund behavior is required?
- What are normal and exceptional peaks? Is this hotel traffic or a concert-like sale?
- Is one region enough initially? What happens during a regional outage?
Rather than wait for answers, I will state assumptions and challenge them as the design evolves.
Primary example Hotel room-type booking
Resource A room type at one property
Inventory unit Interchangeable room count per stay date
Complete flow Search → quote → hold → pay → confirm → view/cancel/refund
Daily active users 10 million
Searches 20/user/day, about 200 million/day
Search peak about 45,000–50,000 requests/second
Booking attempts 5 million/day; about 58/second average
Booking campaign peak about 5,000 attempts/second
Exceptional hot sale 10,000–100,000 attempts/second
Typical booking 1 room for 2–4 nights
Catalog 500,000 properties, 5 room types/property
Sellable window 365 days
Hold duration usually 5–10 minutes
Deployment one region first; home-region ownership later
These are planning values, not promises. Search conversion, date-range distribution, response size, property skew, payment latency, cancellation rate, and database behavior under lock contention require measurement.
1. Clarify the boundary and inventory model
The first version includes:
- Search properties and room types by destination, dates, guests, and filters.
- Get an authoritative quote and availability check.
- Place a short-lived hold across every stay date.
- Pay and confirm the booking.
- View a booking.
- Cancel according to policy and start any refund.
Recommendation internals, hotel onboarding, payment-provider internals, and a complete search-engine design are excluded. We still define their boundaries because failures cross them.
Count-based versus item-based inventory
Hotel A may have 20 interchangeable deluxe rooms. The resource is HotelA/Deluxe; inventory for August 20 is a count:
total = 20, held = 2, booked = 17, available = 1
The governing invariant is:
For every resource and stay date:
held + booked <= total
Equivalently:
available = total - held - booked >= 0
A cinema or aircraft often sells named items. Seat 12A has one state, and a uniqueness constraint on (show_id, seat_id) can choose the winner. Hotel rooms are usually assigned later, so forcing customers to choose physical room 412 creates operational complexity without product value.
We choose count-based inventory. The same architecture can support item-based resources, but its reservation operation would claim unique item IDs rather than increment date counters. We will not design both deeply.
A booking spans dates, not one row
A stay from August 20 to August 23 consumes the nights of August 20, 21, and 22; checkout day is conventionally excluded. All three rows must succeed or none may change.
HotelA/Deluxe
2026-08-20 reserve 1
2026-08-21 reserve 1
2026-08-22 reserve 1
Reserving Monday and failing Tuesday is not a partial success. It is a corrupted product outcome.
2. Requirements, consistency, retention, and edge cases
Functional requirements
The system must support the complete customer journey, preserve price and policy snapshots, expose current booking/payment/refund state, and keep an auditable history of externally meaningful transitions.
Search results may say “one room left,” but the detail/hold operation is the point at which inventory is authoritative. A successful hold promises capacity until its deadline, subject only to explicitly documented exceptional policy.
Non-functional goals
Search latency p95 around 300 ms under normal load
Hold/confirm latency low hundreds of ms excluding provider latency
Search availability high, with honest degraded results
Inventory correctness no oversell
Durability no acknowledged hold/booking silently lost
Throughput absorb normal peaks; protect hot inventory
Retry safety idempotent customer and provider operations
Auditability versioned states and immutable event trail
Fault tolerance bounded retries, recovery, and compensation
“Highly available” does not mean returning an invented answer. If the authoritative inventory database is unavailable, the service can remain reachable and return 503 Try later; it must not turn a cached “available” hint into a sale. Service availability and truthful availability are different.
Consistency is chosen per datum
Search document/cache eventually consistent derived projection
Displayed availability stale, timestamped hint
Inventory mutation strongly serialized per resource/date
Booking/payment state durable, versioned state machine
Notifications/analytics eventually consistent
We do not pay for global strong consistency where delay is harmless. We do require one serialization point for every inventory counter that can participate in the same sale.
Retention
Bookings, payment references, refunds, and financial/audit transitions may remain for years, subject to legal and privacy policy. Personally identifiable data should be minimized, encrypted, access-controlled, and deleted or tokenized when policy permits.
Holds live minutes, but their audit rows may remain for weeks or months for support and abuse analysis. Idempotency records should outlive the maximum retry window—perhaps 24 hours for ordinary mutations and longer for financial operations—then be archived or deleted by policy. Outbox and event-bus retention is bounded, commonly days or weeks; durable business state is not reconstructed solely from an infinite log.
Edge cases worth naming early
- Alice and Bob both observe the final unit.
- One night in a multi-night stay sells out.
- A client times out after a successful hold and retries.
- The same idempotency key arrives concurrently with different payloads.
- Price or cancellation policy changes after search.
- Payment succeeds but confirmation times out or crashes.
- A hold expires while payment is in flight.
- Duplicate and out-of-order provider webhooks arrive.
- Cancellation races confirmation, expiry, or a second cancellation.
- Inventory is reduced below existing commitments because a room goes out of service.
- A hot sale concentrates a million users on 100 units.
- A clock moves or an expiry worker stops.
3. Estimate scale before choosing components
Read traffic
10,000,000 users × 20 searches/day = 200,000,000 searches/day
200,000,000 / 86,400 ≈ 2,315 average search QPS
20× peak ≈ 46,300 search QPS
At perhaps 10–20 KB compressed per response, a 50,000 QPS peak produces roughly 500 MB–1 GB/s of API egress before protocol overhead. Images belong behind a CDN as URLs, not inside responses.
The 20× multiplier is only a starting hypothesis. Holidays and campaigns correlate destination, date, and cache keys. We should replay production-like query distributions and test with a zone unavailable.
Booking and inventory traffic
5,000,000 attempts/day / 86,400 ≈ 58 attempts/second average
Campaign peak assumption ≈ 5,000 attempts/second
Typical 3-night stay 3 inventory rows per hold
Hold + confirm or release roughly 6 counter-row mutations/attempt
At 5,000 attempts/second, holds alone may inspect or update about 15,000 date rows/second before retries and expiry. A failed attempt still consumes validation, locking, and connections. Exceptional 10,000–100,000/s sales are an admission-control problem before they are a storage problem.
Peak and skew matter more than average. Five thousand attempts spread over millions of room types is easy compared with 500 attempts queued on one final inventory row.
Inventory cardinality
A naive dense upper bound is:
500,000 properties × 5 room types × 365 dates
= 912.5 million inventory rows
That is large but not automatically impossible; the mistake is treating it as a mandatory, uniformly hot table. Some properties expose shorter windows, close seasons, or use default capacity calendars plus sparse overrides. We can generate near-window rows, archive expired dates, compress historical facts, and partition by property/resource and time.
The transaction path still needs a concrete authoritative row—or an equivalent conditional counter—for each sellable resource/date. Aggregating weekly inventory cannot safely answer a particular Tuesday.
Booking storage
Suppose 5 million attempts/day yield 1 million confirmed bookings/day. If a booking plus indexes, state history, payment references, and three items averages a few KB:
1,000,000 bookings/day × 3–8 KB
≈ 3–8 GB/day
≈ 1–3 TB/year before replicas, backups, and index overhead
The range is intentionally broad. JSON snapshots, index count, audit policy, and cancellation history dominate it. Partitioning and archival become relevant over years, not because a single booking row is large.
4. Define product APIs before architecture
All IDs are opaque. Authentication identifies the user or trusted partner; authorization verifies booking ownership and operational roles. Dates use the property’s business timezone and explicit check-in/check-out semantics.
Search
GET works for bounded filters; a POST /search can be offered when structured filters become too large for safe URLs.
Request:
GET /v1/search?destination=Goa&check_in=2026-08-20&check_out=2026-08-23&guests=2
Authorization: Bearer <token>
Response:
200 OK
{
"results": [{
"resource_id": "rt_HotelA_Deluxe",
"property_name": "Hotel A",
"indicative_price": {"amount": 42000, "currency": "INR"},
"availability_hint": "LOW",
"observed_at": "2026-08-16T15:20:00Z"
}],
"next_cursor": "opaque"
}
This is not a promise. The response labels its freshness.
Authoritative quote and availability
Request:
GET /v1/resources/rt_HotelA_Deluxe/quote?check_in=2026-08-20&check_out=2026-08-23&quantity=1&guests=2
Authorization: Bearer <token>
Response:
200 OK
{
"resource_id": "rt_HotelA_Deluxe",
"available": true,
"price": {"amount": 42000, "currency": "INR"},
"policy_id": "policy_91",
"quote_token": "opaque-signed-or-server-side-token",
"expires_at": "2026-08-16T15:25:00Z"
}
The quote token binds resource, dates, quantity, occupancy, price, currency, policy, and version. It is opaque so clients cannot manufacture versions. A stale price returns 409 PRICE_CHANGED with a new quote; sold out returns 409 SOLD_OUT. Invalid ranges are 400; unauthenticated and unauthorized requests are 401 and 403.
Create a hold
Request:
POST /v1/holds
Authorization: Bearer <token>
Idempotency-Key: 01J...client-generated
Content-Type: application/json
{
"resource_id": "rt_HotelA_Deluxe",
"check_in": "2026-08-20",
"check_out": "2026-08-23",
"quantity": 1,
"quote_token": "opaque"
}
Response:
201 Created
{
"hold_id": "hold_123",
"status": "ACTIVE",
"expires_at": "2026-08-16T15:31:00Z",
"amount": {"amount": 42000, "currency": "INR"},
"hold_version": "opaque"
}
The server atomically reserves every night. 409 SOLD_OUT means no rows changed. 422 covers a semantically invalid guest/quantity combination. A repeated key with the same request returns the stored or in-progress result.
Pay and confirm
A product may expose POST /bookings over a hold or split payment intent from confirmation. The split form makes asynchronous providers clearer.
Request:
POST /v1/holds/hold_123/confirm
Authorization: Bearer <token>
Idempotency-Key: 01J...stable-confirm-key
Content-Type: application/json
{
"payment_method_token": "pm_provider_token",
"hold_version": "opaque"
}
Response:
202 Accepted
{
"booking_id": "book_456",
"booking_status": "PAYMENT_PENDING",
"payment_status": "PENDING",
"poll_url": "/v1/bookings/book_456"
}
Fast providers may complete synchronously and return 201 CONFIRMED; 202 is honest when the outcome is pending. A network timeout is not evidence that payment failed.
View and cancel
Request:
GET /v1/bookings/book_456
Authorization: Bearer <token>
Response:
200 OK
{
"booking_id": "book_456",
"status": "CONFIRMED",
"payment_status": "CAPTURED",
"refund_status": null,
"resource_id": "rt_HotelA_Deluxe",
"check_in": "2026-08-20",
"check_out": "2026-08-23",
"quantity": 1,
"version": 4
}
Cancel request:
POST /v1/bookings/book_456/cancel
Authorization: Bearer <token>
Idempotency-Key: 01J...stable-cancel-key
Content-Type: application/json
{"reason": "CHANGE_OF_PLANS", "expected_version": 4}
Cancel response:
202 Accepted
{
"booking_id": "book_456",
"status": "CANCELLED",
"refund_status": "PENDING",
"estimated_refund": {"amount": 40000, "currency": "INR"}
}
Cancellation can be accepted before money returns. A policy conflict or stale version is 409; the response must not leak another user’s booking.
Payment webhook
POST /v1/payment-webhooks/provider-x
Provider-Signature: <verified-signature>
{
"event_id": "evt_789",
"payment_attempt_id": "pa_321",
"provider_payment_id": "pay_xyz",
"type": "payment.authorized"
}
After signature and replay-window validation, the endpoint deduplicates event_id, records the payload reference, and returns quickly. State/version checks handle duplicates and out-of-order events.
5. Model the durable facts
SQL is the baseline because constraints, row locks, conditional writes, multi-row transactions, and auditable relationships match the access patterns.
Core entities
Users — user_id primary key. Identity and minimal customer profile; sensitive payment details remain tokenized at the provider.
Resources/RoomTypes — resource_id primary key, property_id, occupancy and attributes. Index (property_id, active) supports catalog reads.
Inventory — primary key (resource_id, stay_date), with total, held, booked, version. Add checks total >= 0, held >= 0, booked >= 0, and held + booked <= total. available is derived. Storing it independently risks disagreement; if materialized for performance, every mutation must update it in the same statement and constraints must prove equivalence.
Holds — hold_id primary key, user_id, status, expires_at, quote/policy/price snapshot, version, timestamps. Index (status, expires_at) drives expiry. An active business key may prevent duplicate logical holds where product semantics require it.
HoldItems — primary key (hold_id, resource_id, stay_date), quantity and price allocation. Every stay date is explicit.
Bookings — booking_id primary key, unique hold_id, user_id, status, total, currency, policy snapshot, version. Index (user_id, created_at desc) supports “my bookings.”
BookingItems — primary key (booking_id, resource_id, stay_date), quantity and immutable booked price details.
Payments/PaymentAttempts — internal attempt ID primary key; unique (provider, provider_payment_id) when known; booking ID, amount, currency, status, provider idempotency key, request/response references, version.
Refunds — refund ID primary key; booking/payment ID, amount, status, provider refund ID with uniqueness, reason, timestamps.
IdempotencyKeys — (scope, user_id, key) unique, plus request_hash, status, lease owner/deadline, resulting resource ID, serialized response/status, created/expiry timestamps.
OutboxEvents — event_id primary key, aggregate type/ID, aggregate version, event type, payload, created/published timestamps, attempts. Index unpublished rows by creation time. Consumers may enforce unique event_id.
Relationships and partitioning
A hold owns many hold items; one confirmed hold maps to at most one booking; a booking owns items, payment attempts, and refunds. Immutable snapshots preserve what Alice agreed to even if today’s room price or policy changes.
Partition inventory primarily by resource_id or property so every date for one room type lands in one database shard. Bookings can partition by home resource/property, tenant, or geography, with a separate user-to-booking lookup projection if needed. Time subpartitioning helps archive expired inventory and old bookings.
If inventory and booking tables are separated into different physical databases too early, hold-to-booking conversion becomes a distributed transaction. Keep the correctness boundary together initially. Later separation requires a carefully designed saga and ownership contract, not merely two services.
A NoSQL store can be reasonable at extreme scale if it provides conditional updates and transactional writes across all dates of one booking, or if the model safely collapses the operation into one atomic partition item. Without that mechanism, “NoSQL scales” does not preserve the invariant.
6. Begin with the simplest viable design
The first deployable system is one application and one relational database:
Client → Booking Application → SQL Database
│
└────────→ Payment Provider
The application runs catalog/search SQL, authoritative inventory transactions, hold expiry, booking workflows, and outbox relay. This is easier to test and operate. Logical modules still separate Search, Booking, Inventory, and Payment Adapter responsibilities, but they need not be network services or own separate databases.
At low traffic, SQL search using destination/property indexes is adequate. It stops fitting when 50,000 searches/s need text relevance, geo, facets, broad date filters, and independent read scaling. That concrete query and traffic pressure justifies a derived search index.
Similarly, Redis is not mandatory for correctness. It becomes useful for hot query/property projections, rate limits, and scheduling hints when measurements show repeated reads or coordination pressure.
Every architectural evolution must preserve:
For each resource/date, held + booked <= total.
Derived search and caches cannot mutate or authorize inventory, so they cannot weaken this invariant.
7. Prevent double booking at the database
Suppose the authoritative row has available = 1.
Alice reads 1 Bob reads 1
Alice decides "yes" Bob decides "yes"
Alice writes held = held + 1 Bob writes held = held + 1
If read and write are separate unprotected operations, both can succeed. Application code checked the rule, but no shared serialization point enforced it.
Compare concurrency choices
In-process lock: works only inside one process. A second application instance bypasses it, and process failure complicates ownership.
Redis distributed lock: can reduce contention, but lease expiry, pauses, partitions, failover, and fencing errors make it unsafe as the sole authority. It may be an optimization only while the database still rejects oversell.
SELECT ... FOR UPDATE: lock each inventory row in a transaction, verify capacity, then update. It is clear and supports multi-date reservations, but hot rows queue and long transactions consume connections.
Optimistic versioning: read version, update with WHERE version = ?, and retry on conflict. It performs well with low contention; a hot final room causes many conflicts and wasteful retries.
Atomic conditional update: combine validation and mutation:
UPDATE inventory
SET held = held + :quantity,
version = version + 1
WHERE resource_id = :resource_id
AND stay_date = :stay_date
AND total - held - booked >= :quantity;
An affected-row count of zero means sold out or conflict. The database decides the winner.
Multi-night atomicity
For August 20–23:
- Begin one local database transaction.
- Address rows in deterministic
(resource_id, stay_date)order. - Lock then validate all rows, or issue conditional updates and verify every expected row.
- Insert the hold and all hold items.
- Commit only if every date succeeded; otherwise roll back all changes.
Deterministic ordering reduces deadlocks. Retrying a deadlock is safe only under the same idempotency record. For a small 2–4-night set, explicit row locks are often easiest to reason about. Conditional updates remain valuable, but code must not commit after only two of three updates.
The check constraint is defense in depth. Domain checks provide good errors; transaction isolation and constraints provide final correctness.
Under contention, optimistic retries can amplify load and row locks can create queues. Use short transactions, strict lock timeouts, bounded retries with jitter, and admission control. Regardless of technique, verify again:
Alice or Bob may win, but affected rows can commit only while
held + booked <= total for every date.
8. Holds bridge inventory and slow payment
Why not keep the inventory transaction open while calling the payment provider? Provider latency is unknown. A seconds-long call would retain row locks and database connections, couple database recovery to provider health, and turn a payment slowdown into inventory outage.
Instead:
AVAILABLE capacity → HELD → BOOKED
└→ EXPIRED → capacity returned
The hold transaction inserts hold_id, user, ACTIVE, expires_at, quantity, quote/version, and items while incrementing inventory.held. It commits before payment begins.
Expiring holds
The database clock sets expires_at, avoiding disagreement among application hosts. Workers scan an index:
SELECT hold_id
FROM holds
WHERE status = 'ACTIVE' AND expires_at <= CURRENT_TIMESTAMP
ORDER BY expires_at
FOR UPDATE SKIP LOCKED
LIMIT :batch_size;
For each batch, one short transaction changes ACTIVE → EXPIRED, decrements held for every item, and writes an outbox event. A status/version predicate makes release idempotent.
A delayed queue or Redis sorted set can wake workers near the deadline, reducing scans. Both are scheduling hints. Redis key-expiry notifications can be lost, duplicated, or unavailable and therefore cannot own capacity release.
If a worker crashes before commit, nothing changed and another worker retries. If it crashes after commit, duplicate work observes EXPIRED and does not decrement again. Late messages follow the same rule. Reconciliation compares active holds, expiry times, hold items, and counters.
Payment racing expiry needs policy:
- Before starting provider work, atomically change the hold to
PAYMENT_PENDINGor extend a bounded processing deadline if policy permits. - Confirmation locks/checks the hold version and accepts only a valid state.
- Expiry and confirmation cannot both consume/release because one state transition wins.
- If payment later succeeds after capacity was released, void/refund it; never recreate inventory by ignoring the winner.
After every path, held + booked <= total.
9. Keep booking and payment state machines distinct
A useful booking/hold journey is:
HOLD_ACTIVE → PAYMENT_PENDING → CONFIRMED
│ │
└→ EXPIRED └→ PAYMENT_FAILED
CONFIRMED → CANCEL_PENDING → CANCELLED
Payment/refund has its own journey:
CREATED → AUTHORIZING → AUTHORIZED → CAPTURED
└→ FAILED
CAPTURED → REFUND_PENDING → REFUNDED
└→ REFUND_FAILED
CANCELLED and REFUND_PENDING can coexist. Combining both machines into one status produces ambiguous states such as “cancelled but provider outcome unknown.”
A saga, not a cross-company transaction
The database and provider cannot share one ACID transaction. The workflow is a saga: durable local steps followed by compensation when a later step fails.
Authorize-then-capture reduces the chance of taking money without inventory confirmation: authorize against a committed hold, atomically confirm, then capture. But authorization can expire, capture can still fail, and some payment methods do not support it.
Charge-then-confirm supports more methods but increases refunds when confirmation cannot complete. The product must display pending states honestly and operate reconciliation.
Whether authorization is enough to call the booking CONFIRMED is a product and provider contract, not a universal rule. If capture is required first, convert the hold to a durable CONFIRMATION_PENDING booking, capture outside the transaction, and then mark it CONFIRMED. A terminal capture failure cancels that pending booking and releases capacity exactly once. If the provider’s authorization is an acceptable payment guarantee, the booking may be confirmed before capture, but failed capture still needs retries, reconciliation, and an eventual cancel/void path.
Payment succeeds but confirmation fails
Persist a payment attempt before the provider call and send a stable provider idempotency key. On success or timeout, retain the provider reference and retry confirmation if the hold remains valid. A bounded PAYMENT_PENDING extension can protect capacity while the outcome is resolved.
If capacity was legitimately released, void an authorization or refund a charge. A reconciliation worker queries the provider for UNKNOWN attempts and repairs local state. Never tell Alice “payment failed” merely because our HTTP response timed out.
Webhooks are authenticated, deduplicated by provider event ID, and applied only when their event type and aggregate version represent a valid transition. An old authorized event arriving after refunded is recorded for audit but cannot move state backward.
10. Make every mutation retry-safe
Client retries are inevitable: mobile networks lose responses, gateways time out, and users double-click.
Hold, confirm/payment-intent, and cancel APIs accept Idempotency-Key. The table stores:
scope, user_id, key, request_hash, status,
lease_owner, lease_expires_at,
resource_id, response_code, response_body,
created_at, expires_at
A unique constraint on (scope, user_id, key) resolves concurrent duplicates:
- Same key and same hash with
COMPLETED: return the stored result. - Same key and same hash with
IN_PROGRESS: wait briefly or return202plus a status location. - Same key with a different hash: return
409 IDEMPOTENCY_KEY_REUSED.
Where possible, create/claim the idempotency row and perform the business mutation in one transaction. If the owner crashes while an external call is running, a lease may expire and a worker resumes from durable payment-attempt state; it does not blindly repeat the provider charge.
Provider calls use a stable key derived from the internal payment attempt. Webhooks and event consumers use separate event IDs because API idempotency does not deduplicate asynchronous delivery.
Retention follows the real retry and dispute window. Deleting a key after five minutes while a provider retries for a day reopens duplication risk.
11. Publish events without dual-write loss
Imagine confirmation executes:
1. UPDATE booking SET status = 'CONFIRMED'
2. COMMIT
3. publish BookingConfirmed to Kafka
4. application crashes before step 3
The booking exists, but email, search projections, and analytics never hear about it.
The transactional outbox fixes this by committing the booking change and an outbox_events row in the same SQL transaction. A relay reads unpublished events, publishes, waits for broker acknowledgment, then marks them published.
Delivery is at least once. A crash after broker acknowledgment but before marking the row republishes it. Consumers deduplicate by event_id and ignore aggregate versions they have already applied. Partition by booking_id where per-booking order matters; use resource_id on inventory-oriented topics where that order matters.
Retries use bounded exponential backoff and jitter. Poison events go to a dead-letter workflow with alerts, not an invisible graveyard. Monitor oldest unpublished event age, relay errors, consumer lag, dedup rate, and DLQ depth.
Kafka or another event bus is justified when notifications, search indexing, analytics, buffering, and replay need independent asynchronous consumption. It is not in the inventory transaction critical path.
If Kafka is down, bookings and outbox rows still commit. Notifications and search hints lag while the outbox accumulates within storage and backpressure limits. The inventory invariant remains true.
12. Cancel and refund without leaking capacity
Cancellation first verifies authentication, booking ownership or staff authorization, policy, and expected version.
In one local transaction:
- Lock/version-check the booking.
- Transition a valid state to
CANCELLEDorCANCEL_PENDING. - Decrement
inventory.bookedexactly once for every booking item when policy releases capacity. - Create a durable refund request and outbox event if money is owed.
- Commit.
A simplistic UPDATE bookings SET status='CANCELLED' strands inventory. Releasing inventory before winning the booking transition can release twice.
The refund worker calls the provider with a stable idempotency key. Refund may remain PENDING; customer support sees the durable state. A second cancellation returns the original result. Cancellation racing a payment callback, hold expiry, or confirmation is resolved by row/version predicates and explicit allowed transitions.
The inventory equation after cancellation is still:
available = total - held - booked >= 0
13. Separate search availability from booking availability
Write these labels explicitly in an interview:
SEARCH AVAILABILITY cached/indexed hint, stale by design
AUTHORITATIVE AVAILABILITY transaction-time inventory decision
Initially, SQL supports modest property filters. At 50,000 search QPS with text, geo, facets, and sorting, an OpenSearch-like index becomes useful. Its denormalized document contains property metadata, searchable attributes, indicative price, and coarse/date availability projections.
Outbox or CDC events update that index asynchronously. The search result can be stale between an inventory commit and indexing. Detail/quote and hold always recheck Inventory SQL.
Redis may cache hot query results and property projections using cache-aside:
read cache → miss → bounded search/index read → fill with TTL
Keys must include destination, dates, occupancy, filters, currency, and version-sensitive context. Add TTL jitter, request coalescing, and per-key refresh limits to prevent stampedes. Invalidation accelerates freshness but does not make the cache authoritative.
If Redis fails, bypass it with rate limits and protected downstream capacity. If the search index fails, serve safe cached results or a simpler, tightly limited SQL fallback. Never redirect 50,000 QPS blindly to the transactional inventory database.
14. Protect hot inventory
Normal hotel traffic is distributed enough that a waiting room may be unnecessary. A concert-like sale is different:
1,000,000 users → 100 units → one hot row
The conditional SQL update still prevents oversell, but lock queues, retries, and database connection exhaustion can make the entire system unavailable.
Use edge/API rate limits, bot and abuse controls, signed virtual-waiting-room admission tokens, bounded admission queues, short request deadlines, and a sold-out fast path. Booking workers process only admitted demand. If fairness is promised, tokens and server-side sequence rules must define it; a generic Kafka queue alone neither guarantees global fairness nor prevents duplicate customer requests.
Keep retries bounded and jittered. Shed excess work before it takes a scarce database connection. Isolate hot-event pools so one event cannot starve ordinary hotels.
An advanced option preallocates escrow capacity to shards:
100 units → shard A: 25, B: 25, C: 25, D: 25
Each shard sells only its budget, preserving the global bound, then reconciles transfers. This raises throughput but strands capacity when one shard has demand and another does not; budget transfer needs fencing and audit. Use it only after simpler admission control and a single authoritative counter fail benchmarks.
15. Scale along correctness boundaries
Application nodes are stateless behind load balancers. Search scales with index shards/replicas and caches. SQL mutations use the primary; read replicas serve only reads where staleness is safe, never the winner decision for a hold.
Before sharding, understand the requirement: one hold must update all of its dates atomically. Partition inventory by property/resource and keep its sellable date window together. Do not hash each date independently and then discover that a three-night stay requires distributed transactions.
Bookings may shard by resource home, tenant, or geography. A user-facing booking list can use an asynchronous user projection or routing directory. Historical data moves to cheaper storage while recent operational indexes remain bounded.
Watch for hot partitions: property popularity is not uniform. Split only with an ownership protocol that retains one writer per inventory unit, or use carefully reconciled escrow.
Event topics and consumer groups scale independently. Partition by booking_id for booking-state order and by resource_id for inventory-projection order; separate topics may be clearer than pretending one key satisfies both. Bounded queues, pause/resume, lag-based load shedding, and downstream concurrency limits provide backpressure.
16. Evolve the logical architecture
As query shape and team ownership demand it, split logical components:
- Search owns query serving and derived projections, not inventory truth.
- Booking orchestration owns the customer workflow and aggregate states.
- Inventory owns serialized counters and hold/confirm/release transactions.
- Payment adapter isolates provider protocol, idempotency, and reconciliation.
These can remain modules sharing one SQL cluster initially. A service boundary does not require immediate database-per-service purity. If Inventory and Booking later use separate stores, define which local transaction owns each invariant and use a saga for cross-store progress.
The invariant is unchanged after the split: only Inventory’s authoritative SQL mutation may change held or booked, and its constraints keep held + booked <= total.
17. Design failure as an explicit product state
Failure walkthrough
Payment succeeds, confirmation fails: payment attempt remains durable and UNKNOWN/AUTHORIZED; Alice sees processing. Retry confirmation if the protected hold is valid; otherwise void/refund and release. Reconciliation queries the provider.
Crash after inventory hold: the committed hold remains active. Idempotent retry returns it; expiry eventually releases it. If the transaction did not commit, both hold and counters roll back.
Kafka down: booking correctness continues through SQL and outbox. Notifications, analytics, and search hints lag. Alert on outbox age and cap nonessential producers before storage fills.
Search index down: serve bounded cache or degraded SQL search under strict quotas. Authoritative holds continue. Do not flood inventory SQL.
Redis down: bypass optional caches/scheduling hints with downstream bulkheads, or degrade. DB expiry scans remain authoritative.
Inventory primary failover: in-flight transactions abort and safe idempotent calls retry after leader discovery with jitter. Fencing prevents the old primary accepting writes. Cached availability is not promoted to truth.
Client retry: the unique idempotency record returns or resumes one business result; different payload under the key conflicts.
Hold expires during payment: an atomic state/version winner decides. If expiry wins and payment later succeeds, void/refund. If payment-processing wins, expiry waits until its bounded deadline.
Duplicate/out-of-order webhook: unique provider event ID deduplicates; transition/version rules prevent state regression.
Two users, last unit: database lock or conditional mutation picks one. The loser receives 409 SOLD_OUT; counters never go negative.
Expiry worker down: active holds remain unavailable longer, reducing sales but not overselling. Alert on overdue-hold age; restart workers and reconcile idempotently.
Outbox relay duplicates: consumers deduplicate event IDs and apply only newer aggregate versions.
Hot-event overload: edge admission, bounded queues, pool isolation, no uncontrolled retries, and a sold-out fast path prevent cascade. SQL remains final authority.
One region fails: affected search can degrade regionally. Inventory mutation stops until the home region recovers or a replica is safely promoted and fenced; never allow two homes to write.
Common resilience mechanics
Timeout every remote call. Retry only transient errors and only when the operation is idempotent. Use exponential backoff with jitter and retry budgets. Circuit breakers need meaningful fallbacks: cached search is meaningful; cached authorization to sell inventory is not.
Bound queues and connection pools so latency does not grow without limit. Reconciliation detects impossible or stuck combinations across holds, counters, bookings, payments, refunds, and provider records. Alerts should cover invariant violations, lock waits, sold-out conflict rate, overdue holds, unknown payments, refund age, outbox age, consumer lag, pool saturation, and regional replication lag.
18. Add multi-region only after single-region correctness
Assign every resource/inventory partition one authoritative home region:
Hotel A inventory home: India region
Hotel B inventory home: Europe region
Search indexes, caches, and read projections can be active-active in every region and eventually consistent. Booking mutations route to the resource’s home, accepting cross-region latency when a traveler books distant inventory.
If India and the US independently accept writes against Hotel A during a partition, both can sell the same final room. Async conflict resolution cannot un-oversell it.
On home-region failure, choose one:
- Stop mutations for affected resources temporarily, preserving correctness.
- Promote a sufficiently replicated standby using a quorum/control-plane decision and a new fencing epoch, then reject the old writer.
Define RPO and RTO. Synchronous cross-zone replication may provide near-zero RPO inside a region; cross-region asynchronous replication can lose the newest committed states unless the product pays for consensus. Strong global consensus is possible but raises write latency, cost, and failure complexity. Avoid global 2PC across inventory and payment.
After promotion, recovery workers resume unknown payment attempts, expiry, and outbox publication from durable state. Geo-partitioning is natural because a property’s inventory has a clear home. Search remains globally readable even when a subset of mutations is paused.
19. Final high-level architecture
Only now do the components earn boxes:
Clients
│
▼
CDN / WAF / Rate Limits
│
▼
API Gateway ────────────────┬──────────────────────────────┐
│ │ │
▼ ▼ ▼
Search Service Booking Orchestrator View APIs
│ │
├──► Redis Cache ├──► Inventory Service
│ │ │
└──► Search Index │ ▼
(derived) │ Authoritative SQL
│ inventory + holds +
│ bookings + outbox
│
└──► Payment Adapter ───► Payment Provider
│
└── webhooks
Authoritative SQL ─► Outbox Relay ─► Event Bus
▲ ├──► Notifications
│ ├──► Search Indexer
Expiry / Reconciliation Workers └──► Analytics
The edge terminates transport, filters abuse, and limits bursts. The gateway authenticates and routes.
Search reads Redis and the derived index; its availability is indicative. Booking orchestrates durable customer state but asks Inventory for every capacity mutation. Inventory SQL contains the serialization point, multi-date transaction, and check constraints. Keeping inventory, holds, and booking conversion in one correctness store initially avoids distributed commits.
The Payment Adapter records attempts and translates provider behavior. Provider webhooks re-enter through authenticated handlers.
The outbox relay moves committed events to the bus at least once. Notifications, indexing, and analytics consume asynchronously; none can approve a booking. Expiry and reconciliation workers operate from database truth.
Trace the arrows with the invariant in mind: no arrow from cache, index, event bus, or payment provider directly changes capacity. Inventory SQL alone ensures held + booked <= total.
20. Core request flows
Search
Alice → Gateway → Search
├→ cache hit, or
└→ search index
Search → Alice: indicative result + freshness
The fast path avoids authoritative row reads for every result. Alice’s later quote/hold revalidates.
Hold
Alice → Booking → Inventory SQL transaction
1. claim idempotency key
2. lock/check all date rows in order
3. increment held on every date
4. insert hold + items + outbox
5. commit all or roll back all
Bob concurrently follows the same path. Only transactions that satisfy every conditional row commit.
Payment and confirmation
Booking → persist payment attempt
→ Payment Adapter → Provider
← authorized / pending / unknown
→ Inventory transaction:
hold ACTIVE/PAYMENT_PENDING → confirmation pending or confirmed
held -= quantity; booked += quantity
booking records the chosen payment contract; outbox event
→ capture if required
→ mark CONFIRMED, or cancel/release on terminal capture failure
The conversion preserves held + booked; it changes classification, not total consumption. The customer remains in a processing state until the payment contract required for confirmation has succeeded.
Cancel and refund
Alice → cancel transaction:
booking valid state → CANCELLED
booked -= quantity on all item dates exactly once
refund request + outbox
→ async refund worker → Provider
→ REFUNDED or actionable REFUND_PENDING/FAILED
Inventory becomes sellable at local commit; money can settle later.
21. Appropriately scoped low-level design
The LLD should expose transaction boundaries and invalid transitions, not reproduce a framework.
Interfaces
interface InventoryService {
HoldResult hold(HoldCommand command, IdempotencyKey key);
void releaseHold(HoldId holdId, ExpectedVersion version, ReleaseReason reason);
BookingId confirmHold(HoldId holdId, PaymentAttemptId payment, ExpectedVersion version);
void releaseBooking(BookingId bookingId, ExpectedVersion version);
}
interface HoldService {
Hold get(HoldId id, UserId actor);
HoldResult create(CreateHold command, IdempotencyKey key);
void expireDue(Instant now, int batchSize);
}
interface BookingService {
Booking beginConfirmation(HoldId holdId, PaymentMethodToken token,
IdempotencyKey key);
Booking resumeConfirmation(BookingId bookingId);
CancellationResult cancel(BookingId bookingId, CancelCommand command,
IdempotencyKey key);
}
interface PaymentGateway {
PaymentResult authorize(PaymentCommand command, ProviderIdempotencyKey key);
PaymentResult capture(PaymentAttemptId attempt, ProviderIdempotencyKey key);
RefundResult refund(RefundCommand command, ProviderIdempotencyKey key);
PaymentResult query(ProviderPaymentId id);
}
interface InventoryRepository {
List<InventoryRow> lockInOrder(ResourceId resource, DateRange nights);
void incrementHeld(List<InventoryDelta> deltas);
void convertHeldToBooked(List<InventoryDelta> deltas);
void decrementHeld(List<InventoryDelta> deltas);
void decrementBooked(List<InventoryDelta> deltas);
}
interface TransactionRunner { <T> T inTransaction(Work<T> work); }
interface Clock { Instant now(); }
interface EventWriter { void append(OutboxEvent event); }
Repositories hide persistence mechanics but must not hide whether an operation requires one transaction. Clock makes expiry tests deterministic. Dependency injection lets domain services use fake providers and clocks without global state.
Domain entities reject invalid transitions
class Hold {
HoldStatus status;
long version;
Instant expiresAt;
void beginPayment(Instant now) {
require(status == ACTIVE && now.isBefore(expiresAt));
status = PAYMENT_PENDING;
version++;
}
void confirm() {
require(status == ACTIVE || status == PAYMENT_PENDING);
status = CONFIRMED;
version++;
}
void expire(Instant now) {
require(status == ACTIVE && !now.isBefore(expiresAt));
status = EXPIRED;
version++;
}
}
class Booking {
void confirm(PaymentAttemptId attempt);
void requestCancellation(CancellationPolicy policy, Instant now);
void markCancelled();
}
Domain validation gives readable errors, but two processes can validate the same old object. Repository updates therefore include expected status/version predicates, and SQL constraints remain the final guard.
Hold transaction pseudocode
createHold(command, key):
validate dates, quantity, quote signature/version
transaction:
idem = claim(scope="hold", user, key, hash(command))
if idem.completed: return idem.response
rows = inventory.lockInOrder(resource, nights)
if rows missing or any available < quantity:
store deterministic SOLD_OUT result
commit and return conflict
increment held for every row
insert ACTIVE hold and HoldItems
insert outbox HoldCreated
complete idempotency record
return hold
Confirm transaction pseudocode
confirmHold(holdId, paymentAttempt, expectedVersion):
transaction:
lock hold and booking-by-hold
return existing booking if already confirmed
require hold state allows confirmation
lock inventory rows in deterministic order
for every item:
held -= quantity
booked += quantity
insert booking and items
transition hold to CONFIRMED
append BookingConfirmed outbox event
return bookingId
Cancel transaction pseudocode
cancel(bookingId, expectedVersion, key):
transaction:
claim cancellation idempotency key
lock booking; verify actor, policy, state, version
if already cancelled: return original outcome
lock item inventory rows in deterministic order
decrement booked exactly once
transition booking to CANCELLED
create REFUND_PENDING when amount > 0
append BookingCancelled and RefundRequested
complete idempotency record
return accepted cancellation
Patterns and error taxonomy
The state machine pattern is useful because allowed transitions are business rules. Repository and dependency injection isolate persistence and providers. SOLID here means keeping inventory policy, payment integration, and orchestration separately testable—not creating an interface for every one-line class.
Expose stable domain errors:
VALIDATION_FAILED, UNAUTHENTICATED, FORBIDDEN,
SOLD_OUT, PRICE_CHANGED, HOLD_EXPIRED,
STATE_CONFLICT, IDEMPOTENCY_KEY_REUSED,
PAYMENT_PENDING, DEPENDENCY_UNAVAILABLE
Map them consistently to HTTP while logging internal causes and correlation IDs. Do not expose lock timeouts or SQL errors directly.
22. Schema and strategy recap
The durable schema is centered on:
users
resources
inventory(resource_id, stay_date, total, held, booked, version)
holds → hold_items
bookings → booking_items
payments → payment_attempts
refunds
idempotency_keys
outbox_events
The API surface is:
GET/POST /search
GET /resources/{id}/quote
POST /holds
POST /holds/{id}/confirm
GET /bookings/{id}
POST /bookings/{id}/cancel
POST /payment-webhooks/{provider}
The concurrency strategy is a short local SQL transaction, deterministic multi-date row order, row locking or conditional updates, expected versions, affected-row checks, and constraints. Redis locks never replace it.
The idempotency strategy combines unique scoped keys, request hashes, durable in-progress/completed outcomes, stable provider keys, and separate event/webhook deduplication.
The payment strategy persists attempts before calls, treats timeouts as unknown, favors authorize-then-capture where supported, compensates with void/refund, and reconciles provider truth.
The event strategy commits outbox records with business state, publishes at least once, deduplicates consumers, preserves only necessary key order, and allows lag without endangering bookings.
23. Key trade-offs
SQL versus NoSQL: SQL naturally supports multi-row date transactions and constraints. NoSQL is viable only with equivalent conditional/transactional semantics or a safely single-partition model.
Pessimistic versus optimistic concurrency: row locks are straightforward for short multi-date operations; optimistic versions avoid locks under low contention but amplify retries on hot rows. Conditional writes are excellent atomic primitives.
Long transaction versus hold: a long transaction appears simple but couples inventory to provider latency. A committed expiring hold adds cleanup complexity while bounding locks.
Authorize-then-capture versus charge-then-confirm: authorization reduces refunds but is not universally available. Charge-first expands payment-method support at the cost of compensation.
Fresh search versus fast search: derived availability is fast and scalable but stale. Authoritative validation at hold time preserves correctness.
Single store versus early microservices: one correctness store simplifies atomic transitions. Split only when scale/team boundaries justify saga complexity.
Availability versus correctness during region failure: pausing affected sales is painful but honest. Dual writers are available until they oversell.
24. A 45-minute interview pacing
0–5 min Clarify count-based inventory, date range, flow, scope
5–9 min Requirements, consistency boundaries, invariant
9–13 min Estimate search, booking, row traffic, skew
13–17 min APIs and core entities
17–23 min One-app/SQL baseline and multi-date transaction
23–29 min Double-booking race, holds, expiry
29–34 min Payment saga, idempotency, unknown outcomes
34–38 min Outbox, cancellation/refund, search projection
38–42 min Scaling, hot inventory, failures, multi-region
42–45 min Final diagram, trade-offs, interviewer follow-ups
Spend the most time on the final unit, multi-night atomicity, payment uncertainty, and retry safety. Naming ten technologies before proving those points is a weak answer.
25. Likely interviewer follow-ups
How do Alice and Bob avoid double booking?
They do not coordinate with each other. One authoritative SQL transaction serializes or conditionally updates every resource/date row. Only one can satisfy remaining capacity.
Why not trust Redis availability?
Redis is a derived, failure-prone optimization. Lease and failover edge cases make it unsuitable as the sole correctness guard. SQL counters and constraints own the invariant.
What if one night sells out?
The transaction rolls back all nights. No partial hold is returned.
What if payment times out?
Return pending, query/reconcile by durable provider reference and idempotency key, then confirm or compensate. Do not infer failure.
Can search show sold-out inventory?
Briefly, yes. Search is eventually consistent and labeled indicative. Detail and hold revalidate authoritatively.
How would item-based seats differ?
Claim unique seat IDs, often with a uniqueness/conditional state transition per seat. The surrounding hold, payment, idempotency, and outbox workflow remains similar.
When would you split databases?
Only when ownership or scale benefits exceed the loss of local atomicity. First define the new invariant owner and saga recovery.
26. Adversarial challenge questions
- Your expiry message was delivered twice. Why was capacity not released twice?
- Payment authorization arrives one second after expiry. Which state wins, and what does Alice see?
- Your search index is ten minutes stale. What incorrect action can it cause, and what can it not cause?
- The same idempotency key arrives simultaneously at two regions. Where is uniqueness enforced?
- A three-night hold touches two shards. Did the partition design already fail?
- The database conditional update is correct, but 100,000 clients retry. How do you protect connections?
- A property reduces
totalbelowheld + booked. Do you reject the update or launch an operational remediation? - Kafka preserves order only within a partition. Which key needs order for which consumer?
- Region failover promotes a replica while the old primary is isolated. What fencing evidence prevents two writers?
- A cancellation commits but refund initiation does not. Which durable row restarts it?
Good answers return to ownership, state/version predicates, bounded work, durable recovery, and the invariant.
27. Senior/Staff-level differentiators
A strong candidate:
- states that search availability and booking availability are different products;
- models inventory per stay date and insists on all-or-nothing multi-night holds;
- places the invariant in the database, not only in application locks;
- treats payment timeout as an unknown outcome;
- separates booking, payment, and refund states;
- makes API, provider, webhook, and consumer idempotency distinct;
- introduces Kafka, Redis, and search indexes only for demonstrated needs;
- protects the database from hot-key retry storms before discussing more shards;
- distinguishes a running service from one able to give a truthful answer;
- defines home-region ownership and fencing instead of claiming active-active writes;
- includes reconciliation and operational signals as part of correctness.
28. A two-minute condensed answer
I would model a hotel room type as count-based inventory per stay date. Search uses an eventually consistent index/cache because 200 million daily searches, peaking near 50,000 QPS, cannot all query transactional inventory. Search availability is only a hint; the hold path revalidates authoritative SQL.
The critical invariant is held + booked <= total for every resource/date. A hold for a 2–4-night stay runs one short SQL transaction that locks rows in deterministic order or uses conditional updates, verifies every night, increments held, and inserts the hold and outbox event. It commits all dates or none. Redis locks are never the sole guard.
Payment runs after the hold commits so provider latency does not retain database locks. Durable payment attempts and stable idempotency keys make retries safe. Booking and payment use separate state machines; timeout means unknown, so reconciliation queries the provider. Confirmation atomically converts held to booked; expiry or cancellation releases capacity exactly once. Refunds are asynchronous and may remain pending.
The system begins as one application and relational database. At scale, Search, Booking, Inventory, and Payment Adapter become logical or physical services; Redis and OpenSearch are derived reads; a transactional outbox feeds notifications, indexing, and analytics at least once. Hot sales use rate limits and waiting-room admission before the database. Multi-region search is active-active, but each inventory partition has one fenced home writer. During ambiguous failover we pause affected sales rather than oversell.
29. The compact mental model
Remember five layers:
Browse with hints.
Hold every date atomically.
Pay outside the inventory transaction.
Advance durable, idempotent state machines.
Publish side effects from an outbox.
At each evolution, ask who owns truth. Search owns discovery, the payment provider owns external money movement, and authoritative SQL owns inventory and booking transitions. Everything else may retry, lag, duplicate, or fail without violating:
For every resource/date: held + booked <= total.
For a broader framework on structuring requirements, estimation, APIs, architecture, scaling, and interview communication, see the complete system design interview guide.
