Hotel Management LLD: The Calendar Is the Hard Part
A low-level design walkthrough of a hotel management system: half-open date ranges, the one-line overlap formula, booking a room type while the hotel assigns the room — fully implemented.
"Design a hotel management system." The cast writes itself — rooms, guests, bookings — and that's the trap. The actual difficulty isn't a single noun; it's the calendar. Can room 101 host a guest checking out on the 14th and a guest checking in on the 14th? (Yes — and half the implementations on the internet say no.) When do two bookings clash, exactly? (One formula, two comparisons, endlessly fumbled.)
This is the queue's interval article. Get the date model right and the hotel is an afternoon of typing; get it wrong and every fix breeds two new off-by-one bugs.
Let's start nowhere near a computer
Think about how a hotel day actually works. Checkout is 11 a.m.; check-in is 2 p.m. So on the 14th, room 101 says goodbye to Asha and hello to Dev — the same calendar day belongs to both bookings, but not at the same time. The night is what you rent, not the day: Asha's stay "10th to 14th" means the nights of the 10th, 11th, 12th, 13th — four nights, and not the night of the 14th.
Mathematics has a name for "includes the start, excludes the end": a half-open interval, written [in, out). Model stays that way and the checkout-day handover works with zero special cases. Model them closed ([in, out]) and you'll be writing minusDays(1) apologies forever.
Where the interval problem follows you
- Meeting rooms, court slots, rental cars — every calendar-booked resource is this exact design.
- The "merge intervals" interview family — same formula, no hotel attached.
- Inventory's sibling: there stock was promised in quantity; here it's promised in time. The airline, next, promises both.
Step 1 — Functional requirements (sentences first)
What the hotel must do, as plain sentences — the functional requirements.
- A hotel has rooms; each room has a type (standard, deluxe…).
- A guest books a type for a date range; the hotel assigns a specific room.
- A room can host bookings that don't overlap — checkout day may equal the next check-in day.
- A booking can be cancelled, freeing its nights.
- The hotel can report occupancy for any date.
That second sentence carries real design weight, so it gets its own section.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a hotel they're where the bugs live:
- Correctness. No room is ever double-booked for overlapping dates, and the checkout-day handover is never mistaken for a clash. One wrong inequality breaks both.
- Thread-safety. Two guests racing for the last deluxe over the same nights must not both win; the check ("is it free?") and the reserve ("take it") cannot be split by another thread.
- Performance. Finding a free room shouldn't mean re-deriving every room's whole calendar on every request — availability search grows with bookings, and you can keep that cheap.
- Extensibility. New room types, seasonal rate plans, and room-assignment strategies should plug in without reopening the booking core.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | half-open intervals [in, out) + the one overlap formula — back-to-back stays coexist — Steps 3, 4 |
| Thread-safety | check-then-reserve runs under one lock (synchronized book), so no two callers interleave — Steps 3, 6 |
| Performance | availability is derived from the booking list, not a stored calendar that drifts — Step 5 |
| Extensibility | room choice is a Strategy seam over isFree's candidates; pricing is a lookup, not branches — Step 3 |
Every trade-off below is chosen to keep one of these.
Step 3 — Guests book a type; the hotel picks the room
When you book "a deluxe room", no human decides which physical room you get — any deluxe whose calendar has the gap will do. Rooms within a type are fungible (the airline article is the contrast — seat 12A is nobody's substitute). So the booking algorithm is a search:
public synchronized Booking book(RoomType type, String guest, LocalDate in, LocalDate out) {
if (!in.isBefore(out)) {
throw new IllegalArgumentException("check-in must be before check-out");
}
for (Room room : rooms) {
if (room.type() != type) continue;
if (isFree(room, in, out)) {
Booking booking = new Booking("B-" + nextId++, room, guest, in, out);
bookings.add(booking);
return booking;
}
}
throw new IllegalStateException("no " + type + " free for " + in + " → " + out);
}First room of the type with a calendar gap wins. (Real hotels optimize the choice — fill rooms evenly, keep adjacent rooms for families — which is a Strategy seam on isFree's candidates. Name it, move on.)
Which structure — and why. A booking is a half-open interval [in, out) and nothing else — that shape buys correctness, because the checkout-day handover becomes a < instead of a special case. Availability isn't a structure at all: it's derived by scanning the booking list, which is the performance lever (a stored per-day calendar drifts the moment a cancel forgets to clear a day). Money, when pricing arrives, is integer minor units — paise or cents, never a double — so a four-night bill sums exactly instead of accumulating float dust. And the whole book method is synchronized: the check-then-reserve is one atomic step, which is the only thing standing between two guests and the same last room — that's the thread-safety NFR, bought with one keyword.
Step 4 — The formula
When do [a.in, a.out) and [b.in, b.out) clash? Not "if any date matches" — that breaks the checkout handover. The answer is two comparisons, worth memorizing for life:
private boolean isFree(Room room, LocalDate in, LocalDate out) {
return bookings.stream()
.filter(b -> b.room().number() == room.number())
.noneMatch(b -> in.isBefore(b.out()) && b.in().isBefore(out));
}a.in < b.out && b.in < a.out — overlap if and only if each starts before the other ends. Back-to-back bookings (a.out == b.in) fail the second comparison and coexist peacefully: the 11 a.m./2 p.m. handover, encoded in a < that isn't a <=.
Derive it in the room instead of reciting: "two intervals don't overlap when
one ends before the other begins — a.out ≤ b.in || b.out ≤ a.in — negate
that and simplify." Thirty seconds, and the interviewer knows you can rebuild
it under pressure rather than hoping you memorized the right inequality.
Step 5 — The rest falls out of the model
With half-open intervals and the formula, the remaining operations are one-liners that can't have off-by-ones:
public synchronized void cancel(String bookingId) {
if (!bookings.removeIf(b -> b.id().equals(bookingId))) {
throw new IllegalArgumentException("no booking " + bookingId);
}
} // the nights free themselves: availability is DERIVED
public synchronized long occupiedOn(LocalDate date) {
return bookings.stream()
.filter(b -> !date.isBefore(b.in()) && date.isBefore(b.out()))
.count(); // in ≤ date < out — half-open to the end
}Notice cancel is just removal — no "mark nights free" bookkeeping, because free-ness was never stored, only derived (inventory's lesson, on a calendar). Stored availability calendars drift; derived ones can't. A cancel frees inventory simply by leaving the search; a hold that's never confirmed is the same idea with an expiry clock, and an overlapping-date request rejects itself by failing the formula — three edges, zero extra bookkeeping.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
half-open intervals [in, out) | closed [in, out] | the checkout-day handover works with no minusDays(1) apologies | correctness |
| availability derived from bookings | a stored per-day calendar | nothing to keep in sync, so it can't drift; cancel is just a removal | correctness |
synchronized book (check + reserve) | check, then reserve unlocked | two guests can't both win the last room — no interleave between the steps | thread-safety |
| guests book a type, room picked by search | guests book a physical room | rooms stay fungible, so a Strategy can later optimize the choice | extensibility |
| money as integer minor units | double rupees | a multi-night bill sums exactly instead of drifting by float dust | correctness |
Growth — when the scan gets long. The isFree scan is fine for a real hotel and most interviews, but it walks every booking on every request. When asked "what about ten thousand bookings?", you don't change datastores — you change the structure: a sorted interval set (or interval tree) per room turns the linear scan into a binary search, and bucketing bookings by room keeps each search local. Same booking logic, a tighter index underneath.
The complete implementation
package dev.fiveyear.hotel;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
public final class Hotel {
public enum RoomType { STANDARD, DELUXE }
public record Room(int number, RoomType type) {}
public record Booking(String id, Room room, String guest, LocalDate in, LocalDate out) {}
private final List<Room> rooms = new ArrayList<>();
private final List<Booking> bookings = new ArrayList<>();
private int nextId = 1;
public void addRoom(int number, RoomType type) {
rooms.add(new Room(number, type));
}
public synchronized Booking book(RoomType type, String guest, LocalDate in, LocalDate out) {
if (!in.isBefore(out)) {
throw new IllegalArgumentException("check-in must be before check-out");
}
for (Room room : rooms) {
if (room.type() != type) continue;
if (isFree(room, in, out)) {
Booking booking = new Booking("B-" + nextId++, room, guest, in, out);
bookings.add(booking);
return booking;
}
}
throw new IllegalStateException("no " + type + " free for " + in + " → " + out);
}
public synchronized void cancel(String bookingId) {
if (!bookings.removeIf(b -> b.id().equals(bookingId))) {
throw new IllegalArgumentException("no booking " + bookingId);
}
}
public synchronized long occupiedOn(LocalDate date) {
return bookings.stream()
.filter(b -> !date.isBefore(b.in()) && date.isBefore(b.out()))
.count();
}
private boolean isFree(Room room, LocalDate in, LocalDate out) {
return bookings.stream()
.filter(b -> b.room().number() == room.number())
.noneMatch(b -> in.isBefore(b.out()) && b.in().isBefore(out));
}
}A June at the front desk:
Hotel hotel = new Hotel();
hotel.addRoom(101, RoomType.DELUXE);
hotel.addRoom(102, RoomType.DELUXE);
LocalDate d10 = LocalDate.of(2026, 6, 10), d14 = LocalDate.of(2026, 6, 14);
LocalDate d18 = LocalDate.of(2026, 6, 18);
Booking asha = hotel.book(RoomType.DELUXE, "asha", d10, d14); // room 101
Booking dev = hotel.book(RoomType.DELUXE, "dev", d14, d18); // room 101 AGAIN —
// checkout day = check-in day ✓
Booking mira = hotel.book(RoomType.DELUXE, "mira", d10, d18); // overlaps both → room 102
hotel.book(RoomType.DELUXE, "kabir", d10, d14); // throws: type exhausted
hotel.occupiedOn(d14); // 2 — dev's first night + mira; asha already left
hotel.cancel(mira.id()); // nights free themselves: nothing to "mark"The interview corner
Clarify before you code: Is pricing in scope? Are date modifications allowed, or only cancel-and-rebook? What happens to no-shows?
The follow-up ladder:
- "Seasonal pricing." A rate table per (type, date); the bill is a sum over the stay's nights — data, not ifs, and the half-open interval tells you exactly which nights.
- "Guest wants to extend by two nights." Modify = cancel + rebook atomically (validate the merged range before releasing the old one) — never edit dates in place around a sleeping overlap formula.
- "No-shows." A booking not checked in by 6 p.m. auto-releases: a status plus the lazy clock — the room simply re-enters
isFree's search. - "Overbook 10% on purpose." Then stop assigning rooms at booking: sell against type capacity + buffer, and assign physical rooms at check-in — the airline's quota trick, landside.
- "Housekeeping." Each room grows a second machine — CLEAN / DIRTY / MAINTENANCE — that gates the search; two independent lifecycles per room, holding hands like the restaurant's.
Mistakes that fail the round: closed intervals (the checkout-day collision); a stored per-day availability array that drifts; assigning rooms at booking when overbooking exists.
Where to go from here
Pocket version: rent nights, not days — half-open [in, out); clash = a.in < b.out && b.in < a.out; guests book types, the search picks the room; availability is derived, never stored.
- Add pricing seasons — a rate per (type, date) is a lookup table, and the bill is a sum over nights: data, not ifs.
- Speed up
isFree— a sorted interval set per room turns the scan into a binary search; mention it when asked "what about ten thousand bookings?" - Next in the queue: the airline — bookings where every unit has a name, and a hold that expires.
The hard part was never the rooms. It was agreeing what "the 14th" means — and the answer, as so often in this queue, was choosing the right shape for a fact before writing any code about it.