Design a Hotel Reservation System
Design a Booking.com-style hotel reservation backend that tracks room inventory per night and prevents double booking under heavy concurrent demand.
Requirements
Guests search hotels, pick a room type and dates, and pay. Hotels manage their rooms and rates. The heart of the problem is inventory: a room type has a fixed number of rooms per night, and two guests must never be confirmed for the last one.
Functional
- Search hotels by city and dates; show available room types and prices.
- Reserve a room type for a date range, then pay to confirm.
- Cancel a reservation (with policy-based refunds).
- Hotel admins add/update rooms, inventory and rates.
- Configurable controlled overbooking (e.g. allow 105% of capacity).
Non-functional
- No unintended double booking: inventory correctness is critical.
- High read throughput for search; reads outnumber bookings ~100–1000×.
- Moderate latency: search under ~300 ms, booking under ~1–2 s (includes payment).
- High availability, especially during holiday peaks and flash sales.
Clarify whether guests book a room type (typical for hotels: "Deluxe King") or a specific room (typical for Airbnb-style listings). Booking by type means inventory is a counter per type per night, which simplifies concurrency a lot.
Capacity Estimates
Assume 5,000 hotels × 1,000 rooms on average = 5 M rooms, 70% occupancy, 3-night average stay.
| Metric | Math | Value |
|---|---|---|
| Room-nights booked per day | 5 M × 0.7 | ~3.5 M |
| Reservations per day | 3.5 M / 3 nights | ~1.2 M |
| Booking TPS | 1.2 M / 86,400 | ~14 /s (peak ~150 /s) |
| Search / detail QPS | ~1,000× bookings | ~14 K /s (peak ~150 K /s) |
| Inventory rows (2 years ahead) | 5,000 hotels × ~20 room types × 730 days | ~73 M rows |
| Reservation storage | 1.2 M/day × 1 KB × 365 | ~440 GB/year |
Booking writes are low enough for a single relational primary per region. Search traffic is the scaling challenge, and it can be served from caches and replicas because it tolerates slightly stale data.
API Design
GET /v1/hotels/search?city=Paris&checkIn=2026-12-20&checkOut=2026-12-23&guests=2
200: [{ "hotelId": 77, "name": "...", "minPrice": 18900, "available": true }, ...]
GET /v1/hotels/{hotelId}/availability?checkIn=...&checkOut=...
200: [{ "roomTypeId": 3, "name": "Deluxe King", "remaining": 4, "totalPrice": 56700 }]
POST /v1/reservations
Headers: Idempotency-Key: 1c8e...
Body: { "hotelId": 77, "roomTypeId": 3, "checkIn": "2026-12-20",
"checkOut": "2026-12-23", "guestId": 501 }
201: { "reservationId": "r_9001", "status": "PENDING", "holdExpiresAt": "..." }
409: { "error": "SOLD_OUT" }
POST /v1/reservations/{id}/confirm { "paymentToken": "pm_tok..." }
POST /v1/reservations/{id}/cancel
PUT /v1/admin/hotels/{hotelId}/roomtypes/{rtId}/inventory (hotel staff)
- The idempotency key ensures a retried "Book" click does not create two reservations.
- A reservation starts as a short-lived hold (e.g. 10 minutes) while the guest pays.
Data Model
CREATE TABLE room_type_inventory ( hotel_id BIGINT, room_type_id BIGINT, stay_date DATE, total_inventory INT NOT NULL, total_reserved INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, -- for optimistic locking PRIMARY KEY (hotel_id, room_type_id, stay_date), CHECK (total_reserved <= total_inventory * 1.05) -- overbooking cap ); CREATE TABLE reservations ( reservation_id VARCHAR(32) PRIMARY KEY, idempotency_key VARCHAR(64) UNIQUE NOT NULL, hotel_id BIGINT, room_type_id BIGINT, guest_id BIGINT, check_in DATE, check_out DATE, status VARCHAR(16), -- PENDING, CONFIRMED, CANCELLED, EXPIRED hold_expires_at TIMESTAMP, total_price BIGINT ); CREATE TABLE rates ( hotel_id BIGINT, room_type_id BIGINT, stay_date DATE, price_minor BIGINT, PRIMARY KEY (hotel_id, room_type_id, stay_date) );
Inventory is stored per room type per night. A 3-night stay touches 3 inventory rows, and all must be updated atomically.
High-Level Design
flowchart LR User --> CDN[CDN for static content] User --> GW[API Gateway] GW --> SS[Search Service] GW --> RS[Reservation Service] GW --> HS[Hotel Admin Service] SS --> ES[(Search Index)] SS --> AC[(Availability Cache)] RS --> DB[(Inventory and Reservations DB)] RS --> PAY[Payment Service] HS --> DB DB -->|CDC events| BUS[[Event Bus]] BUS --> AC BUS --> ES EXP[Hold Expiry Worker] --> DB
- Search reads from a search index (Elasticsearch) for hotels and from an availability cache for "is anything left". Results may lag the database by seconds; the authoritative check happens at booking time.
- Reservation Service owns the inventory database. It reserves inventory in one transaction, then hands off to payment.
- Change data capture streams inventory changes to refresh the cache and index.
- The expiry worker releases holds whose payment never completed.
Deep Dives
Concurrency control options
The classic race: one Deluxe King left on Dec 20. Two guests click "Book" at the same moment; both read reserved = 9, total = 10, both write reserved = 10, and both get confirmed. Three standard fixes:
1. Pessimistic locking
BEGIN; SELECT total_inventory, total_reserved FROM room_type_inventory WHERE hotel_id=77 AND room_type_id=3 AND stay_date BETWEEN '2026-12-20' AND '2026-12-22' FOR UPDATE; -- application checks every night has space UPDATE room_type_inventory SET total_reserved = total_reserved + 1 WHERE ...; INSERT INTO reservations ...; COMMIT;
Rows are locked until commit, so the second transaction waits and then sees the updated count. Safe and easy to reason about, but locks reduce throughput and can deadlock if transactions lock dates in different orders (always lock in ascending date order).
2. Optimistic locking — read the row with its version, then:
UPDATE room_type_inventory SET total_reserved = total_reserved + 1, version = version + 1 WHERE hotel_id=77 AND room_type_id=3 AND stay_date='2026-12-20' AND version = 41;
If 0 rows are updated, someone else won; retry or fail. No locks held, so it is fast when contention is low, but under heavy contention (flash sale) most attempts fail and retry.
3. Database constraint / conditional update
UPDATE room_type_inventory SET total_reserved = total_reserved + 1 WHERE hotel_id=77 AND room_type_id=3 AND stay_date BETWEEN ... AND total_reserved + 1 <= total_inventory;
Check the affected row count equals the number of nights; otherwise roll back. The database's row-level atomicity does the work. Simple and efficient, though the business rule now lives partly in SQL.
Hotel bookings have low write contention per row (a given room type on a given night is rarely booked by many people in the same second), so optimistic locking or a conditional update is usually the best fit. Pessimistic locking shines when contention is high and retries are expensive.
Holds, payment and expiry
Booking is a two-step process: reserve inventory, then pay. Holding inventory while the guest types card details prevents "it was available a minute ago" failures.
- Create the reservation as
PENDINGwithhold_expires_at = now + 10 min, incrementingtotal_reservedin the same transaction. - On successful payment, flip to
CONFIRMEDwith a conditional update (WHERE status = 'PENDING'). - The expiry worker (or a delayed queue message) finds expired
PENDINGrows, marks themEXPIREDand decrements inventory, again conditionally so it cannot race with a late confirmation. - If payment succeeds after the hold already expired, refund automatically or try to re-reserve.
Never hold a database transaction open while waiting for the guest or the payment provider. Commit the hold, release locks, and use status fields plus conditional updates to coordinate later steps.
Overbooking
Hotels intentionally oversell because a few percent of guests cancel or do not show up. Model it as a per-room-type ratio: allow total_reserved <= total_inventory × 1.05. The check is the same conditional update with a different limit. Front-desk tools handle the rare case where everyone shows up (upgrade or relocate).
Search vs booking consistency
Availability shown in search is a hint, not a promise.
- Cache per (hotel, room type, date) remaining counts with a short TTL, refreshed by CDC events.
- Because inventory changes slowly relative to reads, a few seconds of staleness is fine.
- The booking transaction is the single source of truth; if it fails with
SOLD_OUT, refresh the page with alternatives. - Prices follow the same pattern: show cached prices but re-quote and lock the price in the reservation at booking time.
Partitioning the data
Almost every query is scoped to one hotel, so partition inventory and reservations by hotel_id. A multi-night booking then stays within one shard and one local transaction. Guest-centric queries ("my trips") are served by a secondary index or a separate read model keyed by guest_id, populated asynchronously.
Scaling & Reliability
- Read path: CDN for images and static pages, search index replicas, availability cache, DB read replicas. This absorbs 99%+ of traffic.
- Write path: a relational primary per shard with synchronous standby; low TPS makes this comfortable.
- Hot hotels during flash sales: put a queue or token bucket in front of the reservation endpoint for that hotel so the database sees a steady stream instead of a thundering herd.
- Idempotency on reservation and confirmation endpoints handles client retries.
- Microservice boundaries: if reservation and payment are separate services, use a saga (reserve → pay → confirm, with compensating release) rather than a distributed transaction.
- Channel managers: inventory is also sold through other travel sites; sync via an event feed and keep a small buffer to absorb propagation delay.
Trade-offs
| Decision | Option A | Option B | Typical choice |
|---|---|---|---|
| Locking | Pessimistic (SELECT ... FOR UPDATE) |
Optimistic (version) | Optimistic / conditional update |
| Inventory granularity | Specific room | Room type per night | Room type per night |
| Database | NoSQL (scale) | Relational (transactions, constraints) | Relational, sharded by hotel |
| Search availability | Query DB live (fresh) | Cache + CDC (fast, slightly stale) | Cache + recheck at booking |
| Payment timing | Pay first, then reserve | Hold, then pay | Hold, then pay |
| Overbooking | Never | Small configurable ratio | Configurable per hotel |
Interview Tips
- Model inventory as rows per room type per night early; everything else builds on it.
- Show the double-booking race with concrete numbers, then present all three fixes and pick one with reasons.
- Keep transactions short; explain holds and expiry instead of long-lived locks.
- Separate the stale-tolerant search path from the strongly consistent booking path.
- Mention idempotency keys for the Book button and deadlock-avoiding lock ordering.
- Bring up overbooking; it shows you understand the business, not just the database.