06-lld-interview-problems

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.

August 11, 2026
lldhotel-bookingdate-rangeconcurrencypricingcancellation

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 Room or BookingService.
  • 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

ActorDescription
GuestSearches availability, creates/cancels bookings, makes payment.
Hotel AdminConfigures rooms, room types, and pricing for a hotel.
Booking ServiceOrchestrates availability check → hold → payment → confirmation.
Payment Gateway (external)Processes the actual charge; abstracted behind an interface.

Primary use cases

  1. Search availability — guest specifies hotel, date range, room type → service returns rooms with no overlapping confirmed/pending booking in that range.
  2. Create booking — guest selects a room → service creates a PENDING booking (a short-lived hold) → payment is processed → booking becomes CONFIRMED (or CANCELLED on failure).
  3. Cancel booking — guest cancels a CONFIRMED booking → cancellation policy computes refund/fee based on how far ahead of check-in the cancellation occurs.
  4. 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

java
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

java
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

java
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

java
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

java
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

java
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

PatternWhere usedWhy
StrategyPricingStrategy, CancellationPolicyBoth pricing rules and refund policy vary by hotel/rate-plan and need to swap independently of BookingService's orchestration logic.
Saga (orchestration)BookingService.createBookingfinalizeWithPaymentBooking 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 MethodBookingFactory.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 cancellationDecouples "a room freed up" from "who is waiting for it" — cancellation code doesn't need to know waitlist internals beyond the manager's interface.
RepositoryRoomRepository/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:

java
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

java
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.

sql
-- 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:

text
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 createBooking after 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

DecisionTrade-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 intervalsMatches 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 commitSaga 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 waitlistSimple 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.priceFor already 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 CancellationPolicy decorator 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 Room close 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 PENDING bookings, and what should happen to a room on TTL expiry?
  • How does CancellationPolicy as a Strategy let you support flexible/moderate/strict rate plans without touching BookingService?
  • How would you extend this design to support an atomic multi-room group booking?