Design a Hotel Booking System
Low-level design for a hotel booking platform: date-range availability without double-booking, booking concurrency, strategy-based cancellation and pricing policies, and a saga-style book-pay-confirm flow.
Design a Hotel Booking System
Hotel Booking is fundamentally a date-range allocation problem wearing a booking-flow costume. The entity model (Hotel, Room, Booking, Guest) is easy; the actual interview signal is whether you can (1) check availability across an arbitrary date range without a double-booking race, (2) model cancellation policy and pricing as swappable strategies instead of if chains, and (3) sequence booking → payment → confirmation as a multi-step process that can fail partway through and needs to unwind cleanly.
1. Requirements
Functional requirements
- Platform hosts multiple hotels, each with rooms of types Standard, Deluxe, Suite.
- A guest searches a hotel for availability over a date range and room type.
- A guest creates a booking for available room(s) across check-in/check-out dates.
- A guest cancels a booking, subject to a cancellation policy that determines any refund/fee.
- Pricing varies by room type and by date (weekday/weekend/seasonal).
- Payment is processed as part of booking; a failed payment must not leave a "ghost" reservation.
- Fully booked hotels/room-types offer a waitlist guests can join.
Non-functional requirements
- No double-booking: two guests must never both hold a confirmed booking for the same room on an overlapping date range.
- Concurrency-safe under simultaneous booking attempts for the same room and overlapping dates.
- Extensible cancellation policy (flexible / moderate / strict) without touching the booking flow itself.
- Extensible pricing (seasonal, weekend, promotional) without touching
RoomorBookingService. - Booking creation should be resilient to a payment step that can fail or time out — never silently duplicate a charge or a room hold.
Say the date-overlap check out loud early: "two ranges [a, b) and [c, d) overlap iff a < d and c < b." Getting this comparison right (and using half-open intervals so a checkout on the 10th doesn't conflict with a check-in on the 10th) is the single most common place candidates lose points in this problem.
2. Actors & Use Cases
| Actor | Description |
|---|---|
| Guest | Searches availability, creates/cancels bookings, makes payment. |
| Hotel Admin | Configures rooms, room types, and pricing for a hotel. |
| Booking Service | Orchestrates availability check → hold → payment → confirmation. |
| Payment Gateway (external) | Processes the actual charge; abstracted behind an interface. |
Primary use cases
- Search availability — guest specifies hotel, date range, room type → service returns rooms with no overlapping confirmed/pending booking in that range.
- Create booking — guest selects a room → service creates a
PENDINGbooking (a short-lived hold) → payment is processed → booking becomesCONFIRMED(orCANCELLEDon failure). - Cancel booking — guest cancels a
CONFIRMEDbooking → cancellation policy computes refund/fee based on how far ahead of check-in the cancellation occurs. - Join waitlist — no rooms available for the requested range → guest joins a FIFO waitlist for that hotel/room-type/date-range; notified if a cancellation frees a matching room.
3. Class Diagram
4. Core Class Design
Room — availability is the core query
class Room {
private final String id;
private final RoomType type;
private final double basePrice;
// Sorted by checkIn so overlap scans can short-circuit; in production this
// check is pushed into the database (see §6) rather than an in-memory list.
private final List<Booking> bookings = new ArrayList<>();
Room(String id, RoomType type, double basePrice) {
this.id = id;
this.type = type;
this.basePrice = basePrice;
}
/** Half-open interval overlap check: [checkIn, checkOut) vs each existing booking. */
synchronized boolean isAvailable(LocalDate checkIn, LocalDate checkOut) {
for (Booking b : bookings) {
if (b.getStatus() == BookingStatus.CANCELLED) continue;
boolean overlaps = checkIn.isBefore(b.getCheckOut()) && b.getCheckIn().isBefore(checkOut);
if (overlaps) return false;
}
return true;
}
synchronized void addBooking(Booking booking) { bookings.add(booking); }
String getId() { return id; }
RoomType getType() { return type; }
double getBasePrice() { return basePrice; }
}Booking, Guest, Payment
enum RoomType { STANDARD, DELUXE, SUITE }
enum BookingStatus { PENDING, CONFIRMED, CANCELLED, COMPLETED }
enum PaymentStatus { SUCCESS, FAILED, PENDING }
final class Guest {
private final String id;
private final String name;
private final String contact;
Guest(String id, String name, String contact) {
this.id = id; this.name = name; this.contact = contact;
}
String getId() { return id; }
}
class Booking {
private final String id;
private final Guest guest;
private final Room room;
private final LocalDate checkIn;
private final LocalDate checkOut;
private volatile BookingStatus status;
private final double amount;
Booking(String id, Guest guest, Room room, LocalDate checkIn, LocalDate checkOut, double amount) {
this.id = id;
this.guest = guest;
this.room = room;
this.checkIn = checkIn;
this.checkOut = checkOut;
this.amount = amount;
this.status = BookingStatus.PENDING;
}
void setStatus(BookingStatus status) { this.status = status; }
BookingStatus getStatus() { return status; }
LocalDate getCheckIn() { return checkIn; }
LocalDate getCheckOut() { return checkOut; }
Room getRoom() { return room; }
double getAmount() { return amount; }
String getId() { return id; }
}
final class Payment {
final String id;
final double amount;
volatile PaymentStatus status;
Payment(String id, double amount) {
this.id = id;
this.amount = amount;
this.status = PaymentStatus.PENDING;
}
}PricingStrategy
interface PricingStrategy {
double priceFor(Room room, LocalDate checkIn, LocalDate checkOut);
}
class SeasonalPricingStrategy implements PricingStrategy {
private final Set<DayOfWeek> weekendDays = Set.of(DayOfWeek.FRIDAY, DayOfWeek.SATURDAY);
private final double weekendMultiplier = 1.3;
public double priceFor(Room room, LocalDate checkIn, LocalDate checkOut) {
double total = 0;
for (LocalDate date = checkIn; date.isBefore(checkOut); date = date.plusDays(1)) {
double nightly = room.getBasePrice();
if (weekendDays.contains(date.getDayOfWeek())) nightly *= weekendMultiplier;
total += nightly;
}
return total;
}
}CancellationPolicy — strategy per policy tier
record RefundResult(double refundAmount, double cancellationFee) {}
interface CancellationPolicy {
RefundResult evaluate(Booking booking, LocalDate cancellationDate);
}
/** Full refund if cancelled 24h+ before check-in, otherwise no refund. */
class FlexiblePolicy implements CancellationPolicy {
public RefundResult evaluate(Booking booking, LocalDate cancellationDate) {
long daysBefore = ChronoUnit.DAYS.between(cancellationDate, booking.getCheckIn());
if (daysBefore >= 1) return new RefundResult(booking.getAmount(), 0);
return new RefundResult(0, booking.getAmount());
}
}
/** Tiered refund: 100% at 7+ days, 50% at 3-6 days, 0% inside 3 days. */
class ModeratePolicy implements CancellationPolicy {
public RefundResult evaluate(Booking booking, LocalDate cancellationDate) {
long daysBefore = ChronoUnit.DAYS.between(cancellationDate, booking.getCheckIn());
double refundRate = daysBefore >= 7 ? 1.0 : daysBefore >= 3 ? 0.5 : 0.0;
double refund = booking.getAmount() * refundRate;
return new RefundResult(refund, booking.getAmount() - refund);
}
}
/** No refunds, ever — e.g. non-refundable rate. */
class StrictPolicy implements CancellationPolicy {
public RefundResult evaluate(Booking booking, LocalDate cancellationDate) {
return new RefundResult(0, booking.getAmount());
}
}WaitlistManager
record WaitlistEntry(Guest guest, String hotelId, RoomType roomType, LocalDate checkIn, LocalDate checkOut) {}
class WaitlistManager {
private final Map<String, Queue<WaitlistEntry>> waitlists = new ConcurrentHashMap<>();
void join(WaitlistEntry entry) {
String key = key(entry.hotelId(), entry.roomType());
waitlists.computeIfAbsent(key, k -> new ConcurrentLinkedQueue<>()).add(entry);
}
Optional<WaitlistEntry> pollNext(String hotelId, RoomType roomType) {
Queue<WaitlistEntry> queue = waitlists.get(key(hotelId, roomType));
return queue == null ? Optional.empty() : Optional.ofNullable(queue.poll());
}
private String key(String hotelId, RoomType roomType) { return hotelId + ":" + roomType; }
}BookingService — the saga orchestrator
interface PaymentGateway {
Payment charge(Guest guest, double amount);
}
class BookingService {
private final PricingStrategy pricingStrategy;
private final CancellationPolicy cancellationPolicy;
private final PaymentGateway paymentGateway;
private final WaitlistManager waitlistManager;
BookingService(PricingStrategy pricingStrategy, CancellationPolicy cancellationPolicy,
PaymentGateway paymentGateway, WaitlistManager waitlistManager) {
this.pricingStrategy = pricingStrategy;
this.cancellationPolicy = cancellationPolicy;
this.paymentGateway = paymentGateway;
this.waitlistManager = waitlistManager;
}
Booking createBooking(Guest guest, Room room, LocalDate checkIn, LocalDate checkOut) {
// Step 1: hold — atomic check-and-reserve (see §6 for the race this closes).
synchronized (room) {
if (!room.isAvailable(checkIn, checkOut)) {
waitlistManager.join(new WaitlistEntry(guest, room.getId(), room.getType(), checkIn, checkOut));
throw new RoomUnavailableException(room.getId());
}
double amount = pricingStrategy.priceFor(room, checkIn, checkOut);
Booking booking = new Booking(UUID.randomUUID().toString(), guest, room, checkIn, checkOut, amount);
room.addBooking(booking); // reserved as PENDING inside the same lock as the check
return finalizeWithPayment(booking, guest);
}
}
// Step 2 & 3: pay, then confirm or roll back — the saga's compensating action.
private Booking finalizeWithPayment(Booking booking, Guest guest) {
Payment payment = paymentGateway.charge(guest, booking.getAmount());
if (payment.status == PaymentStatus.SUCCESS) {
booking.setStatus(BookingStatus.CONFIRMED);
} else {
booking.setStatus(BookingStatus.CANCELLED); // compensating action: release the hold
}
return booking;
}
RefundResult cancelBooking(Booking booking, LocalDate cancellationDate) {
RefundResult result = cancellationPolicy.evaluate(booking, cancellationDate);
booking.setStatus(BookingStatus.CANCELLED);
waitlistManager.pollNext(booking.getRoom().getId(), booking.getRoom().getType())
.ifPresent(entry -> {/* notify entry.guest() that a room opened up */});
return result;
}
}5. Design Patterns Applied
| Pattern | Where used | Why |
|---|---|---|
| Strategy | PricingStrategy, CancellationPolicy | Both pricing rules and refund policy vary by hotel/rate-plan and need to swap independently of BookingService's orchestration logic. |
| Saga (orchestration) | BookingService.createBooking → finalizeWithPayment | Booking is a multi-step process (hold → pay → confirm) where a mid-sequence failure needs a compensating action (release the hold) instead of a single atomic transaction spanning an external payment gateway. |
| Factory Method | BookingFactory.forHotel(hotel) wiring the right pricing/cancellation strategy per hotel's rate plan (not shown in full — standard extension) | Different hotels/rate-plans compose different strategy combinations; a factory centralizes that wiring. |
| Observer (extension point) | WaitlistManager notifying waitlisted guests on cancellation | Decouples "a room freed up" from "who is waiting for it" — cancellation code doesn't need to know waitlist internals beyond the manager's interface. |
| Repository | RoomRepository/BookingRepository (implied, backing Room's in-memory list in a real system) | Isolates persistence (SQL row locks, indices on date ranges) from BookingService's orchestration logic — swappable for tests via an in-memory fake. |
6. Key Algorithms, Concurrency & Edge Cases
Date-range overlap: the correct comparison
Two half-open ranges [checkIn1, checkOut1) and [checkIn2, checkOut2) overlap iff:
checkIn1.isBefore(checkOut2) && checkIn2.isBefore(checkOut1)Using half-open intervals (checkOut is the day the room becomes available again, not itself occupied) is what makes a checkout-day-equals-checkin-day booking valid — a very common real-world case that a naive checkIn1 <= checkOut2 && checkIn2 <= checkOut1 (closed-interval) comparison gets wrong by one day.
The booking race: two guests, one room, overlapping dates
synchronized (room) {
if (!room.isAvailable(checkIn, checkOut)) { /* reject / waitlist */ }
// ... reserve inside the SAME lock ...
}Same failure mode as Parking Lot's spot race: if "check availability" and "create the booking" are two separate steps without a shared lock (or without a database-level constraint), two threads can both see "available" and both create a CONFIRMED booking for overlapping dates.
In-memory fix (shown above): lock on the Room instance so check-and-reserve is atomic per room. Fine for a single-process demo; contention is scoped per room, not global.
Production fix: push the invariant into the database. A common pattern is a PENDING row insert guarded by a database-level exclusion constraint on (room_id, daterange) (PostgreSQL's EXCLUDE USING gist with a daterange column and the && overlap operator), or a SELECT ... FOR UPDATE on the room row before checking existing bookings — either way, the database becomes the single source of truth for the no-overlap invariant instead of relying on in-process locking, which breaks the moment you run more than one app server.
-- PostgreSQL exclusion constraint sketch: DB rejects any overlapping insert atomically.
ALTER TABLE booking ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, daterange(check_in, check_out) WITH &&)
WHERE (status != 'CANCELLED');In-memory synchronized locks only work within a single JVM. The moment the booking service scales to multiple instances behind a load balancer, the no-double-booking invariant MUST be enforced at the database (or a distributed lock), not in application memory.
Saga: book → pay → confirm, with compensation
Payment is an external call — it can fail, time out, or succeed-but-the-response-is-lost. The booking flow is therefore not a single atomic transaction; it's a saga with an explicit compensating action:
1. Reserve room as PENDING (local transaction — safe to roll back)
2. Charge payment (external call — the risky, non-transactional step)
3a. Payment succeeds -> booking becomes CONFIRMED
3b. Payment fails -> booking becomes CANCELLED, room hold released (compensation)
A PENDING booking should also carry a TTL (e.g., 10 minutes) — if the payment step never returns (client disconnected mid-flow, gateway hung), a background sweeper cancels stale PENDING bookings and releases the room, the same TTL-hold idea used in the Movie Ticket Booking guide's seat lock.
Other edge cases worth naming
- Idempotent payment retries: if the client retries
createBookingafter a timeout (unsure whether the first attempt succeeded), the service needs an idempotency key so a retried request doesn't double-charge or double-reserve. - Cancellation exactly at a policy boundary (e.g., exactly 7 days before check-in in
ModeratePolicy): decide inclusive vs. exclusive and document it — an off-by-one here silently changes refund amounts. - Multi-room bookings: a single booking spanning several rooms needs all-or-nothing reservation — if room 2 of 3 is unavailable, the whole booking fails and any already-held rooms in that request must be released.
- Waitlist promotion race: when a cancellation frees a room, multiple waitlisted guests could be notified simultaneously; promotion itself must go through the same atomic check-and-reserve path as a normal booking, not bypass it.
7. Trade-offs & Extensions
| Decision | Trade-off |
|---|---|
Per-room synchronized lock (in-memory) | Simple and correct for a single instance; does not survive horizontal scaling — needs a DB constraint or distributed lock in production, as covered above. |
| Half-open date intervals | Matches real hotel semantics (checkout day = next guest's checkin day) but requires discipline — every comparison in the codebase must consistently treat checkOut as exclusive. |
| Saga with compensation vs. two-phase commit | Saga is simpler and doesn't require the payment gateway to participate in a distributed transaction protocol (it can't — it's a third party); the cost is a brief window where a PENDING booking exists without a confirmed payment, requiring the TTL sweeper. |
| FIFO waitlist | Simple and fair by arrival order; doesn't account for a guest willing to pay more or a loyalty tier — a priority queue would generalize this at the cost of fairness complaints. |
Natural extensions an interviewer may probe:
- Multi-night dynamic pricing (price changes per night within one booking, e.g., a conference in town on night 3) —
PricingStrategy.priceForalready iterates night-by-night, so this is a data change, not a structural one. - Group/corporate bookings: block-book N rooms atomically — extends the saga's "all-or-nothing" reservation step to a set of rooms instead of one.
- Overbooking strategy (airlines/hotels intentionally overbook by a small margin, betting on no-shows) — would require relaxing the strict no-overlap invariant into a probabilistic one, a substantial design shift worth flagging as "a different problem" rather than a small extension.
- Loyalty-tier cancellation exceptions — a
CancellationPolicydecorator that grants flexible-tier refunds to loyalty members regardless of the room's base rate plan, mirroring the Decorator seam used for pricing in the Parking Lot guide.
Interview Questions
- Write the correct half-open interval overlap check for two date ranges, and explain why using
<=instead of<is a bug. - Where exactly is the race condition in booking creation, and how does locking on
Roomclose it? Why does that fix break down across multiple app servers? - What would a PostgreSQL-level fix for the no-double-booking invariant look like, and why is it more robust than application-level locking?
- Walk through the saga: what happens if payment succeeds but the confirmation step crashes before returning to the client?
- How would you design the TTL sweep for stale
PENDINGbookings, and what should happen to a room on TTL expiry? - How does
CancellationPolicyas a Strategy let you support flexible/moderate/strict rate plans without touchingBookingService? - How would you extend this design to support an atomic multi-room group booking?