Home › Low-Level Design › Design a Hotel Booking System
D

Design a Hotel Booking System

The whole problem lives in one sentence: never sell the same room twice. Search, pricing, cancellations, all of it is scaffolding around that single hard guarantee. I say this to the interviewer in the first 60 seconds, because it reframes a sprawling CRUD app into the one invariant I will defend for the rest of the hour. This is a classic low-level design problem, and I walk it the way I would at a whiteboard: out loud, pruning as I go.

Low-Level DesignHard13 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. Inventory is per date, not a flag. A room is free on the 8th and booked on the 3rd at the same time, so availability lives in a per-type, per-night count, not a boolean on Room.
  2. Hold, commit, release. A two-phase reservation lets payment sit between tentative and final without ever exposing the room, and gives a clean thing to time out when a user walks away.
  3. The hold is atomic. Check-then-decrement runs inside one lock, all-or-nothing across every night, so exactly one of two racing checkouts wins the last room.
  4. Holds expire on their own. A sweeper (or a delay queue) releases abandoned holds after 10 minutes, and the release is idempotent so a sweep racing a commit never hands a room back twice.
  5. Pricing and cancellation are strategies. The parts the business changes weekly are drop-in classes, so Booking never changes when marketing invents a new rate.

1Clarify the requirements#

Pin down what we are actually building before drawing a single class, because "hotel booking" can mean three different systems depending on who is asking.

The first fork: are we the hotel or the middleman?

  • A property-management system owns its own inventory, so the authoritative write lives in our code.
  • An aggregator (a travel site in front of many hotels) holds no inventory and must call out to each property, so the authoritative write lives somewhere else.
  • I assume we own the inventory for one chain, because that puts the double-booking fight inside our own code where I can show it.
  • If the interviewer wants the aggregator angle, I push it to extensions in section 7.

Functional scope.

  • Search available rooms by city, date range, and guest count.
  • Hold and book a room for a date range.
  • Apply a price (rates change by season, day of week, occupancy).
  • Cancel a booking and apply a cancellation policy.
  • Handle payment as a step in the booking, not an afterthought.

Non-functional scope (this is where the "Hard" label lives).

  • No double booking. Two users at checkout for the last room in the same millisecond: exactly one wins, the other gets a clean "sold out."
  • Holds expire. A user who starts checkout gets the room reserved for about 10 minutes, then it returns to the pool with no human in the loop.
  • Consistency over raw speed on the write path. Search can lag a few seconds; booking cannot. I would rather show a stale "available" and fail the hold than confirm two guests into one bed.

Scope I name out loud.

  • I am designing the object model and the booking logic, not the database schema or the REST layer.
  • In a 45-minute slot that cut is the difference between a finished design and a half-drawn one.
From the interviewer's chair

I am scoring this opening before you draw a box. I want to hear you say "never sell the same room twice" on your own, and I want you to pick a lane (property vs aggregator) instead of trying to design both. Candidates who start listing classes before naming the invariant have already told me they see CRUD where I see a concurrency problem.

2Identify the core entities#

List the nouns that keep showing up when you describe the flow, then prune the ones that do not earn a class.

  • Hotel. A physical property in a city. Owns rooms and one inventory.
  • Room. A specific physical room (101, 102). Belongs to one hotel, has a RoomType.
  • RoomType. The category (Deluxe, Suite). Pricing and capacity attach here, not to the individual room. A guest books "a Deluxe," not room 214.
  • RoomInventory. Tracks, per RoomType per date, how many units are open. This is the heart of the system and where the double-booking fight happens.
  • Guest. The person booking.
  • Booking. The reservation. Ties a guest to a RoomType over a date range, carries status and price.
  • DateRange. Check-in to check-out. A value object I use everywhere, so it gets its own class to hold overlap logic in one place instead of scattering date math.
  • Payment. The charge against a booking.
  • PricingStrategy. Computes the rate for a RoomType over a range. Pluggable on purpose.
  • CancellationPolicy. Decides the refund when someone cancels.
The entity candidates miss

RoomInventory as its own concept. Most people put availability on Room as a boolean, which falls apart the instant two date ranges overlap: room 101 is booked for the 3rd to the 5th and free for the 8th to the 10th, and a single flag cannot say both. Availability is per date, full stop.

3Class design#

The design pivots on one split: separate the physical room from what gets sold.

  • RoomType carries capacity and the pricing hook. It is the thing a guest actually buys.
  • RoomInventory holds a per-date count of available units for a type.
  • Booking never points at a specific Room. The front desk assigns a physical room at check-in.
  • Why it pays off: this mirrors how real hotels run, and it dodges a whole class of locking problems. I decrement a count, I do not toggle a specific object that other threads also want to touch.

Two more shape decisions.

  • Booking is a small state machine: CREATED -> CONFIRMED -> CHECKED_IN -> CHECKED_OUT, with CANCELLED reachable from the early states. Status is an enum driving guarded transitions, not a free-floating string anyone can overwrite.
  • Pricing and cancellation are strategies because they are the parts a business changes most. Weekend surcharge, last-minute discount, non-refundable rate: all new strategy classes, not edits to Booking.
classDiagram class Hotel { -String id -String city +addRoom(room) void +getInventory() RoomInventory } class Room { -String number -RoomStatus status } class RoomType { -String name -int capacity -PricingStrategy pricing +priceFor(range) Money } class RoomInventory { -Map available +hold(type, range, qty) boolean +release(type, range, qty) void +commit(type, range, qty) void } class Booking { -String id -DateRange range -BookingStatus status -Money total +confirm() void +cancel() RefundResult } class DateRange { -LocalDate checkIn -LocalDate checkOut +overlaps(other) boolean +nights() int } class Guest { -String id -String email } class Payment { -Money amount -PaymentStatus status +capture() boolean } class PricingStrategy { +price(type, range) Money } class CancellationPolicy { +refund(booking, now) Money } class SeasonalPricing class WeekendPricing Hotel *-- Room Hotel *-- RoomInventory Room --> RoomType RoomType --> PricingStrategy Booking --> RoomType Booking --> Guest Booking *-- DateRange Booking *-- Payment Booking --> CancellationPolicy PricingStrategy <|-- SeasonalPricing PricingStrategy <|-- WeekendPricing RoomInventory o-- RoomType
The class model. Notice what Booking points at: a RoomType, not a Room. That one arrow is the whole trick. You book a category and decrement a count, so no two threads fight over the same physical object.

4Key implementation#

Start with the value object, because every other class leans on it.

DateRange and the half-open interval.

  • A stay from the 3rd to the 5th occupies nights 3 and 4, so the check-out day (the 5th) is free for the next guest to check in.
  • Getting this wrong is the single most common availability bug.
  • It shows up as phantom conflicts, where two back-to-back stays appear to collide on the turnover day.
public final class DateRange {
    private final LocalDate checkIn;
    private final LocalDate checkOut; // exclusive

    public DateRange(LocalDate in, LocalDate out) {
        if (!out.isAfter(in)) throw new IllegalArgumentException("checkout must be after checkin");
        this.checkIn = in;
        this.checkOut = out;
    }

    public boolean overlaps(DateRange o) {
        return checkIn.isBefore(o.checkOut) && o.checkIn.isBefore(checkOut);
    }

    public int nights() {
        return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
    }

    public Stream<LocalDate> nightsStream() {
        return checkIn.datesUntil(checkOut); // excludes checkout
    }
}
Pause before the inventory code: two guests want the last Deluxe for overlapping dates and both click "book" at the same instant. What exactly stops one of them from succeeding on a room that is already gone?
Reveal the answer
The check and the decrement must be one atomic step. The naive version reads "1 left," then separately writes, so both threads read "1 left" before either writes and both proceed. The fix is to put check-then-decrement inside a single lock: the first thread in sees availability and takes it, the second sees zero and gets a clean false. Reaching for this before the interviewer asks is the whole signal of the problem.

RoomInventory: the piece that makes or breaks the system.

  • Keeps a count per RoomType per night.
  • Exposes three operations: hold (tentative, during checkout), commit (confirmed), release (hold expired or cancelled).
  • The hold is atomic across every night in the range: we grab all of them or none.
public class RoomInventory {
    // key: typeId -> (date -> units still open)
    private final Map<String, Map<LocalDate, Integer>> available = new ConcurrentHashMap<>();
    private final Object lock = new Object();

    public boolean hold(RoomType type, DateRange range, int qty) {
        synchronized (lock) {
            Map<LocalDate, Integer> byDate = available.get(type.getId());
            // all-or-nothing check first
            boolean ok = range.nightsStream()
                .allMatch(d -> byDate.getOrDefault(d, 0) >= qty);
            if (!ok) return false;
            range.nightsStream().forEach(d -> byDate.merge(d, -qty, Integer::sum));
            return true;
        }
    }

    public void release(RoomType type, DateRange range, int qty) {
        synchronized (lock) {
            Map<LocalDate, Integer> byDate = available.get(type.getId());
            range.nightsStream().forEach(d -> byDate.merge(d, qty, Integer::sum));
        }
    }

    // commit is a no-op on counts: the hold already decremented them.
    public void commit(RoomType type, DateRange range, int qty) { /* audit only */ }
}

The subtle point on commit.

  • The count already dropped at hold time, so confirming a booking changes no counters.
  • Decrement again at commit and I double-charge inventory, losing rooms to nothing.
  • The count moves exactly once (on hold) and comes back exactly once (on release).
  • commit only flips the hold's internal state so the sweeper in section 6 stops eyeing it for expiry.

On the single lock.

  • One lock per inventory (so, per hotel) is the simple answer I give first.
  • It is correct and easy to reason about, and I would rather be provably correct than cleverly fast on a whiteboard.
  • If the interviewer pushes on throughput, I move to per-type striped locks so two different room types book in parallel. More in section 6.

The booking flow pulls it together.

  • The hold happens before payment, and I always release on any failure.
  • The ordering is deliberate: briefly hold a room and give it back beats taking a payment for a room I cannot deliver.
public class BookingService {
    private final RoomInventory inventory;
    private final PaymentGateway gateway;

    public Booking book(Guest guest, Hotel hotel, RoomType type, DateRange range, int qty) {
        if (!inventory.hold(type, range, qty)) {
            throw new NoAvailabilityException(type, range);
        }
        try {
            Money total = type.priceFor(range).times(qty);
            Payment payment = new Payment(total);
            if (!payment.capture()) {
                throw new PaymentFailedException();
            }
            inventory.commit(type, range, qty);
            return Booking.confirmed(guest, type, range, total, payment);
        } catch (RuntimeException e) {
            inventory.release(type, range, qty); // give the units back
            throw e;
        }
    }
}

The Booking state machine guards transitions.

  • Nobody can cancel a checked-out stay or confirm twice.
  • The guard is a single if at the top of each mutator, but it is the thing that stops a buggy caller or a retried request from corrupting status.
public class Booking {
    private BookingStatus status;
    private final CancellationPolicy policy;
    private final DateRange range;

    public RefundResult cancel(LocalDate now) {
        if (status != BookingStatus.CONFIRMED && status != BookingStatus.CREATED) {
            throw new IllegalStateException("cannot cancel in state " + status);
        }
        Money refund = policy.refund(this, now);
        this.status = BookingStatus.CANCELLED;
        return new RefundResult(refund);
    }
}

5Design decisions and patterns used#

I lean on four patterns and can defend each one. I name the pattern, then the churn it absorbs, because a pattern with no reason is just ceremony.

  • Strategy for PricingStrategy and CancellationPolicy. Pricing rules churn constantly: a seasonal bump, a weekend rate, a non-refundable fare that trades a discount for zero refund. Each is a drop-in class. Booking and RoomType never change when the business invents a new rate, which is exactly the part marketing changes weekly.
  • State for BookingStatus. The guarded transitions inside Booking are a lightweight state machine. I keep it as an enum with transition checks rather than a class-per-state, because the number of states is small and stable (four plus cancelled). A full state-object hierarchy would be overkill, and I say so out loud so the interviewer knows I chose the simpler tool on purpose.
  • Factory for building a confirmed Booking (Booking.confirmed(...)). It keeps valid-state construction in one place, so you cannot new up a half-built booking stuck in a bad status with no payment attached.
  • Observer for side effects after confirmation: send the confirmation email, update a loyalty ledger, push to an analytics sink. BookingService publishes a BookingConfirmed event and subscribers react. That keeps notifications out of the booking logic, so a flaky email service can never block a confirmed sale.
The deeper decision under all four

The hold/commit/release split on inventory is really a two-phase reservation. It lets payment sit between tentative and final without ever exposing the room to another buyer, and it gives a clean thing to time out when the user walks away mid-checkout. The money is made the instant the hold commits; everything downstream is best-effort.

6Handling edge cases and concurrency#

This is the section that decides a Hard interview. I try to raise each of these before the interviewer does.

The double-booking race.

  • Two threads call hold for the last unit.
  • The synchronized block makes check-then-decrement one atomic step, so exactly one sees availability and the other gets false.
  • The naive version (read count, then separately write) loses here: both threads read "1 left" before either writes, and both proceed. I point that out explicitly, because "I would check if it is available then book it" is the answer that fails.

All-or-nothing across nights.

  • A five-night stay where night three is sold out must fail cleanly, not book four nights and strand the guest on night three.
  • The allMatch check runs fully before any decrement, inside the lock, so a partial range never mutates the map.

Hold expiry.

  • A hold with no cleanup leaks inventory forever: the user closes the tab and that room is dead stock until a human notices.
  • I attach an expiry timestamp to each hold and run a sweeper (a scheduled task) that calls release on anything past its 10-minute window.
  • Alternatively a delay queue scheduling one release per hold at creation time, which avoids scanning.
  • The sweeper must be idempotent: releasing an already-committed or already-released hold is a no-op, which is why I track hold state, not just counts. Without that, a sweep racing a commit gives a room back twice.

Payment fails after hold.

  • Handled by the try/catch release in BookingService.
  • The units go back immediately on any thrown exception, including a declined card or a gateway timeout, so a failed payment never strands inventory.

Throughput.

  • One lock per hotel serializes every booking at that property. Fine for correctness, maybe not for a 2,000-room resort on a flash sale where every request queues behind one mutex.
  • The upgrade is striping: lock per RoomType, since holds on a Deluxe and a Suite never touch the same counters and can safely run in parallel.
  • To go further, move the per-date counts behind an atomic compare-and-set, or push the whole thing to a database row with SELECT ... FOR UPDATE or an optimistic version column.
  • I name that as the real-world answer while keeping the in-memory lock for the interview, because the in-memory version is what I can reason about live without hand-waving the DB.

Clock and timezones.

  • Check-in dates are the hotel's local date, not the server's.
  • I store a zone on the hotel and compute "today" against it, so a booking made at 11pm server time does not silently shift a day and either reject a valid same-day booking or apply the wrong cancellation window.
Red flag that fails you here

Modeling availability as a boolean on Room, then reaching for "check if available, then book" with no atomic step. That is the exact pair of mistakes that double-books a room on a Friday night. If you cannot say "exactly one wins" and point at where the lock makes it true, I have not seen you solve the actual problem.

7Extensions and follow-ups#

When the interviewer pushes, here is where I go.

  • Aggregator model. Many hotels, each owning inventory, with a search layer on top. Search reads a denormalized, eventually-consistent availability view; the authoritative hold still happens against the single hotel that owns the room. Search being a few seconds stale is fine; the hold is the source of truth.
  • Overbooking policy. Hotels deliberately sell a few past capacity, betting on no-shows. I let RoomInventory accept a per-type overbook buffer, so the floor for hold becomes -buffer rather than zero.
  • Dynamic pricing. A PricingStrategy that reads current occupancy and raises the rate as a type fills. Clean, because pricing is already pluggable: one more strategy class, not a rewrite.
  • Distributed scale. Move holds to a store with per-row locking or an atomic counter, and give each hold a TTL so a crashed node's holds self-heal instead of leaking inventory when the sweeper process dies with them.
  • Partial-stay changes. Extending a stay is a hold on the extra nights plus a price delta; shortening is a release plus a refund via the cancellation policy. Both reuse the primitives I already built, which is the point of modeling inventory as counts.
Follow-ups the interviewer will ask (and your answers)
"Two guests race for the last room. Walk me through exactly who wins and why."
Both threads call hold. The synchronized block makes check-then-decrement one atomic step, so the first thread in reads "1 left," decrements to 0, and returns true. The second thread then reads 0, fails the allMatch, and returns false with no mutation. The naive "read count, then write" loses because both read 1 before either writes. Exactly one wins by construction.
"A user starts checkout and vanishes. How does the room come back?"
Each hold carries an expiry timestamp. A sweeper task (or a delay queue scheduling one release per hold) calls release once the 10-minute window passes. The release is idempotent: releasing an already-committed or already-released hold is a no-op, because I track hold state, not just counts. That stops a sweep racing a commit from handing the same room back twice.
"Why does commit not touch the counters?"
The count already dropped at hold time. If I decremented again at commit I would charge inventory twice for one booking and lose rooms to nothing. The count moves exactly once on hold and returns exactly once on release. Commit only flips the hold's internal state so the expiry sweeper leaves it alone.
"The one lock per hotel is a bottleneck on a flash sale. Fix it."
Stripe the lock per RoomType, since holds on a Deluxe and a Suite never touch the same counters and can run in parallel. If I need more, I move the per-date count behind an atomic compare-and-set, or push it to a database row with SELECT ... FOR UPDATE or an optimistic version column. I keep the in-memory lock for the interview because I can reason about it live.
"Why split RoomType from Room at all?"
Because a guest books a category, not a physical room, and availability is per date. Putting a boolean on Room cannot express "free on the 8th, booked on the 3rd." Modeling inventory as a per-type, per-night count means I decrement a number instead of locking a specific object, which dodges a whole class of contention, and the front desk assigns the actual room number at check-in.

What you can now explain in the room

  • Why availability is a per-date count, not a boolean on Room, and how overlapping ranges break the flag.
  • How hold/commit/release forms a two-phase reservation that holds a room across a payment safely.
  • Why check-then-decrement must be one atomic step, and exactly which thread wins the last room.
  • How abandoned holds self-heal via an idempotent sweeper or a per-hold delay queue.
  • Why Strategy and State keep pricing, cancellation, and status churn out of the booking core.
  • How to scale past one lock: per-type striping, then atomic counters or a DB row lock.

Next up, if you liked the concurrency angle: try Design a Parking Lot for the allocation core, and Design a Ride-Hailing System for matching under contention. Both reuse the Strategy and State patterns you just saw here.

Get a prep plan built around your Design a Hotel Booking System interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design a Hotel Booking System prep plan is on the way.