Source: https://www.hack2hire.com/question-bank/questions/system-design/69d5d62760ca00f1f2f5f466
Company: Airbnb, Meta, Microsoft, Amazon
Difficulty: Medium
Problem statement
Search hotels by place and dates, show availability and prices, and book rooms without double-booking, including holds and cancellations.
Requirements
We are building a hotel reservation platform: guests search hotels by location and dates, see which room types are free and what they cost, place a short hold on a room, pay to confirm it, and cancel later under the hotel's policy, with no room-night ever sold twice.
Clarify first: are we an online travel agency over many chains or one chain's own site (assume an OTA with direct inventory)? Do we book a specific room or a room type (room type; the hotel assigns the physical room at check-in)? Is controlled overbooking allowed (off by default, per-hotel setting)?
Functional requirements
- Search by city, map area or hotel name, check-in/check-out dates, guests and rooms; return available hotels with the lowest nightly price, sortable and filterable (price, stars, amenities).
- Hotel detail: room types available for every night of the stay, with total price including taxes and fees.
- Reserve: hold N rooms of a type for the stay for 10 minutes while the guest pays.
- Confirm: charge (or authorize) payment and turn the hold into a booking; send a confirmation.
- Cancel and modify: apply the rate plan's policy (free until 48 h before, then first night), refund accordingly.
- Hotel partners update inventory counts and rates (extranet and channel-manager feeds).
Non-functional requirements
- Never double-book: room-night inventory is strongly consistent on the booking path.
- Search is read-heavy and may be slightly stale (seconds); the final price and availability are re-checked at reserve time.
- Search p99 under 300 ms; reserve and confirm p99 under 1 s excluding the payment provider.
- Booking path 99.99% available; a lost or duplicated booking is worse than a slow one, so every write is idempotent.
Back-of-the-envelope estimation
- 1M hotels, average 50 rooms in 4 room types → 4M room types, 50M rooms.
- Inventory sold 365 days ahead: 4M room types × 365 nights ≈ 1.5B room-type-night rows, ~100 B each ≈ 150 GB. Fits a sharded relational cluster.
- 100M searches/day ≈ 1,200/s average, 10k/s peak. Look-to-book ratio ~1000:1 → ~100k bookings/day ≈ 1.2/s average, 50/s at a flash-sale peak.
- A booking touches on average 3 nights → 300k inventory row updates/day: tiny write load; the problem is contention, not volume.
- Rate and availability updates from channel managers: ~50M/day ≈ 600/s, which drives search-index churn.
API design
GET /v1/search?lat=..&lng=..&radius_km=5&check_in=2026-12-20&check_out=2026-12-23&guests=2&rooms=1&sort=price&cursor=..
→ 200 { results: [{hotel_id, name, stars, from_price: {amount: 18900, currency: "USD"}, available: true}], next_cursor }
GET /v1/hotels/{hotel_id}/availability?check_in=..&check_out=..&guests=2
→ 200 { room_types: [{room_type_id, rate_plans: [{rate_plan_id, refundable, cancel_by, nightly: [..], total: 61200}], remaining: 3}] }
POST /v1/reservations Idempotency-Key: <uuid>
{ hotel_id, room_type_id, rate_plan_id, check_in, check_out, rooms: 1, guests: 2, quoted_total: 61200 }
→ 201 { reservation_id, status: "held", hold_expires_at, total: 61200 }
→ 409 { code: "sold_out" } | 409 { code: "price_changed", new_total }
POST /v1/reservations/{id}/confirm Idempotency-Key: <uuid>
{ payment_method_token }
→ 200 { reservation_id, status: "confirmed", confirmation_code }
→ 410 { code: "hold_expired" } | 402 { code: "payment_declined" }
POST /v1/reservations/{id}/cancel Idempotency-Key: <uuid>
→ 200 { status: "cancelled", refund: { amount, status: "pending" } }
GET /v1/reservations/{id} → current status (the client polls this)
PUT /v1/partner/hotels/{id}/inventory { room_type_id, dates: [{date, total, rate}] }
Recovery contract: every mutating call takes an Idempotency-Key; a retry with the same key returns the stored response. If confirm times out, the client polls GET /reservations/{id} rather than retrying with a new key, and the server never charges twice for one reservation.
Data model
Booking data lives in a sharded relational database (PostgreSQL or MySQL), sharded by hotel_id, so every row a booking touches sits on one shard and one local transaction covers it.
| Table | Key columns | Notes |
|---|---|---|
hotels |
hotel_id PK, name, lat, lng, city_id, stars, timezone |
Source for the search index |
room_types |
room_type_id PK, hotel_id, max_occupancy, amenities |
|
rate_plans |
rate_plan_id PK, room_type_id, refundable, cancel_hours, meal_plan |
Policy snapshot copied onto each booking |
room_inventory |
PK (hotel_id, room_type_id, date), total, reserved, version |
One row per room type per night; reserved <= total check (or total + overbook_limit) |
rates |
PK (rate_plan_id, date), amount, currency |
Nightly price |
reservations |
reservation_id PK, hotel_id, guest_id, status, check_in, check_out, rooms, total, hold_expires_at, idempotency_key UNIQUE |
Status: held, confirmed, cancelled, expired, failed |
reservation_nights |
PK (reservation_id, date), room_type_id, nightly_amount |
Exactly which inventory rows the booking took |
payments |
payment_id PK, reservation_id, provider_ref, kind (auth, capture, refund), amount, status |
Append-only ledger |
outbox |
id, aggregate_id, event_type, payload, published_at |
Written in the same transaction as state changes |
Indexes: reservations(status, hold_expires_at) for the expiry sweeper; reservations(guest_id, check_in) for "my trips"; a guest-keyed copy of reservations goes to a separate store for the guest's trip list, since the primary is sharded by hotel.
Storage trade-off: counting rows per room-type-night (one counter row) instead of a row per physical room per night keeps contention to one row update per night and makes the check a single UPDATE. Search uses Elasticsearch/OpenSearch (geo, filters, ranking) plus Redis for per-hotel availability-and-price summaries; neither is authoritative.
High-level design
flowchart LR
U[Guest app] --> GW[API gateway]
GW --> SS[Search service]
GW --> RS[Reservation service]
SS --> ES[(Search index)]
SS --> AC[(Availability cache)]
RS --> DB[(Inventory and bookings DB)]
RS --> PAY[Payment service]
PAY --> PSP[Payment provider]
P[Partner feeds] --> IS[Inventory service]
IS --> DB
DB --> OB[Outbox relay]
OB --> K[Event stream]
K --> IDX[Index updater]
IDX --> ES
IDX --> AC
K --> N[Notification service]
SW[Hold expiry sweeper] --> DB
- Search (scalable path). The search service queries the index by geo and filters to get candidate hotels, then reads per-hotel availability summaries for the requested nights from the availability cache, drops hotels without availability on every night, prices the stay and ranks. Results may be seconds stale.
- Hotel page. Reads availability and rates for one hotel; served from the cache, with a short-TTL read of the primary if the guest is close to booking.
-
Reserve (authoritative path). The reservation service, in one transaction on the hotel's shard, decrements inventory for each night, inserts the held reservation and its nights, and writes an outbox event. It re-prices from
ratesand returnsprice_changedif the quote moved. -
Confirm. Authorize payment through the payment service with the reservation id as the provider idempotency key, then mark the reservation confirmed in a transaction that checks it is still
held. Capture happens at confirm for prepaid rates, at check-in for pay-at-hotel rates. -
Expire. The sweeper releases holds past
hold_expires_at, adding inventory back in the same transaction. - Propagate. The outbox relay publishes inventory changes to the event stream; the index updater refreshes the availability cache and search index within a few seconds; notifications send emails and push the booking to the hotel's PMS.
The two paths meet only at the database: search may show a hotel that just sold out, and the reserve call is where truth is decided.
Deep dives
Preventing double-booking under concurrent pressure. The reserve transaction runs one conditional update per night, in a fixed date order to avoid deadlocks:
UPDATE room_inventory
SET reserved = reserved + :rooms, version = version + 1
WHERE hotel_id = :h AND room_type_id = :rt AND date BETWEEN :in AND :last_night
AND reserved + :rooms <= total;
-- affected rows must equal the number of nights, else ROLLBACK → sold_out
| Approach | How | Fit |
|---|---|---|
| Conditional update (above) | Row lock held for the single statement | Pick this: one round trip, correct under any concurrency |
SELECT ... FOR UPDATE then update |
Explicit pessimistic lock | Correct, but holds locks across app logic |
| Optimistic version check | Read version, update WHERE version = :v, retry |
Good at low contention; retries storm on a popular hotel during a sale |
| Redis counter in front of DB |
DECRBY in Lua, then DB write |
Absorbs flash-sale spikes, but two sources of truth need reconciliation |
At 50 bookings/s spread across many hotels, row contention is rare. For a single hot hotel on New Year's Eve, a shard-level queue per (hotel, room type) serializes reserves so they fail fast instead of piling on locks. Holds count against inventory immediately, so a guest who reaches the payment page has a room.
Availability search across millions of properties. Querying 1.5B inventory rows per search is out. Precompute per hotel a compact summary: for each of the next 365 nights, the lowest available nightly price across room types and whether anything is free. In Redis that is one hash per hotel (avail:{hotel_id} → night → price|free_rooms), ~365 × 8 B ≈ 3 KB per hotel, 3 GB total. Search: geo query in the index returns 2,000 candidates; a pipelined HMGET fetches 3 nights each; filter, compute the stay total, rank, return 30. Popular date ranges (next 2 weekends, holidays) also go into the search index as a precomputed available_nights field so filters can run inside Elasticsearch. Updates arrive from the event stream; a nightly job rebuilds from the database to correct drift. Room-level availability for a multi-room, multi-guest request is checked on the hotel page, not in the list.
Booking lifecycle orchestration. The reservation is a state machine: held → confirmed → (cancelled | checked_in → completed), with held → expired and held → failed. Each transition is a conditional update (WHERE status = 'held'), so a confirm racing the expiry sweeper has exactly one winner. The dangerous case is payment authorized but the confirm write lost (crash, timeout):
- Authorize with the provider using
reservation_idas the idempotency key, so a retry never double-charges. - Write the payment row before calling the provider (
status = pending) and update it after. - A reconciler scans
pendingpayments older than 2 minutes, asks the provider for the result, then either confirms the reservation (if the hold is still valid, or the inventory can be re-taken) or voids the authorization and marks it failed. - The sweeper skips holds that have a pending payment, extending them by a grace period rather than releasing inventory under an in-flight charge.
This is a saga with compensations (void auth, release inventory), not a distributed transaction; the outbox keeps downstream systems (email, PMS, search) in step without dual writes.
Cancellations, refunds and overbooking. On cancel, the rate plan snapshot stored on the booking decides the fee (rules can change later; the guest keeps the terms they booked under). Inventory is released in the same transaction as the status change; the refund is an async payment job with its own idempotency key, so the guest sees refund: pending immediately and the money moves in 3–10 days. Hotels that allow overbooking set overbook_limit (say 5% of rooms) and the check becomes reserved + rooms <= total + overbook_limit. When a physical oversell happens (walk-in, PMS drift), an alert routes to support, who relocate the guest to a comparable nearby hotel at our cost; a daily reconciliation compares our counts with each hotel's PMS.
Rate consistency and pricing cache. Search shows cached prices; reserve re-prices from the rates table and compares with quoted_total. A small drop is honored silently; a rise returns price_changed and the client shows the new total before the guest commits. Price snapshots (nightly amounts, taxes, currency, FX rate) are stored on reservation_nights so later rate edits never change a booked price. Track the search-to-reserve price mismatch rate; above ~2% means the cache lags too far behind the feeds.
Trade-offs
- Sharding by
hotel_idmakes every booking a single-shard transaction but scatters a guest's trips across shards; we pay for a second, guest-keyed copy fed by events. - Counter rows per room-type-night are simple and fast but cannot express "room 412 specifically"; room assignment moves to the hotel's PMS.
- Holding inventory at reserve time guarantees the guest a room at payment but lets abandoned carts block rooms for up to 10 minutes; a short TTL limits the cost.
- Stale search availability keeps search cheap and fast, at the cost of occasional "sold out" at reserve time (target under 1% of reserve attempts).
- Saga with compensations instead of two-phase commit across our DB and the payment provider: no distributed locks, but we need reconcilers and idempotency everywhere.
- Overbooking raises occupancy and revenue but creates rare, expensive relocations and guest anger; it stays a per-hotel opt-in.
- A Redis counter in front of the database would absorb flash-sale spikes but adds a second source of truth; we keep the database authoritative and add per-hotel queuing only for hot spots.
Interviewer expectations
Mid-level (L4)
- Clear entities (hotel, room type, inventory per night, reservation) and the search / reserve / confirm / cancel APIs.
- Prevents double-booking with a transaction and a row lock or conditional update.
- Separates search from booking and puts a cache in front of search.
Senior (L5)
- Models inventory as room-type-night counters and checks every night of the stay atomically on one shard.
- Holds with TTL and an expiry sweeper; idempotency keys on reserve, confirm and cancel.
- Precomputed availability summaries for search, with event-driven updates and accepted staleness, re-checked at reserve time.
- Handles price changes between search and booking, and snapshots the cancellation policy onto the booking.
Staff (L6+)
- Treats the payment step as a saga: provider idempotency, pending-payment reconciliation, the race between confirm and hold expiry.
- Outbox for consistent propagation to search, notifications and partner PMS; nightly drift correction.
- Hot-hotel contention during sales, overbooking policy with oversell recovery, and SLOs such as search-to-reserve sold-out rate and price mismatch rate.
- Explains what is strongly consistent and what is eventually consistent, and why that boundary sits at the reserve call.