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.
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.
| Minutes | Phase | What you should have on the board |
|---|---|---|
| 0–5 | Clarify scope | Answers to five or six questions; the invariant stated aloud |
| 5–10 | Requirements and estimate | Functional list, the search/booking NFR split, four numbers that drive the design |
| 10–15 | Data model and APIs | room_night_inventory with its CHECK; hold and book endpoints with an Idempotency-Key |
| 15–30 | Whiteboard Part 1 | Naive design → split search → the last-room race → atomic multi-night allocation |
| 30–42 | Whiteboard Part 2 | Holds → state machine → payment ordering and idempotency → reconciliation |
| 42–52 | Whiteboard Part 3 | Cancel and modify → hotel side and PMS sync → scaling and failure |
| 52–60 | Deep dive, follow-ups, close | One 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.
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.
| Question | Why it matters | How the answer changes the design |
|---|---|---|
| Do we sell a room type, or a specific physical room at booking time? | Decides the inventory model | Room 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 invariant | No (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 window | Prepaid: 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 writer | In 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 path | Marketplace (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 machine | Yes (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.
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.
| Quantity | Estimate | What it tells you |
|---|---|---|
| Inventory rows | 50,000 properties × 3 room types × 400 days = 60 M room-night rows; ~4.8 GB raw, ~10 GB with indexes | The ledger is small; storage is not the problem |
| Search | 25 M searches/day ≈ 289 QPS average, ~1,800 QPS peak; ~3 availability reads each ≈ 5,400/s | Search needs its own horizontally scaled path |
| Booking | 2.5% of searches → ~45 attempts/s peak; 625,000 attempts/day; ~90% succeed ≈ 562,500 reservations/day | Search is ~40× booking: two paths with different consistency needs |
| Write transactions | ~3 short transactions per attempt (hold, attempt, confirm) ≈ 135/s at peak | One relational primary is enough; sharding later is for blast radius, not throughput |
| Hot row | 50–200 attempts within seconds on one room-night; a 2–5 ms lock hold ≈ 200–500 allocations/s per row | Contention, not throughput, is the booking-side risk: keep transactions short, never call the network while holding a lock |
| Derived volumes | Read model ~1.2 GB; ~2.5 M events/day (~29/s); ~307 GB/year of reservations | All comfortable on standard infrastructure |
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.
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.
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.
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;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.
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.
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.
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.
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.
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.
The stay grows from 2 nights to 5. Does the mechanism change?
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?
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.
What if there is no booking attempt at all?
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.
Two Expiry Worker instances pick up the same hold. Problem?
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.
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?
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.
- 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?
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?
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?
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?
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?
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?
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?
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.
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 answer | Why it looks plausible | What breaks | Strong answer |
|---|---|---|---|
| Decrement a cache (or the search count) as the claim | Fast and simple; correct with one user | Two reads of the same count both proceed; a crash between the cache and the database leaves them disagreeing | Conditional 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 meanwhile | Treat search as a hint; the hold re-checks the ledger and returns 409 naming the night |
| Model the stay as one unit | The guest wants "a room for the trip" | Cannot express "11-14 has 3, 11-15 has 1"; overlapping stays compete per night | One row per (hotel, room type, night); all nights or none |
One UPDATE … WHERE stay_date IN (…) | Shorter than one statement per night | No guaranteed lock order → deadlocks; cannot name the failing night | One UPDATE per night in ascending stay_date order; summed rows_affected |
| Redis lock as the correctness boundary | Locks feel like safety | Lease expiry or a GC pause lets a stale holder write; no recovery path after a crash | Database transaction + CHECK; a lock only as optional load shedding |
| Payment success means booked | The money moved | A crash before confirmation leaves a charge without a reservation, and nothing records it | Durable 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 state | Dedupe by event id; scoped idempotency keys with fingerprints; guarded transitions |
| Stop-sell by setting sellable = 0 | One number to check | Rejected when units are held or sold; overwritten by the next capacity sync; the reason is lost | A Restriction row checked in the hold transaction → 422 |
| Modify by cancelling, then rebooking | Reuses two existing endpoints | The scarce night is released before it is re-claimed, and another request can take it | Saga: hold the added night, settle price, swap in one transaction; stays CONFIRMED v+1 |
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?
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?
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?
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?
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.
- 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?
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?
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?
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?
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?
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?
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.
Quick-Revision Summary
Everything to recall in the last five minutes before the interview.
- 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.