See It Flow
System Design · Interview Perspective · ~25 min read

Hotel Reservation System Design Interview

Hotel booking system design interview guide: how to answer in 45–60 minutes, from clarifying questions and estimates to a whiteboard built stage by stage, with the deep dive to offer and Staff-level follow-ups. The core is separating eventually consistent search from strongly consistent booking.

Mangesh MandlikLast updated:
Interview 01

The 45–60 Minute Plan

Interviewers grade the reasoning path, not the final diagram. Start from the invariant and the simplest design that works, and let each concrete limit justify the next component — the same stage order Complete Design follows. Prompts arrive as "design a hotel booking system", "design Booking.com" or "design room inventory"; the shape is the same: a scarce, date-scoped resource allocated atomically across every night of a stay, under concurrency, with an external payment step attached.

MinutesPhaseWhat you should have on the board
0–5Clarify scopeAnswers to five or six questions; the invariant stated aloud
5–10Requirements and estimateFunctional list, the search/booking NFR split, four numbers that drive the design
10–15Data model and APIsroom_night_inventory with its CHECK; hold and book endpoints with an Idempotency-Key
15–30Whiteboard Part 1Naive design → split search → the last-room race → atomic multi-night allocation
30–42Whiteboard Part 2Holds → state machine → payment ordering and idempotency → reconciliation
42–52Whiteboard Part 3Cancel and modify → hotel side and PMS sync → scaling and failure
52–60Deep dive, follow-ups, closeOne mechanism in depth; a precise statement of guarantees and limits

Bring a small example. One hotel (HTL-204), one room type (RT-DLX, 5 rooms) and a 2-night stay (nights 11-14 and 11-15), where 11-15 has exactly 1 unit left. Concrete numbers make the race, the hold and the crash branch easy to explain without inventing characters.

Interview 02

Clarifying Questions That Change the Design

A question earns its time only if the answer changes what you draw. Ask briefly, state an assumption if the interviewer defers, and say what would change.

QuestionWhy it mattersHow the answer changes the design
Do we sell a room type, or a specific physical room at booking time?Decides the inventory modelRoom type (default): a per-night count per room type; the front desk assigns the physical room at check-in. Specific rooms (villas, accessible units): per-room calendars, an interval-scheduling problem, and holds lock specific rooms.
Is overbooking allowed?Defines the invariantNo (default): held + confirmed ≤ sellable, enforced by the allocation condition and a CHECK constraint. Yes: model it explicitly — a per-night overbooking allowance, so the cap becomes sellable + allowance, enforced by the same conditional update — plus a relocation process. Never get there by loosening the check.
Prepaid, pay-at-hotel, or both? Do we build payments?Decides the order of operations and the crash windowPrepaid: hold → attempt → authorize → capture → confirm, with reconciliation for the capture-then-crash gap. Pay-at-hotel or deferred capture: authorize or take a card guarantee → confirm → capture later. Either way payments are an external provider; this design owns the ordering around it.
Is a hotel PMS or channel manager in scope, and who is authoritative?Introduces a second writerIn scope (default): per-hotel cursor-ordered batches, guarded capacity changes, the platform ledger authoritative for its own bookings, conflicts to reconciliation. If the PMS is authoritative, the hold must consult it synchronously or accept a wider cross-channel window.
One hotel's site, or a marketplace with search across thousands of properties?Decides whether search needs its own pathMarketplace (default): a separate Search Service over a projection. A single property can serve search from the booking database.
Are cancellation and date changes in scope at full depth?Sizes the state machineYes (default): guarded cancellation, refunds via the outbox, and an acquire-before-release modification saga.

Assumptions for the rest of this page: room type sold, physical room at check-in; strict no-oversell; prepaid rates with an external provider; PMS sync in scope with the platform authoritative for its own bookings; a multi-property marketplace; cancellation and modification in scope; one primary region per shard with a cross-region standby.

Interview 03

Back-of-Envelope Estimates That Steer the Design

Estimate to discover which problem is hard, not to show arithmetic. Label each number as an assumption; the conclusions in the right-hand column are what the interviewer is listening for.

QuantityEstimateWhat it tells you
Inventory rows50,000 properties × 3 room types × 400 days = 60 M room-night rows; ~4.8 GB raw, ~10 GB with indexesThe ledger is small; storage is not the problem
Search25 M searches/day ≈ 289 QPS average, ~1,800 QPS peak; ~3 availability reads each ≈ 5,400/sSearch needs its own horizontally scaled path
Booking2.5% of searches → ~45 attempts/s peak; 625,000 attempts/day; ~90% succeed ≈ 562,500 reservations/daySearch is ~40× booking: two paths with different consistency needs
Write transactions~3 short transactions per attempt (hold, attempt, confirm) ≈ 135/s at peakOne relational primary is enough; sharding later is for blast radius, not throughput
Hot row50–200 attempts within seconds on one room-night; a 2–5 ms lock hold ≈ 200–500 allocations/s per rowContention, not throughput, is the booking-side risk: keep transactions short, never call the network while holding a lock
Derived volumesRead model ~1.2 GB; ~2.5 M events/day (~29/s); ~307 GB/year of reservationsAll comfortable on standard infrastructure
Interview 04

Whiteboard Part 1 — From the Naive Design to Atomic Allocation

Minutes 15–30. Each step starts from the limit of the previous one; name that limit out loud before drawing the fix. Chapter references point to the matching stage in Complete Design.

Step 1 — The ledger and the APIs (Complete Design Ch.06–07)

Draw

room_night_inventory(hotel_id, room_type_id, stay_date, sellable, held, confirmed) with primary key on the first three columns and CHECK (held + confirmed <= sellable). Beside it: GET /search (returns availability_as_of), POST /quotes, POST /holds, POST /bookings with an Idempotency-Key, and the errors 409 INVENTORY_UNAVAILABLE and 422 RESTRICTION_VIOLATED.

Say

"The room-night is the unit of scarcity: available is sellable minus held minus confirmed. A 2-night stay touches two rows. Prices live in a separate DailyRate table, so a rate edit never touches a hot inventory row."

Trade-off to name

Per-night rows versus a precomputed availability calendar: rows keep every write a simple conditional update; a calendar speeds range reads but must be kept in sync, which the search projection does instead.

Expected follow-up

"Why not one row per stay?" — Nights are independently scarce; two stays that overlap on one night compete for that night only.

Step 2 — Stage 0: the naive design (Ch.08)

Draw

Traveler → one App Service → one database. Search is SQL over the inventory; booking reads each night, checks it in code, then writes.

Say

"This is right for a handful of hotels. It breaks in three places: search load competes with bookings on one primary, check-then-act loses updates, and nights are written independently, so a stay can be half-booked."

Trade-off to name

Simplicity against the three limits — and a promise to remove them one at a time.

Expected follow-up

"Where does it break first?" — Name all three limits and the order you will fix them in.

Stage 0 — Naive Design: One Service, One DatabaseSearch queries and read → check → write bookings share one primaryTravelerweb / appApp Servicesearch + bookingDatabasesingle primarysearch SQL + booking writesAddedSimplest thing that works: SQL search over room_night_inventory; booking reads each night, then writes.Still openSearch load competes with bookings; check-then-act races oversell; nights are allocated independently.
Stage 0: one App Service and one database serve search queries and read → check → write bookings

Step 3 — Stage 1: separate search from booking authority (Ch.09)

Draw

API Gateway; a Search Service reading an Availability Read Model (index + cache, ~1.2 GB); an Outbox Relay and projector fed by outbox rows written in the same transaction as each inventory change. The booking path still writes the primary and never reads the projection.

Say

"Search reads a projection that may lag about 60 seconds. A stale positive costs one failed hold, a 409; a stale negative costs a missed sale until the projection catches up. Neither is a correctness problem, because every hold re-checks the authoritative rows."

Trade-off to name

Read replicas (same slow query shape, unbounded lag) versus a TTL cache (low hit rate on date × occupancy combinations) versus a periodic full rebuild (60 M rows for a tiny change set) versus an incremental, outbox-fed projection — chosen because it updates only what changed and its lag is a measurable SLO.

Expected follow-up

"Search showed a room that is gone — is that a bug?" — No: the response states its freshness, and the hold returns 409 naming the night.

Stage 1 — Search Reads a Projection; Booking Reads the AuthorityEvery inventory change writes an outbox row; a projector updates the read model (lag ≤ ~60 s)Travelerweb / appAPI Gatewayauth · rate limitNEWSearch Servicedest · dates · filtersNEWAvail. Read Modelindex + cacheNEWOutbox Relayprojector, lag ≤ ~60 sNEWBooking Serviceread → check → writeBooking DBauthoritativeAddedSearch Service + Availability Read Model + Outbox Relay/projector; the gateway routes reads and writes apart.Still openThe booking path is still read → check → write against the primary: the last-room race remains.
Stage 1: Search Service and Availability Read Model fed by the Outbox Relay and projector; booking still reads and writes the primary

Step 4 — Stage 2: expose the last-room race (Ch.10)

Draw

A two-column interleaving: Request A and Request B both read available = 1 on 11-15, both write confirmed = 5, both insert a reservation.

Say

"Two reservations, one room, and the counter says 5 of 5 — a lost update the ledger cannot even see. Writing confirmed + 1 fixes the arithmetic, not the decision. The root cause is deciding in application memory between two statements."

Trade-off to name

Candidate fixes: SELECT … FOR UPDATE then check (correct, more round trips under lock), SERIALIZABLE (correct, aborts and retries under contention, engine-specific), an optimistic version column (retry storms on a hot row), a Redis lock (not the system of record), or a conditional UPDATE (one statement, the database decides under the row lock).

Expected follow-up

"Do idempotency keys fix this?" — No. They stop the same request applying twice; A and B are different travelers with different keys.

Step 5 — Stage 3: atomic multi-night allocation (Ch.11)

Draw

An Inventory Service as the only writer of counts; one transaction with one conditional UPDATE per night in ascending stay_date order; summed rows_affected must equal the number of nights.

Say

"Each UPDATE takes the row lock and evaluates the condition on the latest committed row, so B waits for A and then matches 0 rows on 11-15. Any 0 rolls back every night. Ascending order means two overlapping stays can never deadlock. The CHECK constraint makes an oversell uncommittable even if a code path forgets the condition."

Trade-off to name

Conditional update versus lock-then-check: same correctness, fewer round trips under lock. A Redis lock is at best an optimization in front of this transaction: a lease can expire during a GC pause while its holder keeps writing.

Expected follow-up

"Why not one UPDATE … WHERE stay_date IN (…)?" — The database promises no lock order for it, so it can deadlock, and you cannot tell which night failed.

sqlWorth writing on the board: the decision lives in the WHERE clause, not in application code. (Stage 3 writes confirmed + 1; from Stage 4 the same statements write held + 1.)
BEGIN;
-- one statement per night, sorted by stay_date
UPDATE room_night_inventory SET held = held + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-14'
  AND sellable - held - confirmed >= 1;   -- must affect 1 row
UPDATE room_night_inventory SET held = held + 1
WHERE hotel_id = 'HTL-204' AND room_type_id = 'RT-DLX' AND stay_date = '2026-11-15'
  AND sellable - held - confirmed >= 1;   -- must affect 1 row
-- any 0 -> ROLLBACK and return 409 naming that night
COMMIT;
Last-Room Race — Atomic Multi-Night AllocationTwo requests both need 11-14 AND 11-15 for RT-DLX at HTL-204; 11-15 has 1 unit leftRequest A2 nights, RT-DLXRequest Bsame 2 nightsInventory Servicelock order: date ascRow 11-14avail 3 -> 2Row 11-15avail 1 -> 0A: HLD-1both nights heldB: 40911-15: 0 rows matchedWHERE available >= 1, per night11-15: only one request can winA's transaction updates 11-14, then 11-15, and commits both — the conditional WHERE passed on every night.B waits on A's row lock, then re-evaluates the WHERE clause against the committed row and matches 0 rows on 11-15.B's whole transaction rolls back — including its 11-14 update — so no partial-stay claim survives.The 409 INVENTORY_UNAVAILABLE names the blocking night (11-15), so the client can re-search alternatives,e.g. a Standard room (RT-STD) that is still free on both nights.
Request A allocates both nights in one transaction; Request B waits on A's lock, re-evaluates the condition, matches 0 rows on 11-15 and rolls back entirely — 409 naming 11-15, with RT-STD as an alternative
Interview 05

Whiteboard Part 2 — Holds, State Machine, Payment and Recovery

Minutes 30–42. Allocation is correct, but it happens at click time, before payment. Everything in this part exists because paying is slow, external and sometimes ambiguous.

Step 6 — Stage 4: holds (Ch.12)

Draw

A Pricing/Quote Service (quote expires in 10 minutes), an inventory_hold row (ACTIVE, TTL 600 s) created by the same ordered transaction with held + 1, and an Expiry Worker.

Say

"Booking at click time leaves unpaid reservations; charging first can take money for a room that sells out meanwhile. A hold reserves the nights for one traveler for ten minutes. The Expiry Worker flips ACTIVE to EXPIRED with a compare-and-set on the hold's state and only then releases the nights, so it can never release a hold that was just confirmed."

Trade-off to name

TTL length: long enough for card entry and 3-D Secure, short enough that abandoned holds do not hide scarce inventory. Holds cost availability: one abandoned hold hides the last room for up to ten minutes.

Expected follow-up

"Bots hoard holds — now what?" — One active hold per traveler or session, rate limits, explicit release when checkout is abandoned, shorter TTLs on very scarce nights.

Stage 4 — Holds: A Temporary, Expiring Claim While the Traveler PaysQuote → hold (held += 1 per night, TTL 600 s) → book; an expiry worker releases abandoned holdsTravelerweb / appAPI Gatewayauth · rate limitPricing / QuoteDailyRate, 10 minNEWSearch Servicedest · dates · filtersAvail. Read Modelindex + cacheOutbox Relayprojector, lag ≤ ~60 sBooking Servicequote → hold → bookInventory Serviceordered cond. UPDATEsBooking DBauthoritativeExpiry Workerhold TTL 600 sNEWAddedPricing/Quote Service, inventory_hold rows fenced by state (ACTIVE → CONSUMED | EXPIRED), Expiry Worker.Still openPayment is a slow external call: retries, ordering and crashes need explicit states and idempotency.
Stage 4: Pricing/Quote Service, held counts and an Expiry Worker; booking becomes quote → hold → book

Step 7 — Stage 5: the booking state machine (Ch.13)

Draw

QUOTED → HELD → PENDING_PAYMENT → CONFIRMED, with EXPIRED, PAYMENT_FAILED, CANCELLED, and the crash branch PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING → CONFIRMED | REFUNDED.

Say

"A booking is a lifecycle, not a boolean. Every transition is UPDATE … WHERE state = :from; zero rows means someone else moved first, and the caller stops. A modification is a transition that keeps the reservation CONFIRMED with version + 1, so cancelling a modified booking just works."

Trade-off to name

An is_confirmed flag versus explicit states: the flag cannot represent "paid but not confirmed", which is exactly what a crash produces.

Expected follow-up

"Where do these states live?" — Spread over existing rows: the hold's state, the booking attempt's state, and the reservation's state and version.

Step 8 — Stage 6: payment coordination and idempotency (Ch.14)

Draw

Booking Orchestrator, Payment Service, the external Payment Provider, a booking_attempt table, the outbox → Notifications. Number the order: hold → attempt → authorize → capture → confirm.

Say

"Three short transactions — T1 hold, T2 attempt, T3 confirmation — and the provider calls happen between them, so no lock is ever held across the network. The attempt is unique on principal, endpoint and Idempotency-Key, carries a fingerprint of hold, amount and payment token, and a partial unique index allows one pending attempt per hold. The Expiry Worker defers holds whose attempt is younger than the 60-second auth_timeout. T3 fences the hold ACTIVE to CONSUMED, moves each night from held to confirmed, writes the reservation and an outbox event in one commit."

Trade-off to name

Capture-then-confirm (default for prepaid: a confirmed booking always has money behind it, at the cost of a capture-then-crash window) versus authorize → confirm → capture (smaller crash window; the natural fit for pay-at-hotel).

Expected follow-up

"Two retries land on two servers in the same millisecond?" — Insert first and let the unique constraint pick the winner; the loser loads the existing attempt and returns its result, or 202 while it is pending.

Stage 6–7 — Payment, Idempotent Attempts, Outbox and Reconciliationhold → booking attempt → authorize → capture → confirmation transaction (+ outbox event)Travelerweb / appAPI Gatewayauth · rate limitPricing / QuoteDailyRate, 10 minSearch Servicedest · dates · filtersAvail. Read Modelindex + cacheOutbox Relayprojector + eventsNotificationsdedupe event_idNEWBooking Orchestratorattempt → pay → confirmInventory Serviceordered cond. UPDATEsBooking DBauthoritativePayment Serviceauthorize → captureNEWPayment Providerexternal, tokenizedNEWReconciliationstuck attemptsNEWExpiry Workerhold TTL 600 sAddedBooking Orchestrator, Payment Service, booking_attempt idempotency, Notifications, Reconciliation Worker.Still openHotels and their PMS also write inventory, rates and restrictions — a second writer to the ledger.
Stages 6–7: Booking Orchestrator, Payment Service, idempotent booking attempts, outbox-driven Notifications and a Reconciliation Worker

Step 9 — Stage 7: crash recovery and reconciliation (Ch.15)

Draw

A Reconciliation Worker that sweeps attempts stuck in PENDING_PAYMENT and queries the provider by attempt reference.

Say

"Payment success is not booking success. If the provider captured and we crashed before T3, the worker marks the attempt PAYMENT_CAPTURED_BOOKING_FAILED, then RECONCILING, and runs the same T3. If the hold is still ACTIVE, the booking is confirmed; if it expired, we refund. We never oversell to honour a payment."

Trade-off to name

Sweep cadence (minutes) against support load; whether policy tries a fresh allocation before refunding.

Expected follow-up

"Why not one transaction around the payment call?" — A database transaction cannot span an external call, and holding row locks across it would serialize every traveler behind one slow card.

Interview 06

Whiteboard Part 3 — Cancellation, Hotel Sync and Scale

Minutes 42–52. The booking path is complete. The remaining limits come from after-the-fact changes, a second writer, and scale. Move faster here; one sentence per mechanism is often enough unless the interviewer digs in.

Step 10 — Stage 8: cancellation and modification (Ch.16)

Draw

No new boxes: a guarded cancel with per-night releases and a refund.requested outbox event; a modification saga beside it.

Say

"Cancellation releases the nights and starts a refund as two independent operations; a failing refund never reopens the cancellation. A modification acquires the new night first, settles the price difference, then swaps nights in one transaction — the reservation stays CONFIRMED with version + 1."

Trade-off to name

Saga versus cancel-and-rebook: the shortcut releases the scarce night before re-claiming it, so another request can take it in between.

Expected follow-up

"The refund fails — what then?" — It is its own record, retried with the same provider reference and escalated after a bound.

Step 11 — Stage 9: hotel side and PMS sync (Ch.17)

Draw

Hotel Extranet and a PMS Connector writing through the Inventory Service; a per-hotel sync cursor.

Say

"Stop-sell, minimum stay and closed-to-arrival are Restriction rows checked in the hold transaction — a 422, never sellable = 0. Capacity cuts below held + confirmed are rejected. PMS batches apply only if their cursor is newer, and the cursor advances in the same transaction. For bookings made here, the platform ledger wins; disagreements go to reconciliation."

Trade-off to name

Platform-authoritative versus PMS-authoritative inventory; the cross-channel oversell window is bounded by sync latency, not eliminated.

Expected follow-up

"The PMS reports fewer rooms than you have sold?" — Do not apply it; record a conflict for the hotel to resolve; never cancel paid platform bookings because of a PMS report.

Stage 9 — Hotel Extranet and PMS Sync Write the Same LedgerRestrictions are rows checked at hold time; PMS batches apply only if their cursor is newerTravelerweb / appAPI Gatewayauth · rate limitPricing / QuoteDailyRate, 10 minSearch Servicedest · dates · filtersAvail. Read Modelindex + cacheOutbox Relayprojector + eventsNotificationsdedupe event_idBooking Orchestratorattempt → pay → confirmInventory Serviceordered cond. UPDATEsBooking DBauthoritativePayment Serviceauthorize → capturePayment Providerexternal, tokenizedReconciliationstuck attemptsExpiry Workerhold TTL 600 sHotel Extranetrates · restrictionsNEWPMS Connectorper-hotel cursorNEWAddedHotel Extranet (rates, stop-sell, min-stay, capacity) and a PMS/channel connector with per-hotel cursors.Still openHot properties and blast radius: one popular hotel must not degrade every other hotel (Stage 10).
Stage 9: Hotel Extranet and PMS Connector write rates, restrictions and capacity adjustments to the same ledger, ordered by per-hotel cursors

Step 12 — Stage 10: scaling and failure (Ch.18)

Draw

A shard router by hotel_id; each Booking DB shard with a synchronous standby and read replicas; another region replicated asynchronously.

Say

"About 135 write transactions a second fit one primary; we shard for blast radius, so one hot hotel cannot exhaust everyone's connections. A hotel's inventory, holds, attempts, reservations and outbox share a shard, so confirmation stays a local transaction. A hot row still serializes: keep transactions to milliseconds, add admission control, fail fast with 409. In-region failover keeps RPO near zero; cross-region failover can lose seconds of commits and needs reconciliation with payment and PMS records."

Trade-off to name

Shared versus sharded infrastructure; synchronous in-region standby versus asynchronous cross-region replication.

Expected follow-up

"Can you still say 'never oversold' after a regional failover?" — Not absolutely: the guarantee holds for the ledger that survives; lost commits are repaired from provider and PMS records.

Final Architecture — Separate Search, Booking, Payment and Hotel-Sync PathsSearch is projected and eventually consistent; allocation and confirmation are authoritative and atomicTravelerweb / appAPI Gatewayauth · rate limitPricing / QuoteDailyRate, 10 minSearch Servicedest · dates · filtersAvail. Read Modelindex + cacheOutbox Relayprojector + eventsNotificationsdedupe event_idBooking Orchestratorattempt → pay → confirmInventory Serviceordered cond. UPDATEsBooking DB shardsby hotel_idPayment Serviceauthorize → capturePayment Providerexternal, tokenizedReconciliationstuck attemptsExpiry Workerhold TTL 600 sHotel Extranetrates · restrictionsPMS Connectorper-hotel cursorReplicasstandby · readsNEWScaleBooking DB sharded by hotel_id for blast radius, with an in-region synchronous standby and read replicas.RuleA cached result or search hit is never a reservation guarantee: only a committed hold or confirmation is.
Final architecture: projected search; Inventory Service and Booking Orchestrator over a hotel-sharded Booking DB; payment, outbox, expiry, reconciliation and hotel sync around it
Interview 07

Deep-Dive Options: Race, Crash and Expiry

When the interviewer asks you to go deep, offer one of these. Each has a weak first instinct, a probe that breaks it, and a strong answer that reuses a mechanism already on the board. Other good options: the PMS cursor protocol, and how the projection stays within its 60-second lag.

Option A — The last room across multi-night stays

Prompt: "Make sure two travelers can never both confirm the last Deluxe room on the same night." Show the answer evolving through three iterations, each fixing the hole the interviewer pokes in the last.

Iteration 1 — decrement the number shown in search (weak)

It looks fine in a single-request demo. Probe: "Request A and Request B both see available = 1 on 11-15 and click within the same second." Two independent reads both proceed; nothing stops both writes. The cached number was never the thing enforcing scarcity — conflating the displayed count with the authoritative row is the most common wrong turn.

Iteration 2 — one conditional update per night (better, incomplete)

UPDATE … SET held = held + 1 WHERE … AND sellable - held - confirmed >= 1, checking one row affected. This fixes the single-night race. Probe: "Both requests want 11-14 and 11-15. What stops a booking succeeding on 11-14 and failing on 11-15?" Independent per-night statements have nothing tying the outcomes together.

Iteration 3 — one transaction, ascending order, summed rows (correct)

All nights' updates in one transaction, sorted by stay_date, with summed rows_affected required to equal the night count. A takes 11-14 (3 → 2) and 11-15 (1 → 0) and commits. B gets 11-14 but matches 0 rows on 11-15; 1 + 0 ≠ 2, so its whole transaction rolls back — including 11-14 — and the 409 names 11-15. The ascending order also removes deadlocks between overlapping stays.

Deadlock Avoidance — Ascending Lock Order for Multi-Night AllocationTwo concurrent transactions touching the same two room-night rows, HTL-204/RT-DLX 11-14 and 11-15Without a fixed order — each txn locks in its own request order -> deadlockTxn Xlocks 11-14 then 11-15Txn Ylocks 11-15 then 11-14Row 11-14Row 11-15X locks 11-14Y locks 11-15locked by X · Y waits for itlocked by Y · X waits for itCircular wait: X holds 11-14 and waits on 11-15; Y holds 11-15 and waits on 11-14 — deadlock.Canonical rule — every transaction locks ascending by stay_date, regardless of request orderTxn X11-14 -> 11-15Txn Y11-14 -> 11-15Row 11-14Row 11-15X locks 11-14 first — wins1. X locks first · Y queues here2. X locks, commits · then Y proceedsNo cycle is possible: every transaction locks rows in the same global order, so the loser only waits.
Txn X and Txn Y need rows 11-14 and 11-15: locking in request order creates a circular wait; locking in ascending stay_date order makes the second transaction wait instead

The stay grows from 2 nights to 5. Does the mechanism change?

Strong answer angle

No: five ordered statements and one equality check. What grows is the number of rows locked per transaction. Only a product dominated by very long stays (extended-stay, corporate housing) might justify a range-oriented write structure, at the cost of keeping it consistent with the per-night rows.

Why sum rows_affected instead of checking each statement?

Strong answer angle

Accepting any non-zero result per statement is exactly the bug that confirms B for 11-14 alone — a partial stay. The check is one boolean over the whole transaction: every night succeeded, or nothing is kept.

Option B — "The guest paid, but the reservation does not exist"

Prompt: "Support says a customer was charged but has no booking. Debug it and prevent it." This tests whether the state machine can represent "charged but not confirmed" as a recoverable state.

1. Look up the attempt, not the reservation

If the confirmation never committed, the reservation table has nothing to find. Look up the booking attempt by the client's Idempotency-Key (3f9c2a71-book → BKA-1): it was written before the provider was called precisely so it exists after a crash.

2. Ask the provider what happened

BKA-1 is PENDING_PAYMENT with no reservation. Query the provider by BKA-1's reference — a status check, not a new authorization. It reports CAPTURED for PAY-1: money moved, but T3 (hold → CONSUMED, held → confirmed, RES-1, EVT-1) never committed.

3. Resolve forward through the fence

The attempt moves PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING, and the worker runs the normal T3. Hold still ACTIVE → CONFIRMED and the notification goes out from the outbox. Hold EXPIRED and the night resold → REFUNDED. The fenced claim decides the branch, not the payment.

4. Prevent and detect

T3 writes the reservation, nights and outbox event atomically, so "confirmed but never announced" cannot happen. A sweep with an oldest-stuck-attempt alert finds these cases in minutes rather than via support. Provider calls reuse the attempt reference, so recovery can never charge twice.

Payment Timeout / Reconciliation SequenceCapture succeeds at the provider, then the orchestrator crashes before the confirmation transaction commitsBooking Orchestratorattempt BKA-1Payment ProviderPAY-1 CAPTUREDBooking DBattempt PENDING1 authorize + capture2 reply lost — process crashesconfirmation never committedReconciliationstuck attempts3 query by attempt idRECONCILINGwas CAPTURED_BOOKING_FAILED4 mark attemptCONFIRMEDhold still ACTIVEREFUNDEDhold EXPIREDPayment success alone never meansthe booking is confirmed.Hold still ACTIVE: run the normalconfirmation transaction (same attempt).Hold EXPIRED and nights resold: refund —never oversell to honour a payment.
Capture succeeds, the orchestrator crashes before the confirmation transaction, and the Reconciliation Worker resolves the attempt to CONFIRMED (hold still ACTIVE) or REFUNDED (hold expired)

What if there is no booking attempt at all?

Strong answer angle

Then the client never reached the booking endpoint with that key, and the charge did not come from this flow (a client bug, a different purchase, a provider issue). Investigate on the provider side instead of assuming reconciliation applies.

Option C — Hold expiry versus a late confirmation

Prompt: "What happens if the hold expires at the exact moment the payment confirmation arrives?" This tests whether you reach for a bigger timeout or for a mechanism that makes either ordering safe.

The weak instinct: a longer TTL

Probe: "Which TTL guarantees the payment always wins?" None — a provider can be arbitrarily slow. A longer TTL only makes the race rarer while hiding scarce inventory longer.

The strong answer: fence on the hold row

Both writers start with a compare-and-set on the hold's own state. Confirmation first (t = 598 s): ACTIVE → CONSUMED matches, and the worker's ACTIVE → EXPIRED at 601 s matches 0 rows — a no-op. Expiry first (t = 600 s): the hold expires, HLD-2 takes the unit, and the late confirmation's fence matches 0 rows, so the money is refunded through reconciliation. The worker also defers holds whose attempt is younger than the 60 s auth_timeout, which makes the second ordering rare; the fence makes it safe.

Hold-Expiry vs. Confirmation Race — Fencing on the Hold Row's StateHLD-1 (TTL 600 s) holds the last RT-DLX unit on 11-15 — two orderings of the same raceA — confirmation lands first; expiry becomes a safe no-opt=598 s · confirmation txnhold ACTIVE → CONSUMED (1 row)11-15: held 1→0, confirmed 4→5t=601 s · expiry workerWHERE state='ACTIVE' → 0 rowsnothing releasedResultbooking stays CONFIRMEDexpiry logs a no-opB — expiry lands first; the late confirmation is fenced offt=600 s · expiry workerhold ACTIVE → EXPIRED (1 row)11-15: held 1→0t=602 s · another travelernew hold HLD-2 claims the unit11-15: held 0→1t=605 s · late confirm for HLD-1WHERE state='ACTIVE' → 0 rowsno reservation; refund pathStage 6 makes B rare: the expiry worker defers holds whose payment attempt is younger than 60 s (auth_timeout).Fencing rule: every write that spends or releases a hold is a compare-and-set on that hold's own state.Expiry cannot un-confirm a completed booking (A), and a late confirmation cannot resurrect an expired hold ortake HLD-2's unit (B). In both directions 0 rows affected means "stop" — never "retry blindly" or "overwrite".If money was already captured in B, reconciliation refunds it (Stage 7).
HLD-1 holds the last 11-15 unit: in A the confirmation consumes it and the expiry is a no-op; in B the hold expires, HLD-2 takes the unit and the late confirmation is fenced off and refunded; the 60 s deferral makes B rare

Two Expiry Worker instances pick up the same hold. Problem?

Strong answer angle

No. They claim batches with FOR UPDATE SKIP LOCKED, and even if both reached the hold, only one ACTIVE → EXPIRED can match; the other affects 0 rows and releases nothing.

Interview 08

Staff-Level Follow-Ups: Proving and Bounding the Invariant

Staff-level follow-ups stop asking what you would build and start asking how you know it works, what you would cut, and where the guarantee ends.

How do you prove — not claim — that you never oversell in production?

Strong answer angle

Three layers. The allocation transaction and the CHECK constraint make a violating commit impossible on the platform's ledger. A standing monitor queries held + confirmed <= sellable and compares held counts with ACTIVE holds and confirmed counts with reservation nights, catching drift from any path that changed one without the other. And the outbox history lets you reconstruct, for a disputed room-night, every hold, confirmation, expiry and cancellation in order.

Likely follow-ups
  • What would a failure-injection test look like? — Fire N concurrent holds at a night with one unit and assert exactly one success and that held + confirmed never exceeded sellable at any point — not just at the end.

Where exactly is the correctness boundary, and why isn't a Redis lock enough?

Strong answer angle

The database transaction: it commits durably or leaves nothing changed. A lock only serializes a critical section; its lease can expire during a GC pause while the holder keeps writing, and a crash between a Redis decrement and the database write leaves two systems disagreeing with no transactional way back. A per-(hotel, room type) lock or queue in front of the database is a fine load-shedding optimization — remove it and the system is slower, never wrong.

The Expiry Worker pool is down for 20 minutes during a conference. What is the blast radius?

Strong answer angle

Holds past their TTL stay ACTIVE and keep their held counts, so scarce nights look sold out and new holds get 409s — lost sales and a stale-negative wave, but no oversell, because nothing was released that someone else could take. A late confirmation of an overdue but still-ACTIVE hold succeeds, which is also safe: its nights are still counted as held. Recovery drains the backlog in bounded batches with SKIP LOCKED across instances, releasing nights in ascending order. Alert on the age of the oldest ACTIVE hold past expires_at.

What changes if the hotel's PMS is authoritative instead of you?

Strong answer angle

Ownership flips: the platform's inventory becomes a replica gated by the PMS cursor, and the hold needs either a synchronous availability call to the PMS (its latency and uptime join your booking SLO) or a materially tighter freshness bound. Conflicts would resolve in the PMS's favour, which introduces the risk this design avoids: a buggy or late feed invalidating a reservation you already confirmed and charged for. Reconciliation becomes more important, not less.

The business wants 5% overbooking on peak nights. How?

Strong answer angle

Make it explicit: a per-night overbooking allowance, so the condition and CHECK use held + confirmed <= sellable + allowance. Everything else — ordered conditional updates, holds, fences — is unchanged. Add a relocation (walk) process and per-hotel limits. Never implement it by disabling the check or by letting PMS conflicts through.

How does the payment flow change for pay-at-hotel rates?

Strong answer angle

There is no capture at booking time: authorize or vault a card guarantee, run T3 to confirm, and capture at the stay (or charge a no-show fee per policy). The capture-then-crash window disappears; the new risks are an authorization expiring before the stay and no-shows, handled by policy. This is the authorize → confirm → capture alternative, used per rate plan.

If you had to cut one mechanism, which would you keep no matter what?

Strong answer angle

The ordered, conditional, all-nights-or-none allocation transaction with its CHECK constraint. It alone enforces no-oversell. Holds, idempotency, reconciliation and PMS cursors make the experience and the payment story correct around it, but none of them substitutes for it.

How would you test correctness beyond unit tests?

Strong answer angle

A concurrency test hammering one scarce night; a chaos test that kills the orchestrator between capture and T3 and asserts every attempt converges to CONFIRMED or REFUNDED within the reconciliation SLO; a race test that runs expiry against a late confirmation in both orderings; and a replay test that redelivers every outbox event and webhook twice and asserts no duplicate side effects.

Interview 09

Weak vs. Strong Answers

Each weak answer passes a casual review and a single-request demo. Each fails under concurrency or across a crash — the two conditions a whiteboard exercise exists to force into the open.

Weak answerWhy it looks plausibleWhat breaksStrong answer
Decrement a cache (or the search count) as the claimFast and simple; correct with one userTwo reads of the same count both proceed; a crash between the cache and the database leaves them disagreeingConditional per-night UPDATE on the authoritative row, inside one transaction
Trust search availability at booking time"The page already said 1 left"The projection lags up to ~60 s; the night may have sold meanwhileTreat search as a hint; the hold re-checks the ledger and returns 409 naming the night
Model the stay as one unitThe guest wants "a room for the trip"Cannot express "11-14 has 3, 11-15 has 1"; overlapping stays compete per nightOne row per (hotel, room type, night); all nights or none
One UPDATE … WHERE stay_date IN (…)Shorter than one statement per nightNo guaranteed lock order → deadlocks; cannot name the failing nightOne UPDATE per night in ascending stay_date order; summed rows_affected
Redis lock as the correctness boundaryLocks feel like safetyLease expiry or a GC pause lets a stale holder write; no recovery path after a crashDatabase transaction + CHECK; a lock only as optional load shedding
Payment success means bookedThe money movedA crash before confirmation leaves a charge without a reservation, and nothing records itDurable attempt before the provider call; PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING → CONFIRMED or REFUNDED
Process webhooks and retries as they arrive"Providers send each event once"At-least-once delivery double-applies; a stale event regresses a newer stateDedupe by event id; scoped idempotency keys with fingerprints; guarded transitions
Stop-sell by setting sellable = 0One number to checkRejected when units are held or sold; overwritten by the next capacity sync; the reason is lostA Restriction row checked in the hold transaction → 422
Modify by cancelling, then rebookingReuses two existing endpointsThe scarce night is released before it is re-claimed, and another request can take itSaga: hold the added night, settle price, swap in one transaction; stays CONFIRMED v+1
Interview 10

Question Bank

Likely questions across scope, data model, concurrency, payment, hotel sync and scale, each with a strong answer direction and a pointer to where the module covers it.

What is the difference between a search result, a quote, a hold and a reservation?

Strong answer angle

A search result promises nothing. A quote (QTE-1) fixes a price for 10 minutes and claims no inventory. A hold (HLD-1) claims specific room-nights for 600 s while the traveler pays. A reservation (RES-1) is the only real guarantee. Merging quote and hold would let every price check consume scarce inventory. See Domain Model.

Why sell a room type and assign the physical room at check-in?

Strong answer angle

A specific room drags housekeeping, maintenance and preferences into the hot booking transaction for no correctness gain — the traveler needs a Deluxe room until check-in, and per-type counts already guarantee one exists. Selling named rooms is right for villas or accessibility-specific units. See Room-Type Allocation vs. Physical-Room Assignment.

Pessimistic or optimistic concurrency for the inventory update?

Strong answer angle

A conditional UPDATE — a short row lock evaluated by the database — because conflicts on a scarce night are the normal case at peak, and optimistic retries pile up exactly then; a version column also turns every unrelated change on the row into a conflict. Optimistic control wins when conflicts are rare. See Pessimistic Locks vs. Optimistic CAS for Inventory Decrement.

How do you make a retried POST /v1/bookings safe?

Strong answer angle

Insert the booking attempt first, unique on (principal, endpoint, Idempotency-Key); on a violation return the stored result if the fingerprint matches, 202 if still pending, 409 IDEMPOTENCY_KEY_CONFLICT if it differs. A partial unique index allows one pending attempt per hold, so a new key cannot start a second payment, and provider calls reuse the attempt id as their idempotency reference. See Idempotency Keys, Unique Constraints and Fingerprints.

Likely follow-ups
  • Same key, different payment token? — Fingerprint mismatch → 409 IDEMPOTENCY_KEY_CONFLICT: a client bug, not a retry.

How fresh is search, and how is it kept fresh?

Strong answer angle

Lag of at most about 60 s, measured as a metric. Every inventory change writes an outbox row in the same transaction; the relay and projector update only the affected entries. It is not a periodic rebuild and not a synchronous invalidation inside the booking transaction. See Stage 1 — Separate Search from Booking Authority.

A hotel stop-sells 2026-11-20 while bookings are in flight. What happens?

Strong answer angle

A Restriction row is inserted; holds touching 11-20 get 422 RESTRICTION_VIOLATED; counts, existing reservations and holds already granted are untouched; bookings for other dates are unaffected. See Restriction Enforcement.

The PMS reports fewer Deluxe rooms than the platform has already sold. What do you do?

Strong answer angle

First check the batch cursor: an older one is rejected outright. If it is newer, translate it to a capacity adjustment; it would push sellable below held + confirmed, so do not apply it — record a conflict for the hotel and operations. Never cancel paid platform bookings because of a PMS report. See PMS Sync: Cursor Ordering and Conflict Handling and Channel-Manager-Authoritative vs. Platform-Authoritative Inventory.

150 concurrent holds hit one room-night. What scales and what doesn't?

Strong answer angle

Sharding by hotel_id isolates that hotel from others but does nothing for one row, which serializes on its lock regardless of servers or shards. What helps: millisecond transactions with no network calls inside, admission control per (hotel, room type), a fast 409 for the losers. See Sharding, Routing and Observability.

Why shard at all if one primary handles the load?

Strong answer angle

Blast radius and hot-property isolation: at ~135 write transactions/s one primary copes, but one popular hotel can exhaust shared connections and lock queues for everyone, and a single database is a single failure domain. Sharding by hotel_id keeps each booking a local transaction. See Stage 10 — Scaling: Hot Rows, Sharding, Replicas and Regions.

How do you cancel an RP-FLEX booking so the refund can never be applied twice?

Strong answer angle

A guarded CONFIRMED → CANCELLED with per-night releases and a refund.requested outbox event in one transaction; the refund is its own record with a stable provider reference, retried idempotently. A retried cancel returns the original result; a stale replay matches 0 rows. Under RP-SAVER the refund is zero. See Cancellation and Modification.

Interview 11

Quick-Revision Summary

Everything to recall in the last five minutes before the interview.

Key takeaways
  • Invariant: for every (hotel, room type, night), held + confirmed ≤ sellable; a multi-night stay gets all nights or none
  • Ask early: room type vs. physical room, overbooking, payment model, PMS authority, search scale, cancel/modify depth
  • Numbers: 60 M room-night rows (~10 GB); ~1,800 search QPS peak (~5,400 availability reads/s); ~45 booking attempts/s; ~135 write tx/s fit one primary; hot row 50–200 attempts in seconds
  • Stage order: invariant → naive → split search (outbox-fed projection, lag ≤ ~60 s) → race → atomic per-night allocation (ascending order, summed rows, CHECK) → holds (600 s, state fence) → state machine → payment (hold → attempt → authorize → capture → confirm) → reconciliation → cancel/modify → hotel/PMS sync → sharding
  • Search is a hint; only a committed hold or confirmation is a claim
  • Idempotency ≠ atomicity: the transaction stops two travelers winning; the scoped key, fingerprint and one-pending-attempt-per-hold stop one traveler paying twice
  • Expiry and confirmation both compare-and-set the hold's state; the worker defers holds with an attempt younger than 60 s
  • Payment success is not booking success: PAYMENT_CAPTURED_BOOKING_FAILED → RECONCILING → CONFIRMED | REFUNDED; never oversell to honour a payment
  • Modification acquires before it releases and keeps the reservation CONFIRMED (version + 1); stop-sell is a Restriction row (422), never sellable = 0
  • Shard by hotel_id for blast radius, not throughput; a hot row still serializes; cross-region failover has RPO > 0 and needs reconciliation

The closing statement. Say what the design guarantees — no oversell on the platform's ledger, all-nights-or-none allocation, idempotent retries, and capture/confirmation convergence after a crash — and what it deliberately does not: that search is real-time, that a specific physical room is promised before check-in, that sales on other channels cannot collide with platform sales, or that an asynchronous cross-region failover is lossless. A precise boundary reads as more senior than a confident overclaim.