System design deep dive · HLD
Design a Hotel Booking System: full high-level design.
Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for date-range availability search and for reservation with overbooking prevention, and an entity-relationship diagram for hotels, rooms, reservations, and rate plans - with the reasoning an interviewer expects behind every box and arrow.
1. Clarify requirements before drawing any box
Hotel booking looks like a CRUD app until you notice the core invariant: two guests must never both be confirmed for the same physical room on the same night. Everything about inventory, caching, and consistency is downstream of protecting that one guarantee.
Functional requirements
Non-functional requirements
2. Back-of-the-envelope capacity estimation
These numbers decide whether availability can be served from a cache (yes, aggressively) and how the reservation path's write contention is bounded to a single room type at a single hotel rather than the whole system.
| Metric | Assumption | Resulting estimate |
|---|---|---|
| Inventory size | 2 million hotels, ~80 rooms average, ~3 room types/hotel | ~160 million physical rooms; availability tracked per (room_type, date) not per physical room |
| Search requests | ~50 million searches/day globally | ~580 searches/sec average, ~5,000/sec peak (weekend/holiday planning surges) |
| Bookings | ~100,000 confirmed bookings/day | ~1.2 bookings/sec average, ~50/sec peak - a search:booking ratio of roughly 500:1 |
| Availability cache size | Room types × a 365-day rolling search window | 2M hotels × 3 room types × 365 days ≈ 2.2 billion (room_type, date) cells - fits a sharded in-memory/columnar cache, refreshed on booking events |
| Reservation write contention | Concurrent booking attempts for the same room_type+date range | Bounded to a single hotel's single room type per night - trivially shardable, never a global lock |
3. High-level design (HLD)
The HLD separates the read-heavy, cache-friendly search path from the low-volume, correctness-critical reservation path - both ultimately resolve against the same inventory database, but only the reservation path is allowed to write to it.
What each box owns
Search service & availability cache
Resolves a location/date-range/guest-count query entirely against a denormalized, per-(room_type, date) availability cache - it never touches the inventory database directly. This cache can lag reality by a few seconds without any real harm, since the reservation path re-validates availability authoritatively at booking time regardless of what search showed.
Reservation service & inventory DB
Owns the only write path to room-night inventory. A booking is a two-step "hold, then confirm" transaction: first decrement available count for every date in the stay (atomically, guarded so it can never go below zero), then finalize the reservation record. This is the one part of the system where correctness strictly outranks latency or throughput.
Pricing / rate-plan service
Resolves the final nightly price for a given room type, date range, and selected rate plan (flexible vs non-refundable, length-of-stay discounts, promotional codes). Pricing is deliberately decoupled from availability - a room can be available at multiple different prices depending on which rate plan the guest qualifies for or selects.
CDC / notification pipeline
Every confirmed booking or cancellation in the inventory DB emits a change event (via change-data-capture or an explicit outbox) that asynchronously updates the availability cache. This keeps the cache "close enough" to real time for search purposes without the reservation path ever waiting on a cache write to complete.
4. Detailed architecture diagram
The architecture diagram shows inventory sharded by hotel_id (so no booking anywhere in the world contends with another hotel's booking), a globally distributed availability cache, and rate-plan pricing computed independently of the write-locked inventory path.
| Decision | Choice | Reasoning |
|---|---|---|
| Inventory sharding key | Hash of hotel_id | Every booking is scoped to a single hotel's room inventory; sharding on hotel_id guarantees write contention never crosses hotels, however busy the platform gets globally. |
| Search consistency | Eventually consistent cache, refreshed via CDC | Search results being a few seconds stale is an acceptable trade-off given the reservation path always re-checks authoritative inventory before confirming. |
| Overbooking prevention | Atomic conditional decrement at the inventory DB, not application-level checks | A read-then-write check in application code is racy under concurrency; the decrement itself must be the atomic operation that both checks and reserves. |
| Idempotency | Client-supplied idempotency key on every booking request | A guest's retried request (double-tap, network timeout) must not create two reservations or double-decrement inventory for the same intended booking. |
5. Sequence diagrams for the two critical flows
Search shows how the cache-only read path stays fast and decoupled; reservation shows exactly where the atomic conditional decrement prevents two guests from ever winning the same room-night.
5.1 Room availability search across a date range
Step 3's "available for all nights" check requires the room_type to have a positive count on every single date in the requested range, not just the check-in date - a common bug is checking only the first night. Every one of these steps reads from a cache or a stateless pricing computation; none of them touch the inventory database, which is exactly why search can absorb 5,000 requests/sec without any risk of lock contention with actual bookings.
5.2 Reservation with overbooking prevention
Step 2 is the entire mechanism: the conditional WHERE count > 0 makes the check and the decrement one atomic database operation instead of two separate steps a race condition could slip between - this is what makes overbooking structurally impossible rather than merely unlikely. Step 3b shows the multi-night case: if the stay spans 3 nights and any single night is already fully booked, the entire transaction rolls back rather than partially reserving some nights, since a "half-booked" stay is not a valid reservation. Step 5's cache update is deliberately asynchronous and after the fact - the guest's confirmation in step 6 never waits on it.
6. Entity-relationship (ER) diagram and schema
The schema has to answer: how is per-night inventory tracked without one row per physical room, how does a reservation span multiple nights atomically, and how do rate plans attach pricing rules without duplicating them per date.
Key modeling decisions
| Storage choice | Use when | Watch out for |
|---|---|---|
| Relational (Postgres/MySQL), sharded by hotel_id, for rooms/reservations | Overbooking prevention requires atomic, transactional conditional writes across multiple date rows at once. | A very long stay (many nights) means locking many rows in one transaction; length-of-stay caps or careful lock ordering avoid excessive contention. |
| Wide denormalized cache (Redis/columnar) for the availability read path | Access pattern is "is this room type open for this whole date range," at very high read volume, tolerant of staleness. | Never treated as authoritative - the reservation path always re-validates against the relational inventory DB before confirming. |
7. Deep dives interviewers actually probe
How exactly does the atomic decrement prevent a race condition that a naive check-then-write wouldn't?
A naive implementation reads available_count, checks it's > 0 in application code, then issues a separate UPDATE to decrement it. Between the read and the write, another request can read the same (now-stale) count and also decide it's safe to book - both succeed, and the room is oversold. Folding the check into the WHERE clause of the UPDATE itself (SET count = count - 1 WHERE count > 0) makes the database's own row-level locking do the serialization: only one of two concurrent transactions can win the conditional update, and the loser's statement affects zero rows, which the application interprets as "sold out, try another room type."
What happens if a guest's booking request times out on their end but actually succeeded server-side?
The client (correctly) doesn't know if its request succeeded, and retries with the same idempotency_key. Because that key has a unique constraint on the reservations table, the retried request's INSERT fails harmlessly (or the service short-circuits and returns the original confirmation) instead of creating a second reservation and double-decrementing inventory. This is why idempotency keys are treated as a core correctness mechanism, not just a nice-to-have API convention.
How is a hold/cart-style temporary reservation handled during checkout?
Many booking flows briefly "hold" inventory (e.g. for 10-15 minutes) while a guest completes payment, so it isn't sold to someone else mid-checkout. This is modeled as a reservation in a pending state with a short expiry - the same atomic decrement reserves the inventory immediately, but a background job releases (increments back) any pending reservation whose hold window expires without payment completing, converting a pending reservation to expired rather than confirmed.
How does search stay fast when a query spans a wide date range across thousands of hotels?
The availability cache is denormalized specifically to answer "is this room type open for every night in this range" as a single range query per hotel/room_type, rather than joining across per-night rows at query time. Geographic sharding of the cache (by city/region) also means a search for "hotels in Tokyo" never scans data for hotels anywhere else, keeping each shard's working set small and cache-friendly.
What is the single biggest bottleneck as this scales 10x?
Not search - the cache tier scales horizontally by geography. The real bottleneck becomes write contention on a small number of extremely popular hotels during a demand spike (a major event driving thousands of simultaneous booking attempts at the same few properties) where many transactions compete for locks on the same room_type+date rows. The fix is finer-grained row locking (per date rather than per room_type spanning all dates) combined with a request queue that serializes contested bookings for that specific hotel rather than letting them all hit the database simultaneously and mostly fail.
Post a Comment
Add