🏗️ Hotel Booking System — High-Level Design¶
Target Level: Senior/Staff Engineer Focus: Inventory management, pricing strategies, booking lifecycle
1. SYSTEM OVERVIEW¶
Purpose: Online hotel reservation system with dynamic pricing and inventory management.
Scale: a chain of ~1,000 properties × ~200 rooms = 200K rooms. At ~70% occupancy and ~2.5 nights per stay that is roughly 55K new bookings/day (~1/s average, ~10/s at peak). Search is 100–1000× booking volume (look-to-book ratio), so reads and writes need different paths. (A single 500-room hotel cannot produce 100K bookings/month: 500 rooms × 30 nights = 15K room-nights.)
2. SYSTEM ARCHITECTURE¶
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Guest App │ │ Admin Panel │ │ OTA Channel │
│ (Web/Mobile) │ │ (Management) │ │ (Booking.com)│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌────────────▼────────────┐
│ API Gateway (REST) │
└────────────┬────────────┘
│
┌────────────┴────────────┐
│ Hotel Booking Service │
│ (Java - Spring Boot) │
├─────────────────────────┤
│ Pricing | Inventory │
│ Booking | Notification │
└────────────┬────────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ PostgreSQL │ │ Redis │ │ Message Q │
│ (Bookings + │ │ (Search │ │ (Kafka / │
│ Inventory) │ │ cache) │ │ RabbitMQ) │
└─────────────┘ └─────────────┘ └─────────────┘
Figure: clients, booking service and stores.
flowchart TB
G1["Guest app"] --> G["API gateway"]
A["Admin panel"] --> G
OT["OTA channel"] --> G
G --> S["Hotel booking service: pricing, inventory, booking, notification"]
S --> P[("PostgreSQL: bookings + inventory")]
S --> R[("Redis: search cache")]
S --> Q["Message queue"]
3. BOOKING LIFECYCLE¶
SEARCH (no state) → HELD ──pay──▶ CONFIRMED ──▶ CHECKED_IN ──▶ CHECKED_OUT
│ └─TTL──▶ EXPIRED │
└─────────▶ CANCELLED ◀──────┘ (refund per policy)
Figure: booking lifecycle.
stateDiagram-v2
[*] --> HELD: hold from search
HELD --> CONFIRMED: pay
HELD --> EXPIRED: TTL
HELD --> CANCELLED
CONFIRMED --> CHECKED_IN
CONFIRMED --> CANCELLED: refund per policy
CHECKED_IN --> CHECKED_OUT
4. PRICING STRATEGY¶
| Strategy | Logic | Effect |
|---|---|---|
| Base Rate | RoomType.baseRate × nights | Minimum revenue |
| Seasonal | 1.0-2.5× multiplier by month | Captures peak demand |
| Loyalty | 5-30% discount by tier | Repeat customer retention |
| Last Minute | 20% discount within 3 days | Fills unsold inventory |
| Extended Stay | 10% discount for 7+ nights | Increases occupancy |
5. CONCURRENCY & EDGE CASES¶
| Scenario | Approach |
|---|---|
| Double booking | DB constraint is the source of truth: UNIQUE(room_id, night) or an exclusion constraint on (room_id, daterange); or a conditional decrement UPDATE inventory SET available = available - 1 WHERE type=? AND night BETWEEN ? AND ? AND available > 0 that must touch exactly nights rows, else roll back |
| Payment failure | Hold with TTL (expires_at); sweeper expires stale holds with a conditional UPDATE ... WHERE status='HELD'; late payment for an expired hold is refunded |
| Overbooking tolerance | Allow 5% overbooking, VIP guests bumped last |
| Cancellation | Release inventory, process refund by policy |
| No-show | Mark NO_SHOW the morning after the check-in date, charge per policy, release the remaining nights |
6. TRADE-OFF ANALYSIS¶
| Decision | Choice | Rationale |
|---|---|---|
| Inventory granularity | Per-night rows (DB) / per-room interval set (LLD) | Per-night rows make the constraint and the conditional decrement trivial; an interval set is O(log n) in memory |
| Pricing calculation | At booking time, not check-out | Customer knows price upfront |
| Notifications | Async via observer | Non-blocking, extensible |
| Room assignment | At booking (LLD) vs at check-in (most hotels) | Late assignment enables overbooking and better packing; early assignment guarantees a specific room |
7. CONSISTENCY, IDEMPOTENCY & FAILURE MODES¶
Consistency choice: inventory and bookings live in the same relational database and are changed in one transaction. Strong consistency is non-negotiable on the write path (a double booking costs a walked guest); search can be eventually consistent and served from a cache that is a few seconds stale, because hold re-validates against the database.
Partitioning: shard by property_id. A booking only ever touches one property's inventory, so every transaction is single-shard and no distributed transaction is needed.
| Failure | Response |
|---|---|
| Client retries after a timeout | Idempotency-Key header; idempotency(key PK, request_hash, booking_id) written in the booking transaction; a retry returns the stored result |
| Payment captured, then confirm fails (hold expired, crash) | Outbox/saga: record the payment, and a reconciler either confirms (if inventory still held) or refunds. Never leave money taken without a booking |
| Crash between reserving rooms and writing the booking | Both happen in one DB transaction, so the reservation rolls back with it |
| Same room sold via an OTA (Booking.com) and direct | The channel manager pushes availability to OTAs with a delay, so OTAs can oversell. Accept OTA bookings into the same constraint-checked path; on conflict, reject or relocate and treat it as an overbooking case |
| Cache shows rooms that are gone | Expected; hold fails cleanly with "no longer available" and the cache entry is invalidated |
| Sweeper and confirm race on one hold | Conditional UPDATE ... WHERE status='HELD'; whichever commits first wins, the other affects 0 rows |
Figure: hold with TTL; confirm and the sweeper both use a conditional update, so exactly one wins.
flowchart TD
H["hold: reserve rooms, status HELD, expires_at"] --> P["Guest pays"]
P --> C{"UPDATE ... WHERE status = HELD affects 1 row?"}
C -- Yes --> OK[CONFIRMED]
C -- No --> V["Void or refund payment"]
H --> S["Sweeper: UPDATE WHERE HELD and expired"]
S --> EX[EXPIRED, release rooms]
Capacity: 55K bookings/day is trivial for one Postgres primary per region; the hard part is search (~50M searches/day at a 1000:1 look-to-book ratio, ~600/s average, a few thousand/s at peak), which is why search runs off a cache or search index rather than the booking database.