If you remember five things
The whole answer in one glance. Everything below is the defense of these five calls.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
RoomTypecarries capacity and the pricing hook. It is the thing a guest actually buys.RoomInventoryholds a per-date count of available units for a type.Bookingnever points at a specificRoom. 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.
Bookingis a small state machine:CREATED -> CONFIRMED -> CHECKED_IN -> CHECKED_OUT, withCANCELLEDreachable 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.
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
}
}
Reveal the answer
RoomInventory: the piece that makes or breaks the system.
- Keeps a count per
RoomTypeper night. - Exposes three operations:
hold(tentative, during checkout),commit(confirmed),release(hold expired or cancelled). - The
holdis 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
holdtime, 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 (onrelease). commitonly flips the hold's internal state so the sweeper in section 6 stops eyeing it for expiry.
On the single lock.
- One
lockper 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
ifat 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
PricingStrategyandCancellationPolicy. 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.BookingandRoomTypenever change when the business invents a new rate, which is exactly the part marketing changes weekly. - State for
BookingStatus. The guarded transitions insideBookingare 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 cannotnewup 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.
BookingServicepublishes aBookingConfirmedevent and subscribers react. That keeps notifications out of the booking logic, so a flaky email service can never block a confirmed sale.
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
holdfor the last unit. - The
synchronizedblock makes check-then-decrement one atomic step, so exactly one sees availability and the other getsfalse. - 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
allMatchcheck 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
releaseon 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/catchrelease inBookingService. - 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 UPDATEor 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.
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
RoomInventoryaccept a per-type overbook buffer, so the floor forholdbecomes-bufferrather than zero. - Dynamic pricing. A
PricingStrategythat 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
holdon the extra nights plus a price delta; shortening is areleaseplus a refund via the cancellation policy. Both reuse the primitives I already built, which is the point of modeling inventory as counts.
"Two guests race for the last room. Walk me through exactly who wins and why."
"A user starts checkout and vanishes. How does the room come back?"
"Why does commit not touch the counters?"
"The one lock per hotel is a bottleneck on a flash sale. Fix it."
"Why split RoomType from Room at all?"
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.