Low Level DesignWorked problems
Design a hotel booking system
Design a hotel booking system step by step for a low level design interview. Grow from rooms and date ranges to an inventory that sells room types per night, rooms picked at check-in, per-night pricing, changes that can't lose the original booking, cancellations and no-shows, and the race for the last room, with code in Java, Python, C#, Go and TypeScript.
“Design a hotel booking system: guests search for a room, book it for some dates, and can change or cancel.” Often with “and the front desk checks them in” a few minutes later.
The obvious model is a calendar per room: book room 101 from the 10th to the 12th, and check for overlaps. It works until the hotel gets busy. Then it starts turning guests away while rooms sit empty, because the rooms were handed out too early.
A hotel doesn’t really sell room 101. It sells “a double for the nights of the 10th and 11th”. Which double the guest sleeps in is decided when they arrive. That one change, counting room types per night instead of assigning rooms, is what this question is about. Everything else is built on those counts.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- A stay is a list of nights. Arriving on the 10th and leaving on the 12th uses the nights of the 10th and 11th. So one guest can leave on the same day the next one arrives.
- The hotel sells room types per night, not rooms. An inventory counts, for each type and night, how many rooms are sold. A booking takes all its nights or none.
- The room is chosen at check-in. Any empty room of the type works, and if no night is oversold, an empty one always exists.
- A change takes the new nights before giving back the old ones. So a change that doesn’t fit leaves the guest with the booking they had.
- One lock per hotel. Checking and taking a night’s count is one step. So the website, travel agencies and the front desk can’t all sell the last suite.
We’ll build this in four stages, then deal with four channels selling the last room at once.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a hotel booking system.
Whether you focus on one hotel’s rooms quickly, and ask what a guest actually books: a room, or a kind of room. Ten minutes on search filters, reviews and photos leaves no time for the part they will ask about.
Here are the questions that matter:
- Does the guest pick a room, or a type of room? A type (double, suite). The actual room is set when they arrive. Picking a room would make every room its own calendar.
- One hotel or many? One hotel’s booking engine. A platform with many hotels runs one copy of this design per hotel.
- What is a stay? A check-in date and a check-out date. The unit is a night.
- Prices? Per night, set by the hotel, with weekends higher.
- Changes, cancellations, guests who never arrive? All three. Late cancels and no-shows pay for one night.
These answers give us one promise that matters more than the others: no night is ever sold beyond the rooms that exist. That holds even when the website, travel agencies and the front desk book at the same moment. Three smaller promises come with it. A guest with a booking always gets a room at check-in. A failed change leaves the original booking exactly as it was. And every night given up goes back on sale: a cancel, an early checkout, or a no-show.
We leave out search across hotels, payment processing, housekeeping and loyalty programs.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Overbooking? | Never sell more than exist | Capacity becomes rooms plus an allowance; the counts don’t change |
| Payment? | A card on file, charged at checkout | Prepaid rates add a charge at booking, with a refund rule |
| Who books? | Website, travel agencies, front desk | All of them share one inventory, which is the concurrency story |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: Rooms, stays, and a booking that picks a room
Section titled “Stage 1: Rooms, stays, and a booking that picks a room”Start simple: a guest wants a double from the 10th to the 12th. What are your classes?
How you represent a date range, and whether one guest leaving on the 12th and another arriving on the 12th count as a clash. An off-by-one error here shows up in every later answer.
Start with the smallest thing that works. A Room is a number and a type. A Stay is a check-in
date and a check-out date.
How do we count the nights? Arriving on the 10th and leaving on the 12th uses the nights of the 10th
and the 11th, but not the 12th. The check-out day isn’t a night. A range that includes its start
but not its end is called half-open. It makes back-to-back stays fit with no special case: the
10th to the 12th and the 12th to the 14th share no night. And the number of nights is simply
checkOut - checkIn.
The first Hotel keeps a list of bookings, each naming a room. To book, it looks through the rooms
of the requested type, and gives the guest the first one with no overlapping booking.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.record Room(String number, String type) {}
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.record Stay(LocalDate checkIn, LocalDate checkOut) { boolean isValid() { return checkIn.isBefore(checkOut); }
boolean overlaps(Stay o) { return checkIn.isBefore(o.checkOut) && o.checkIn.isBefore(checkOut); }
List<LocalDate> nights() { return checkIn.datesUntil(checkOut).toList(); }}
record Booking(String id, String guest, Room room, Stay stay) {}
class Hotel { private final Map<String, Room> rooms = new LinkedHashMap<>(); // by number private final List<Booking> bookings = new ArrayList<>(); private final Supplier<String> ids;
Hotel(Supplier<String> ids) { this.ids = ids; }
void addRoom(Room r) { rooms.put(r.number(), r); }
// flaw 1: the guest gets a specific room the moment they book, which fragments the calendar. // With two doubles: Ana stays Monday night in 101, Ben arrives Monday and leaves Wednesday in // 102, and Cy arrives Wednesday and gets 101, empty again by then. Dee wants Tuesday to // Thursday and is refused: 101 is free Tuesday night but not Wednesday, and 102 the other way // round. No night has more than two guests, and had Cy been given 102, Dee would fit in 101. // Stage 2 sells room types per night and picks the room at check-in. // flaw 2: every request scans every booking the hotel has ever taken. Booking book(String guest, String type, Stay stay) { if (!stay.isValid()) return null; for (Room r : rooms.values()) { if (!r.type().equals(type)) continue; boolean free = bookings.stream().noneMatch(b -> b.room().equals(r) && b.stay().overlaps(stay)); if (free) { Booking b = new Booking(ids.get(), guest, r, stay); bookings.add(b); return b; } } return null; }
// flaw 3: no prices, and no way to change or cancel. Stages 3 and 4.}from dataclasses import dataclassfrom datetime import date, timedeltafrom typing import Callable
# A physical room, known by its number. Its type ("double", "suite") decides the price and# what's in it.@dataclass(frozen=True)class Room: number: str type: str
# The nights from check-in up to, but not including, check-out: arriving on the 10th and# leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay# end on the day the next begins without a special case.@dataclass(frozen=True)class Stay: check_in: date check_out: date
def is_valid(self) -> bool: return self.check_in < self.check_out
def overlaps(self, o: "Stay") -> bool: return self.check_in < o.check_out and o.check_in < self.check_out
def nights(self) -> list[date]: return [self.check_in + timedelta(days=i) for i in range((self.check_out - self.check_in).days)]
@dataclass(frozen=True)class Booking: id: str guest: str room: Room stay: Stay
class Hotel: def __init__(self, ids: Callable[[], str]): self._rooms: dict[str, Room] = {} # by number self._bookings: list[Booking] = [] self._ids = ids
def add_room(self, r: Room) -> None: self._rooms[r.number] = r
# flaw 1: the guest gets a specific room the moment they book, which fragments the calendar. # With two doubles: Ana stays Monday night in 101, Ben arrives Monday and leaves Wednesday in # 102, and Cy arrives Wednesday and gets 101, empty again by then. Dee wants Tuesday to # Thursday and is refused: 101 is free Tuesday night but not Wednesday, and 102 the other way # round. No night has more than two guests, and had Cy been given 102, Dee would fit in 101. # Stage 2 sells room types per night and picks the room at check-in. # flaw 2: every request scans every booking the hotel has ever taken. def book(self, guest: str, type: str, stay: Stay) -> Booking | None: if not stay.is_valid(): return None for r in self._rooms.values(): if r.type == type and not any(b.room == r and b.stay.overlaps(stay) for b in self._bookings): b = Booking(self._ids(), guest, r, stay) self._bookings.append(b) return b return None
# flaw 3: no prices, and no way to change or cancel. Stages 3 and 4.// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.public sealed record Room(string Number, string Type);
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.public sealed record Stay(DateOnly CheckIn, DateOnly CheckOut){ public bool IsValid => CheckIn < CheckOut;
public bool Overlaps(Stay o) => CheckIn < o.CheckOut && o.CheckIn < CheckOut;
public List<DateOnly> Nights() { var nights = new List<DateOnly>(); for (var d = CheckIn; d < CheckOut; d = d.AddDays(1)) nights.Add(d); return nights; }}
public sealed record Booking(string Id, string Guest, Room Room, Stay Stay);
public sealed class Hotel(Func<string> ids){ private readonly List<Room> _rooms = []; private readonly List<Booking> _bookings = [];
public void AddRoom(Room r) => _rooms.Add(r);
// flaw 1: the guest gets a specific room the moment they book, which fragments the calendar. // With two doubles: Ana stays Monday night in 101, Ben arrives Monday and leaves Wednesday in // 102, and Cy arrives Wednesday and gets 101, empty again by then. Dee wants Tuesday to // Thursday and is refused: 101 is free Tuesday night but not Wednesday, and 102 the other way // round. No night has more than two guests, and had Cy been given 102, Dee would fit in 101. // Stage 2 sells room types per night and picks the room at check-in. // flaw 2: every request scans every booking the hotel has ever taken. public Booking? Book(string guest, string type, Stay stay) { if (!stay.IsValid) return null; foreach (var room in _rooms.Where(r => r.Type == type)) { if (_bookings.Any(b => b.Room == room && b.Stay.Overlaps(stay))) continue; var b = new Booking(ids(), guest, room, stay); _bookings.Add(b); return b; } return null; }
// flaw 3: no prices, and no way to change or cancel. Stages 3 and 4.}// Room is a physical room, known by its number. Its type ("double", "suite") decides the// price and what's in it.type Room struct { Number, Type string}
// Stay is the nights from check-in up to, but not including, check-out: arriving on the 10th// and leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one// stay end on the day the next begins without a special case. Dates are midnight UTC values.type Stay struct { CheckIn, CheckOut time.Time}
func (s Stay) Valid() bool { return s.CheckIn.Before(s.CheckOut) }
func (s Stay) Overlaps(o Stay) bool { return s.CheckIn.Before(o.CheckOut) && o.CheckIn.Before(s.CheckOut)}
func (s Stay) Nights() []time.Time { var out []time.Time for n := s.CheckIn; n.Before(s.CheckOut); n = n.AddDate(0, 0, 1) { out = append(out, n) } return out}
type Booking struct { ID, Guest string Room Room Stay Stay}
type Hotel struct { rooms []Room bookings []Booking ids func() string}
func NewHotel(ids func() string) *Hotel { return &Hotel{ids: ids} }
func (h *Hotel) AddRoom(r Room) { h.rooms = append(h.rooms, r) }
// Book gives the guest a specific room the moment they book, which fragments the calendar// (flaw 1). With two doubles: Ana stays Monday night in 101, Ben arrives Monday and leaves// Wednesday in 102, and Cy arrives Wednesday and gets 101, empty again by then. Dee wants// Tuesday to Thursday and is refused: 101 is free Tuesday night but not Wednesday, and 102// the other way round. No night has more than two guests, and had Cy been given 102, Dee// would fit in 101. Stage 2 sells room types per night and picks the room at check-in.// flaw 2: every request scans every booking the hotel has ever taken. flaw 3: no prices,// and no way to change or cancel; stages 3 and 4.func (h *Hotel) Book(guest, roomType string, stay Stay) *Booking { if !stay.Valid() { return nil } for _, r := range h.rooms { if r.Type != roomType { continue } free := !slices.ContainsFunc(h.bookings, func(b Booking) bool { return b.Room == r && b.Stay.Overlaps(stay) }) if free { b := Booking{h.ids(), guest, r, stay} h.bookings = append(h.bookings, b) return &b } } return nil}// A date with no time and no time zone, counted in days since 1970-01-01. A hotel thinks in// nights in its own town, so a plain day number avoids every midnight-in-which-zone bug, and// "the next night" is just day + 1.type Day = number;
// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.interface Room { readonly number: string; readonly type: string;}
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.class Stay { constructor( readonly checkIn: Day, readonly checkOut: Day, ) {}
isValid(): boolean { return this.checkIn < this.checkOut; }
overlaps(o: Stay): boolean { return this.checkIn < o.checkOut && o.checkIn < this.checkOut; }
nights(): Day[] { const out: Day[] = []; for (let d = this.checkIn; d < this.checkOut; d++) out.push(d); return out; }}
interface Booking { readonly id: string; readonly guest: string; readonly room: Room; readonly stay: Stay;}
class Hotel { private readonly rooms: Room[] = []; private readonly bookings: Booking[] = [];
constructor(private readonly ids: () => string) {}
addRoom(r: Room): void { this.rooms.push(r); }
// flaw 1: the guest gets a specific room the moment they book, which fragments the calendar. // With two doubles: Ana stays Monday night in 101, Ben arrives Monday and leaves Wednesday in // 102, and Cy arrives Wednesday and gets 101, empty again by then. Dee wants Tuesday to // Thursday and is refused: 101 is free Tuesday night but not Wednesday, and 102 the other way // round. No night has more than two guests, and had Cy been given 102, Dee would fit in 101. // Stage 2 sells room types per night and picks the room at check-in. // flaw 2: every request scans every booking the hotel has ever taken. book(guest: string, type: string, stay: Stay): Booking | null { if (!stay.isValid()) return null; for (const room of this.rooms) { if (room.type !== type) continue; const free = this.bookings.every((b) => b.room !== room || !b.stay.overlaps(stay)); if (free) { const b = { id: this.ids(), guest, room, stay }; this.bookings.push(b); return b; } } return null; }
// flaw 3: no prices, and no way to change or cancel. Stages 3 and 4.}It works, but it has a serious flaw. Assigning the room at booking time turns guests away while rooms are free. Let’s watch it happen with two doubles, 101 and 102, and four bookings in this order:
| Booking | Monday | Tuesday | Wednesday | Thursday | Room given |
|---|---|---|---|---|---|
| Ana, Monday night | 101 | 101 | |||
| Ben, Monday to Wednesday | 102 | 102 | 102 | ||
| Cy, Wednesday to Friday | 101 | 101 | 101 | ||
| Dee, Tuesday to Thursday | 101? | 102? | refused |
Dee is refused, even though no night has more than two guests. Room 101 is free on Tuesday but not Wednesday, and 102 the other way round. If Cy had been given 102, Dee would fit in 101.
There are two smaller problems too: every request looks through every booking the hotel has ever taken, and there are no prices, changes or cancellations.
Stage 2: Sell nights per room type, and pick the room at check-in
Section titled “Stage 2: Sell nights per room type, and pick the room at check-in”Your design just refused Dee with a room free every night. Fix it.
Whether you see that the room shouldn’t be fixed until arrival, and can say why a room will always be free then. Moving existing bookings between rooms to make space is the answer they hear most, and it gets complicated fast.
The guest is promised a double, not room 101, so that’s all the booking should record.
An Inventory keeps, for each room type, how many rooms exist, and how many are sold on each
night. Booking a stay checks that every one of its nights has a room left, then adds one to each
night’s count, all or nothing. The result is a Reservation: a guest, a room type, a stay, and a
status. It has no room yet.
The room is chosen at check-in, and any empty room of the type will do. That sounds hopeful, but it’s guaranteed. Here’s why. On the day Dee arrives, everyone already sleeping in a double tonight holds one of tonight’s sold doubles, and Dee holds another. Tonight isn’t oversold, so fewer doubles are taken than exist. So at least one is empty. Picking a room at arrival can never get stuck, and that’s why the counts are the only thing a booking needs to check.
If check-in ever finds no room, the counts themselves are wrong. So the code throws an error rather than returning a polite answer: it’s a bug, not a normal situation.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.class Inventory { private final Map<String, Integer> capacity = new HashMap<>(); // type -> rooms private final Map<String, Map<LocalDate, Integer>> sold = new HashMap<>(); // type -> night -> sold
void addRoom(String type) { capacity.merge(type, 1, Integer::sum); }
boolean sells(String type) { return capacity.containsKey(type); }
int free(String type, LocalDate night) { return capacity.getOrDefault(type, 0) - sold.getOrDefault(type, Map.of()).getOrDefault(night, 0); }
// All or nothing: check every night, then take every night. boolean take(String type, List<LocalDate> nights) { for (LocalDate n : nights) if (free(type, n) <= 0) return false; Map<LocalDate, Integer> m = sold.computeIfAbsent(type, k -> new HashMap<>()); for (LocalDate n : nights) m.merge(n, 1, Integer::sum); return true; }
// The opposite of take: these nights are on sale again. void give(String type, List<LocalDate> nights) { Map<LocalDate, Integer> m = sold.get(type); for (LocalDate n : nights) m.merge(n, -1, (a, b) -> a + b == 0 ? null : a + b); // drop zeros }}
enum Status { BOOKED, CHECKED_IN, CHECKED_OUT }
// A promise of a room of some type for some nights. Which room is decided at check-in.class Reservation { final String id; final String guest; String type; Stay stay; Status status = Status.BOOKED; Room room; // null until check-in
Reservation(String id, String guest, String type, Stay stay) { this.id = id; this.guest = guest; this.type = type; this.stay = stay; }}
// What a request gets back. TOO_EARLY and TOO_LATE are about the day of check-in.enum Result { OK, INVALID_DATES, NO_SUCH_TYPE, SOLD_OUT, NO_SUCH_RESERVATION, WRONG_STATUS, TOO_EARLY, TOO_LATE }
// The result, plus the new reservation when it is OK.record ReserveResult(Result result, Reservation reservation) {}
class Hotel { private final List<Room> rooms = new ArrayList<>(); private final Inventory inventory = new Inventory(); private final Map<String, Reservation> reservations = new HashMap<>(); private final Map<String, Reservation> inHouse = new HashMap<>(); // room number -> guest in it private final Supplier<String> ids; private final Supplier<LocalDate> clock; // the hotel's local date, injected for tests
Hotel(Supplier<String> ids, Supplier<LocalDate> clock) { this.ids = ids; this.clock = clock; }
void addRoom(Room r) { rooms.add(r); inventory.addRoom(r.type()); }
ReserveResult reserve(String guest, String type, Stay stay) { if (!stay.isValid() || stay.checkIn().isBefore(clock.get())) return new ReserveResult(Result.INVALID_DATES, null); if (!inventory.sells(type)) return new ReserveResult(Result.NO_SUCH_TYPE, null); if (!inventory.take(type, stay.nights())) return new ReserveResult(Result.SOLD_OUT, null); Reservation r = new Reservation(ids.get(), guest, type, stay); reservations.put(r.id, r); return new ReserveResult(Result.OK, r); }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The exception would mean the counts are // wrong, not bad luck. Result checkIn(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; LocalDate today = clock.get(); if (today.isBefore(r.stay.checkIn())) return Result.TOO_EARLY; if (!today.isBefore(r.stay.checkOut())) return Result.TOO_LATE; Room room = rooms.stream().filter(x -> x.type().equals(r.type) && isVacant(x, today)).findFirst() .orElseThrow(() -> new IllegalStateException("inventory says a " + r.type + " is free, rooms disagree")); r.room = room; r.status = Status.CHECKED_IN; inHouse.put(room.number(), r); return Result.OK; }
Result checkOut(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.CHECKED_IN) return Result.WRONG_STATUS; LocalDate today = clock.get(); inHouse.remove(r.room.number()); r.status = Status.CHECKED_OUT; // Leaving early gives the remaining nights back to sell. if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); return Result.OK; }
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private boolean isVacant(Room room, LocalDate night) { Reservation r = inHouse.get(room.number()); return r == null || !r.stay.checkOut().isAfter(night); }
int free(String type, LocalDate night) { return inventory.free(type, night); }}from enum import Enum, auto
# What the hotel sells is a type of room for a set of nights, so that is what it counts:# for each type, how many rooms exist and how many are sold on each night.class Inventory: def __init__(self): self._capacity: dict[str, int] = {} # type -> rooms self._sold: dict[str, dict[date, int]] = {} # type -> night -> sold
def add_room(self, type: str) -> None: self._capacity[type] = self._capacity.get(type, 0) + 1
def sells(self, type: str) -> bool: return type in self._capacity
def free(self, type: str, night: date) -> int: return self._capacity.get(type, 0) - self._sold.get(type, {}).get(night, 0)
# All or nothing: check every night, then take every night. def take(self, type: str, nights: list[date]) -> bool: if any(self.free(type, n) <= 0 for n in nights): return False m = self._sold.setdefault(type, {}) for n in nights: m[n] = m.get(n, 0) + 1 return True
# The opposite of take: these nights are on sale again. def give(self, type: str, nights: list[date]) -> None: m = self._sold[type] for n in nights: m[n] -= 1 if m[n] == 0: del m[n]
class Status(Enum): BOOKED = auto() CHECKED_IN = auto() CHECKED_OUT = auto()
# A promise of a room of some type for some nights. Which room is decided at check-in.@dataclass(eq=False)class Reservation: id: str guest: str type: str stay: Stay status: Status = Status.BOOKED room: Room | None = None # None until check-in
# What a request gets back. TooEarly and TooLate are about the day of check-in.class Result(Enum): OK = auto() INVALID_DATES = auto() NO_SUCH_TYPE = auto() SOLD_OUT = auto() NO_SUCH_RESERVATION = auto() WRONG_STATUS = auto() TOO_EARLY = auto() TOO_LATE = auto()
class Hotel: def __init__(self, ids: Callable[[], str], clock: Callable[[], date]): self._rooms: list[Room] = [] self._inventory = Inventory() self._reservations: dict[str, Reservation] = {} self._in_house: dict[str, Reservation] = {} # room number -> guest in it self._ids = ids self._clock = clock # the hotel's local date, injected for tests
def add_room(self, r: Room) -> None: self._rooms.append(r) self._inventory.add_room(r.type)
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: if not stay.is_valid() or stay.check_in < self._clock(): return Result.INVALID_DATES, None if not self._inventory.sells(type): return Result.NO_SUCH_TYPE, None if not self._inventory.take(type, stay.nights()): return Result.SOLD_OUT, None r = Reservation(self._ids(), guest, type, stay) self._reservations[r.id] = r return Result.OK, r
# The room is chosen when the guest arrives, and any vacant room of the type will do. # Every guest in the house tonight holds one of tonight's sold rooms, and so does this # guest, so if tonight isn't oversold a vacant room exists. The error would mean the # counts are wrong, not bad luck. def check_in(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS today = self._clock() if today < r.stay.check_in: return Result.TOO_EARLY if today >= r.stay.check_out: return Result.TOO_LATE room = next((x for x in self._rooms if x.type == r.type and self._is_vacant(x, today)), None) if room is None: raise RuntimeError(f"inventory says a {r.type} is free, rooms disagree") r.room = room r.status = Status.CHECKED_IN self._in_house[room.number] = r return Result.OK
def check_out(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.CHECKED_IN: return Result.WRONG_STATUS today = self._clock() del self._in_house[r.room.number] r.status = Status.CHECKED_OUT # Leaving early gives the remaining nights back to sell. if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) return Result.OK
def free(self, type: str, night: date) -> int: return self._inventory.free(type, night)
# A room is vacant tonight if nobody in it is staying tonight. A guest due out today may # still be packing; housekeeping has the room ready by the afternoon. def _is_vacant(self, room: Room, night: date) -> bool: r = self._in_house.get(room.number) return r is None or r.stay.check_out <= night// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.public sealed class Inventory{ private readonly Dictionary<string, int> _capacity = []; // type -> rooms private readonly Dictionary<string, Dictionary<DateOnly, int>> _sold = []; // type -> night -> sold
public void AddRoom(string type) => _capacity[type] = _capacity.GetValueOrDefault(type) + 1;
public bool Sells(string type) => _capacity.ContainsKey(type);
public int Free(string type, DateOnly night) => _capacity.GetValueOrDefault(type) - (_sold.TryGetValue(type, out var m) ? m.GetValueOrDefault(night) : 0);
// All or nothing: check every night, then take every night. public bool Take(string type, IReadOnlyList<DateOnly> nights) { if (!nights.All(n => Free(type, n) > 0)) return false; if (!_sold.TryGetValue(type, out var m)) _sold[type] = m = []; foreach (var n in nights) m[n] = m.GetValueOrDefault(n) + 1; return true; }
// The opposite of take: these nights are on sale again. public void Give(string type, IReadOnlyList<DateOnly> nights) { var m = _sold[type]; foreach (var n in nights) if (--m[n] == 0) m.Remove(n); // drop zeros }}
public enum Status { Booked, CheckedIn, CheckedOut }
// A promise of a room of some type for some nights. Which room is decided at check-in.public sealed class Reservation(string id, string guest, string type, Stay stay){ public string Id { get; } = id; public string Guest { get; } = guest; public string Type { get; set; } = type; public Stay Stay { get; set; } = stay; public Status Status { get; set; } = Status.Booked; public Room? Room { get; set; } // null until check-in}
// What a request gets back. TooEarly and TooLate are about the day of check-in.public enum Result { Ok, InvalidDates, NoSuchType, SoldOut, NoSuchReservation, WrongStatus, TooEarly, TooLate }
public sealed class Hotel(Func<string> ids, Func<DateOnly> clock) // clock: the hotel's local date{ private readonly List<Room> _rooms = []; private readonly Inventory _inventory = new(); private readonly Dictionary<string, Reservation> _reservations = []; private readonly Dictionary<string, Reservation> _inHouse = []; // room number -> guest in it
public void AddRoom(Room r) { _rooms.Add(r); _inventory.AddRoom(r.Type); }
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { if (!stay.IsValid || stay.CheckIn < clock()) return (Result.InvalidDates, null); if (!_inventory.Sells(type)) return (Result.NoSuchType, null); if (!_inventory.Take(type, stay.Nights())) return (Result.SoldOut, null); var r = new Reservation(ids(), guest, type, stay); _reservations[r.Id] = r; return (Result.Ok, r); }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The exception would mean the counts are // wrong, not bad luck. public Result CheckIn(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var today = clock(); if (today < r.Stay.CheckIn) return Result.TooEarly; if (today >= r.Stay.CheckOut) return Result.TooLate; var room = _rooms.FirstOrDefault(x => x.Type == r.Type && IsVacant(x, today)) ?? throw new InvalidOperationException($"inventory says a {r.Type} is free, rooms disagree"); r.Room = room; r.Status = Status.CheckedIn; _inHouse[room.Number] = r; return Result.Ok; }
public Result CheckOut(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.CheckedIn) return Result.WrongStatus; var today = clock(); _inHouse.Remove(r.Room!.Number); r.Status = Status.CheckedOut; // Leaving early gives the remaining nights back to sell. if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); return Result.Ok; }
public int Free(string type, DateOnly night) => _inventory.Free(type, night);
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private bool IsVacant(Room room, DateOnly night) => !_inHouse.TryGetValue(room.Number, out var r) || r.Stay.CheckOut <= night;}// Inventory counts what the hotel sells: a type of room for a set of nights. For each type, it// knows how many rooms exist and how many are sold on each night.type Inventory struct { capacity map[string]int // type -> rooms sold map[string]map[time.Time]int // type -> night -> sold}
func NewInventory() *Inventory { return &Inventory{capacity: map[string]int{}, sold: map[string]map[time.Time]int{}}}
func (inv *Inventory) AddRoom(roomType string) { inv.capacity[roomType]++ }
func (inv *Inventory) Sells(roomType string) bool { _, ok := inv.capacity[roomType]; return ok }
func (inv *Inventory) Free(roomType string, night time.Time) int { return inv.capacity[roomType] - inv.sold[roomType][night]}
// Take is all or nothing: check every night, then take every night.func (inv *Inventory) Take(roomType string, nights []time.Time) bool { for _, n := range nights { if inv.Free(roomType, n) <= 0 { return false } } if inv.sold[roomType] == nil { inv.sold[roomType] = map[time.Time]int{} } for _, n := range nights { inv.sold[roomType][n]++ } return true}
// Give is the opposite of Take: these nights are on sale again.func (inv *Inventory) Give(roomType string, nights []time.Time) { for _, n := range nights { if inv.sold[roomType][n]--; inv.sold[roomType][n] == 0 { delete(inv.sold[roomType], n) } }}
// Status is a reservation's life. Cancelled and NoShow keep the record, with any fee charged.type Status int
const ( Booked Status = iota CheckedIn CheckedOut)
// Reservation is a promise of a room of some type for some nights. Which room is decided at// check-in.type Reservation struct { ID, Guest string Type string Stay Stay Status Status Room *Room // nil until check-in}
// Result is what a request gets back. TooEarly and TooLate are about the day of check-in.type Result int
const ( OK Result = iota InvalidDates NoSuchType SoldOut NoSuchReservation WrongStatus TooEarly TooLate)
type Hotel struct { rooms []Room inventory *Inventory reservations map[string]*Reservation inHouse map[string]*Reservation // room number -> guest in it ids func() string clock func() time.Time // the hotel's local date, injected for tests}
func NewHotel(ids func() string, clock func() time.Time) *Hotel { return &Hotel{inventory: NewInventory(), reservations: map[string]*Reservation{}, inHouse: map[string]*Reservation{}, ids: ids, clock: clock}}
func (h *Hotel) AddRoom(r Room) { h.rooms = append(h.rooms, r) h.inventory.AddRoom(r.Type)}
func (h *Hotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { if !stay.Valid() || stay.CheckIn.Before(h.clock()) { return InvalidDates, nil } if !h.inventory.Sells(roomType) { return NoSuchType, nil } if !h.inventory.Take(roomType, stay.Nights()) { return SoldOut, nil } r := &Reservation{ID: h.ids(), Guest: guest, Type: roomType, Stay: stay} h.reservations[r.ID] = r return OK, r}
// CheckIn chooses the room when the guest arrives, and any vacant room of the type will do.// Every guest in the house tonight holds one of tonight's sold rooms, and so does this guest,// so if tonight isn't oversold a vacant room exists. The panic would mean the counts are// wrong, not bad luck.func (h *Hotel) CheckIn(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } today := h.clock() if today.Before(r.Stay.CheckIn) { return TooEarly } if !today.Before(r.Stay.CheckOut) { return TooLate } for i := range h.rooms { room := &h.rooms[i] if room.Type == r.Type && h.isVacant(*room, today) { r.Room = room r.Status = CheckedIn h.inHouse[room.Number] = r return OK } } panic("inventory says a " + r.Type + " is free, rooms disagree")}
func (h *Hotel) CheckOut(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != CheckedIn { return WrongStatus } today := h.clock() delete(h.inHouse, r.Room.Number) r.Status = CheckedOut // Leaving early gives the remaining nights back to sell. if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } return OK}
func (h *Hotel) Free(roomType string, night time.Time) int { return h.inventory.Free(roomType, night) }
// isVacant says whether nobody in the room is staying tonight. A guest due out today may// still be packing; housekeeping has the room ready by the afternoon.func (h *Hotel) isVacant(room Room, night time.Time) bool { r := h.inHouse[room.Number] return r == nil || !r.Stay.CheckOut.After(night)}// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.class Inventory { private readonly capacity = new Map<string, number>(); // type -> rooms private readonly sold = new Map<string, Map<Day, number>>(); // type -> night -> sold
addRoom(type: string): void { this.capacity.set(type, (this.capacity.get(type) ?? 0) + 1); }
sells(type: string): boolean { return this.capacity.has(type); }
free(type: string, night: Day): number { return (this.capacity.get(type) ?? 0) - (this.sold.get(type)?.get(night) ?? 0); }
// All or nothing: check every night, then take every night. take(type: string, nights: Day[]): boolean { if (!nights.every((n) => this.free(type, n) > 0)) return false; let m = this.sold.get(type); if (!m) this.sold.set(type, (m = new Map())); for (const n of nights) m.set(n, (m.get(n) ?? 0) + 1); return true; }
// The opposite of take: these nights are on sale again. give(type: string, nights: Day[]): void { const m = this.sold.get(type)!; for (const n of nights) { const left = m.get(n)! - 1; if (left === 0) m.delete(n); // drop zeros else m.set(n, left); } }}
type Status = 'booked' | 'checked-in' | 'checked-out';
// A promise of a room of some type for some nights. Which room is decided at check-in.class Reservation { status: Status = 'booked'; room: Room | null = null; // null until check-in
constructor( readonly id: string, readonly guest: string, public type: string, public stay: Stay, ) {}}
// What a request gets back. TooEarly and TooLate are about the day of check-in.type Result = | 'ok' | 'invalid-dates' | 'no-such-type' | 'sold-out' | 'no-such-reservation' | 'wrong-status' | 'too-early' | 'too-late';
class Hotel { private readonly rooms: Room[] = []; private readonly inventory = new Inventory(); private readonly reservations = new Map<string, Reservation>(); private readonly inHouse = new Map<string, Reservation>(); // room number -> guest in it
constructor( private readonly ids: () => string, private readonly clock: () => Day, // the hotel's local date, injected for tests ) {}
addRoom(r: Room): void { this.rooms.push(r); this.inventory.addRoom(r.type); }
reserve(guest: string, type: string, stay: Stay): { result: Result; reservation?: Reservation } { if (!stay.isValid() || stay.checkIn < this.clock()) return { result: 'invalid-dates' }; if (!this.inventory.sells(type)) return { result: 'no-such-type' }; if (!this.inventory.take(type, stay.nights())) return { result: 'sold-out' }; const r = new Reservation(this.ids(), guest, type, stay); this.reservations.set(r.id, r); return { result: 'ok', reservation: r }; }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The error would mean the counts are // wrong, not bad luck. checkIn(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const today = this.clock(); if (today < r.stay.checkIn) return 'too-early'; if (today >= r.stay.checkOut) return 'too-late'; const room = this.rooms.find((x) => x.type === r.type && this.isVacant(x, today)); if (!room) throw new Error(`inventory says a ${r.type} is free, rooms disagree`); r.room = room; r.status = 'checked-in'; this.inHouse.set(room.number, r); return 'ok'; }
checkOut(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'checked-in') return 'wrong-status'; const today = this.clock(); this.inHouse.delete(r.room!.number); r.status = 'checked-out'; // Leaving early gives the remaining nights back to sell. if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); return 'ok'; }
free(type: string, night: Day): number { return this.inventory.free(type, night); }
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private isVacant(room: Room, night: Day): boolean { const r = this.inHouse.get(room.number); return !r || r.stay.checkOut <= night; }}A room counts as empty tonight if nobody in it is staying tonight. A guest due to leave today may still be packing at 10am, but housekeeping will have the room ready for the afternoon arrival. And a guest who leaves early gives their remaining nights back to the inventory, so they can be sold again tonight.
What does this cost? A guest can’t be promised a specific room when they book. Hotels that sell “room 1408, with the view” treat that one room as its own type, with a capacity of one.
Go deeperDee checks in on Tuesday, step by step1 min
Dee arrives. The front desk asks the hotel to check her in. The hotel looks at the doubles: Ben is in 102 tonight, and 101 has nobody staying tonight (Ana left Tuesday morning). So Dee gets 101, and her reservation’s status becomes “checked in”. On Wednesday, Cy arrives and gets whichever double is empty then.
- Color key
- Facade
Stage 3: Prices per night, and changing a booking
Section titled “Stage 3: Prices per night, and changing a booking”Weekend nights cost more. And a guest booked Tuesday to Thursday wants to move to Wednesday to Friday. The hotel is full on Wednesday. What happens?
Whether the price is per night rather than per stay, and whether you change a booking by cancelling and rebooking. Cancel first, and a change that fails has lost the guest their room. Rebook first, and a full Wednesday refuses a change that only needs Thursday.
First, prices. A RatePlan answers one question: what does one night of this type cost? The
standard plan adds a quarter on Friday and Saturday nights. It’s the Strategy pattern: the hotel
can swap in another plan. Hotels change their rates all the time, so the reservation stores the
price quoted for each night when it was made. The guest pays what they agreed to.
Now the change, which is where the obvious code goes wrong. “Cancel, then book again” can be done in two orders, and both are bad:
- Give back the old nights first. If the new ones don’t fit, someone else may already have taken the old ones. The guest has lost their booking just by asking to change it.
- Take the new nights first, as a separate booking. Say we move Tuesday–Thursday to Wednesday–Friday. Then we need Wednesday twice: once for the old booking, and once for the new. A full hotel can’t give that.
So modify works with the difference. Old stay: Tuesday and Wednesday nights. New stay:
Wednesday and Thursday nights. Wednesday is already held. So modify only takes Thursday. Only
once Thursday is safely taken does it give back Tuesday. If taking Thursday fails, nothing has
changed, and the guest keeps their original booking.
A change of room type works the same way. Take all the new nights of the new type first. Then give back all the old nights of the old type.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.interface RatePlan { long nightlyRate(String type, LocalDate night); // in cents}
// One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates implements RatePlan { private final Map<String, Long> base;
StandardRates(Map<String, Long> base) { this.base = base; }
// Friday and Saturday nights cost a quarter more. public long nightlyRate(String type, LocalDate night) { long rate = base.get(type); DayOfWeek d = night.getDayOfWeek(); return d == DayOfWeek.FRIDAY || d == DayOfWeek.SATURDAY ? rate * 5 / 4 : rate; }}
class Reservation { final String id; final String guest; String type; Stay stay; List<Long> rates; // quoted per night when booked or changed; the guest pays what they agreed to Status status = Status.BOOKED; Room room;
Reservation(String id, String guest, String type, Stay stay, List<Long> rates) { this.id = id; this.guest = guest; this.type = type; this.stay = stay; this.rates = rates; }
long total() { return rates.stream().mapToLong(Long::longValue).sum(); }}
class Hotel { private final List<Room> rooms = new ArrayList<>(); private final Inventory inventory = new Inventory(); private final Map<String, Reservation> reservations = new HashMap<>(); private final Map<String, Reservation> inHouse = new HashMap<>(); private final RatePlan rates; private final Supplier<String> ids; private final Supplier<LocalDate> clock;
Hotel(RatePlan rates, Supplier<String> ids, Supplier<LocalDate> clock) { this.rates = rates; this.ids = ids; this.clock = clock; }
void addRoom(Room r) { rooms.add(r); inventory.addRoom(r.type()); }
ReserveResult reserve(String guest, String type, Stay stay) { Result bad = check(type, stay); if (bad != Result.OK) return new ReserveResult(bad, null); if (!inventory.take(type, stay.nights())) return new ReserveResult(Result.SOLD_OUT, null); Reservation r = new Reservation(ids.get(), guest, type, stay, quote(type, stay)); reservations.put(r.id, r); return new ReserveResult(Result.OK, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. Result modify(String reservationId, String newType, Stay newStay) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; Result bad = check(newType, newStay); if (bad != Result.OK) return bad; boolean sameType = newType.equals(r.type); List<LocalDate> oldNights = r.stay.nights(); List<LocalDate> newNights = newStay.nights(); List<LocalDate> toTake = sameType ? minus(newNights, oldNights) : newNights; List<LocalDate> toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!inventory.take(newType, toTake)) return Result.SOLD_OUT; inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = quote(newType, newStay); return Result.OK; }
Result checkIn(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; LocalDate today = clock.get(); if (today.isBefore(r.stay.checkIn())) return Result.TOO_EARLY; if (!today.isBefore(r.stay.checkOut())) return Result.TOO_LATE; Room room = rooms.stream().filter(x -> x.type().equals(r.type) && isVacant(x, today)).findFirst() .orElseThrow(() -> new IllegalStateException("inventory says a " + r.type + " is free, rooms disagree")); r.room = room; r.status = Status.CHECKED_IN; inHouse.put(room.number(), r); return Result.OK; }
Result checkOut(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.CHECKED_IN) return Result.WRONG_STATUS; LocalDate today = clock.get(); inHouse.remove(r.room.number()); r.status = Status.CHECKED_OUT; if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); return Result.OK; }
int free(String type, LocalDate night) { return inventory.free(type, night); }
private Result check(String type, Stay stay) { if (!stay.isValid() || stay.checkIn().isBefore(clock.get())) return Result.INVALID_DATES; if (!inventory.sells(type)) return Result.NO_SUCH_TYPE; return Result.OK; }
private List<Long> quote(String type, Stay stay) { return stay.nights().stream().map(n -> rates.nightlyRate(type, n)).toList(); }
private boolean isVacant(Room room, LocalDate night) { Reservation r = inHouse.get(room.number()); return r == null || !r.stay.checkOut().isAfter(night); }
private static List<LocalDate> minus(List<LocalDate> a, List<LocalDate> b) { Set<LocalDate> drop = new HashSet<>(b); return a.stream().filter(n -> !drop.contains(n)).toList(); }}from typing import Protocol
# Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate# plan answers "what does one night of this type cost?", and a stay is the sum of its nights.class RatePlan(Protocol): def nightly_rate(self, type: str, night: date) -> int: ... # in cents
# One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates: def __init__(self, base: dict[str, int]): self._base = base
# Friday and Saturday nights cost a quarter more. def nightly_rate(self, type: str, night: date) -> int: rate = self._base[type] return rate * 5 // 4 if night.weekday() in (4, 5) else rate
@dataclass(eq=False)class Reservation: id: str guest: str type: str stay: Stay rates: list[int] # quoted per night when booked or changed; the guest pays what they agreed to status: Status = Status.BOOKED room: Room | None = None
def total(self) -> int: return sum(self.rates)
class Hotel: def __init__(self, rates: RatePlan, ids: Callable[[], str], clock: Callable[[], date]): self._rooms: list[Room] = [] self._inventory = Inventory() self._reservations: dict[str, Reservation] = {} self._in_house: dict[str, Reservation] = {} self._rates = rates self._ids = ids self._clock = clock
def add_room(self, r: Room) -> None: self._rooms.append(r) self._inventory.add_room(r.type)
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: bad = self._check(type, stay) if bad is not Result.OK: return bad, None if not self._inventory.take(type, stay.nights()): return Result.SOLD_OUT, None r = Reservation(self._ids(), guest, type, stay, self._quote(type, stay)) self._reservations[r.id] = r return Result.OK, r
# Change the dates or the room type. Take the nights the new stay needs that the old one # doesn't already hold, and only then give back the nights it no longer needs. If the new # nights aren't free, nothing has changed and the guest keeps the original booking. Moving # Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the # hotel is full on Wednesday. def modify(self, reservation_id: str, new_type: str, new_stay: Stay) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS bad = self._check(new_type, new_stay) if bad is not Result.OK: return bad old_nights, new_nights = r.stay.nights(), new_stay.nights() same_type = new_type == r.type to_take = [n for n in new_nights if n not in old_nights] if same_type else new_nights to_give = [n for n in old_nights if n not in new_nights] if same_type else old_nights if not self._inventory.take(new_type, to_take): return Result.SOLD_OUT self._inventory.give(r.type, to_give) r.type, r.stay, r.rates = new_type, new_stay, self._quote(new_type, new_stay) return Result.OK
def check_in(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS today = self._clock() if today < r.stay.check_in: return Result.TOO_EARLY if today >= r.stay.check_out: return Result.TOO_LATE room = next((x for x in self._rooms if x.type == r.type and self._is_vacant(x, today)), None) if room is None: raise RuntimeError(f"inventory says a {r.type} is free, rooms disagree") r.room = room r.status = Status.CHECKED_IN self._in_house[room.number] = r return Result.OK
def check_out(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.CHECKED_IN: return Result.WRONG_STATUS today = self._clock() del self._in_house[r.room.number] r.status = Status.CHECKED_OUT if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) return Result.OK
def free(self, type: str, night: date) -> int: return self._inventory.free(type, night)
def _check(self, type: str, stay: Stay) -> Result: if not stay.is_valid() or stay.check_in < self._clock(): return Result.INVALID_DATES if not self._inventory.sells(type): return Result.NO_SUCH_TYPE return Result.OK
def _quote(self, type: str, stay: Stay) -> list[int]: return [self._rates.nightly_rate(type, n) for n in stay.nights()]
def _is_vacant(self, room: Room, night: date) -> bool: r = self._in_house.get(room.number) return r is None or r.stay.check_out <= night// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.public interface IRatePlan{ long NightlyRate(string type, DateOnly night); // in cents}
// One hotel's prices: a base rate per room type, and more for weekend nights.public sealed class StandardRates(IReadOnlyDictionary<string, long> baseRates) : IRatePlan{ // Friday and Saturday nights cost a quarter more. public long NightlyRate(string type, DateOnly night) { var rate = baseRates[type]; return night.DayOfWeek is DayOfWeek.Friday or DayOfWeek.Saturday ? rate * 5 / 4 : rate; }}
public sealed class Reservation(string id, string guest, string type, Stay stay, List<long> rates){ public string Id { get; } = id; public string Guest { get; } = guest; public string Type { get; set; } = type; public Stay Stay { get; set; } = stay; public List<long> Rates { get; set; } = rates; // quoted per night when booked or changed; the guest pays what they agreed to public Status Status { get; set; } = Status.Booked; public Room? Room { get; set; }
public long Total => Rates.Sum();}
public sealed class Hotel(IRatePlan rates, Func<string> ids, Func<DateOnly> clock){ private readonly List<Room> _rooms = []; private readonly Inventory _inventory = new(); private readonly Dictionary<string, Reservation> _reservations = []; private readonly Dictionary<string, Reservation> _inHouse = [];
public void AddRoom(Room r) { _rooms.Add(r); _inventory.AddRoom(r.Type); }
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { var bad = Check(type, stay); if (bad != Result.Ok) return (bad, null); if (!_inventory.Take(type, stay.Nights())) return (Result.SoldOut, null); var r = new Reservation(ids(), guest, type, stay, Quote(type, stay)); _reservations[r.Id] = r; return (Result.Ok, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. public Result Modify(string id, string newType, Stay newStay) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var bad = Check(newType, newStay); if (bad != Result.Ok) return bad; var sameType = newType == r.Type; var oldNights = r.Stay.Nights(); var newNights = newStay.Nights(); var toTake = sameType ? newNights.Except(oldNights).ToList() : newNights; var toGive = sameType ? oldNights.Except(newNights).ToList() : oldNights; if (!_inventory.Take(newType, toTake)) return Result.SoldOut; _inventory.Give(r.Type, toGive); r.Type = newType; r.Stay = newStay; r.Rates = Quote(newType, newStay); return Result.Ok; }
public Result CheckIn(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var today = clock(); if (today < r.Stay.CheckIn) return Result.TooEarly; if (today >= r.Stay.CheckOut) return Result.TooLate; var room = _rooms.FirstOrDefault(x => x.Type == r.Type && IsVacant(x, today)) ?? throw new InvalidOperationException($"inventory says a {r.Type} is free, rooms disagree"); r.Room = room; r.Status = Status.CheckedIn; _inHouse[room.Number] = r; return Result.Ok; }
public Result CheckOut(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.CheckedIn) return Result.WrongStatus; var today = clock(); _inHouse.Remove(r.Room!.Number); r.Status = Status.CheckedOut; if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); return Result.Ok; }
public int Free(string type, DateOnly night) => _inventory.Free(type, night);
private Result Check(string type, Stay stay) { if (!stay.IsValid || stay.CheckIn < clock()) return Result.InvalidDates; if (!_inventory.Sells(type)) return Result.NoSuchType; return Result.Ok; }
private List<long> Quote(string type, Stay stay) => stay.Nights().Select(n => rates.NightlyRate(type, n)).ToList();
private bool IsVacant(Room room, DateOnly night) => !_inHouse.TryGetValue(room.Number, out var r) || r.Stay.CheckOut <= night;}// RatePlan answers "what does one night of this type cost?". Prices change by night: weekends// cost more, and a hotel adjusts rates constantly. A stay is the sum of its nights.type RatePlan interface { NightlyRate(roomType string, night time.Time) int64 // in cents}
// StandardRates is one hotel's prices: a base rate per room type, and more for weekend nights.type StandardRates struct { Base map[string]int64}
// NightlyRate charges a quarter more for Friday and Saturday nights.func (s StandardRates) NightlyRate(roomType string, night time.Time) int64 { rate := s.Base[roomType] if d := night.Weekday(); d == time.Friday || d == time.Saturday { return rate * 5 / 4 } return rate}
type Reservation struct { ID, Guest string Type string Stay Stay Rates []int64 // quoted per night when booked or changed; the guest pays what they agreed to Status Status Room *Room}
func (r *Reservation) Total() int64 { var sum int64 for _, x := range r.Rates { sum += x } return sum}
type Hotel struct { rooms []Room inventory *Inventory reservations map[string]*Reservation inHouse map[string]*Reservation rates RatePlan ids func() string clock func() time.Time}
func NewHotel(rates RatePlan, ids func() string, clock func() time.Time) *Hotel { return &Hotel{inventory: NewInventory(), reservations: map[string]*Reservation{}, inHouse: map[string]*Reservation{}, rates: rates, ids: ids, clock: clock}}
func (h *Hotel) AddRoom(r Room) { h.rooms = append(h.rooms, r) h.inventory.AddRoom(r.Type)}
func (h *Hotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { if bad := h.check(roomType, stay); bad != OK { return bad, nil } if !h.inventory.Take(roomType, stay.Nights()) { return SoldOut, nil } r := &Reservation{ID: h.ids(), Guest: guest, Type: roomType, Stay: stay, Rates: h.quote(roomType, stay)} h.reservations[r.ID] = r return OK, r}
// Modify changes the dates or the room type. It takes the nights the new stay needs that the// old one doesn't already hold, and only then gives back the nights it no longer needs. If the// new nights aren't free, nothing has changed and the guest keeps the original booking. Moving// Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the// hotel is full on Wednesday.func (h *Hotel) Modify(id, newType string, newStay Stay) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } if bad := h.check(newType, newStay); bad != OK { return bad } oldNights, newNights := r.Stay.Nights(), newStay.Nights() toTake, toGive := newNights, oldNights if newType == r.Type { toTake, toGive = minus(newNights, oldNights), minus(oldNights, newNights) } if !h.inventory.Take(newType, toTake) { return SoldOut } h.inventory.Give(r.Type, toGive) r.Type, r.Stay, r.Rates = newType, newStay, h.quote(newType, newStay) return OK}
func (h *Hotel) CheckIn(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } today := h.clock() if today.Before(r.Stay.CheckIn) { return TooEarly } if !today.Before(r.Stay.CheckOut) { return TooLate } for i := range h.rooms { room := &h.rooms[i] if room.Type == r.Type && h.isVacant(*room, today) { r.Room = room r.Status = CheckedIn h.inHouse[room.Number] = r return OK } } panic("inventory says a " + r.Type + " is free, rooms disagree")}
func (h *Hotel) CheckOut(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != CheckedIn { return WrongStatus } today := h.clock() delete(h.inHouse, r.Room.Number) r.Status = CheckedOut if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } return OK}
func (h *Hotel) Free(roomType string, night time.Time) int { return h.inventory.Free(roomType, night) }
func (h *Hotel) check(roomType string, stay Stay) Result { if !stay.Valid() || stay.CheckIn.Before(h.clock()) { return InvalidDates } if !h.inventory.Sells(roomType) { return NoSuchType } return OK}
func (h *Hotel) quote(roomType string, stay Stay) []int64 { var out []int64 for _, n := range stay.Nights() { out = append(out, h.rates.NightlyRate(roomType, n)) } return out}
func (h *Hotel) isVacant(room Room, night time.Time) bool { r := h.inHouse[room.Number] return r == nil || !r.Stay.CheckOut.After(night)}
// minus returns the nights in a that aren't in b.func minus(a, b []time.Time) []time.Time { var out []time.Time for _, n := range a { if !slices.ContainsFunc(b, n.Equal) { out = append(out, n) } } return out}// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.interface RatePlan { nightlyRate(type: string, night: Day): number; // in cents}
const FRIDAY = 5;const SATURDAY = 6;
// Day 0 was a Thursday, so (day + 4) % 7 counts Sunday as 0, like Date.getUTCDay().function weekday(d: Day): number { return (d + 4) % 7;}
// One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates implements RatePlan { constructor(private readonly base: Map<string, number>) {}
// Friday and Saturday nights cost a quarter more. nightlyRate(type: string, night: Day): number { const rate = this.base.get(type)!; const d = weekday(night); return d === FRIDAY || d === SATURDAY ? (rate * 5) / 4 : rate; }}
class Reservation { status: Status = 'booked'; room: Room | null = null;
constructor( readonly id: string, readonly guest: string, public type: string, public stay: Stay, public rates: number[], // quoted per night when booked or changed; the guest pays what they agreed to ) {}
total(): number { return this.rates.reduce((a, b) => a + b, 0); }}
class Hotel { private readonly rooms: Room[] = []; private readonly inventory = new Inventory(); private readonly reservations = new Map<string, Reservation>(); private readonly inHouse = new Map<string, Reservation>(); // room number -> guest in it
constructor( private readonly rates: RatePlan, private readonly ids: () => string, private readonly clock: () => Day, ) {}
addRoom(r: Room): void { this.rooms.push(r); this.inventory.addRoom(r.type); }
reserve(guest: string, type: string, stay: Stay): { result: Result; reservation?: Reservation } { const bad = this.check(type, stay); if (bad !== 'ok') return { result: bad }; if (!this.inventory.take(type, stay.nights())) return { result: 'sold-out' }; const r = new Reservation(this.ids(), guest, type, stay, this.quote(type, stay)); this.reservations.set(r.id, r); return { result: 'ok', reservation: r }; }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. modify(id: string, newType: string, newStay: Stay): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const bad = this.check(newType, newStay); if (bad !== 'ok') return bad; const sameType = newType === r.type; const oldNights = r.stay.nights(); const newNights = newStay.nights(); const toTake = sameType ? minus(newNights, oldNights) : newNights; const toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!this.inventory.take(newType, toTake)) return 'sold-out'; this.inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = this.quote(newType, newStay); return 'ok'; }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The error would mean the counts are // wrong, not bad luck. checkIn(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const today = this.clock(); if (today < r.stay.checkIn) return 'too-early'; if (today >= r.stay.checkOut) return 'too-late'; const room = this.rooms.find((x) => x.type === r.type && this.isVacant(x, today)); if (!room) throw new Error(`inventory says a ${r.type} is free, rooms disagree`); r.room = room; r.status = 'checked-in'; this.inHouse.set(room.number, r); return 'ok'; }
checkOut(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'checked-in') return 'wrong-status'; const today = this.clock(); this.inHouse.delete(r.room!.number); r.status = 'checked-out'; // Leaving early gives the remaining nights back to sell. if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); return 'ok'; }
free(type: string, night: Day): number { return this.inventory.free(type, night); }
private check(type: string, stay: Stay): Result { if (!stay.isValid() || stay.checkIn < this.clock()) return 'invalid-dates'; if (!this.inventory.sells(type)) return 'no-such-type'; return 'ok'; }
private quote(type: string, stay: Stay): number[] { return stay.nights().map((n) => this.rates.nightlyRate(type, n)); }
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private isVacant(room: Room, night: Day): boolean { const r = this.inHouse.get(room.number); return !r || r.stay.checkOut <= night; }}
function minus(a: Day[], b: Day[]): Day[] { const drop = new Set(b); return a.filter((n) => !drop.has(n));}Go deeperThe move, step by step1 min
The guest holds Tuesday and Wednesday and asks for Wednesday and Thursday. The hotel works out the
difference: Thursday is new, Tuesday is no longer needed, Wednesday stays. It asks the inventory
to take Thursday. Only if that works does it give back Tuesday, then update the reservation’s stay
and its list of quoted prices. If Thursday were full, the answer would be SOLD_OUT, and the guest
would still hold Tuesday and Wednesday, exactly as before. At no moment did the guest hold fewer
nights than they started with.
- Color key
- Entity
- Facade
Go deeperWhy store a price per night, not a total?1 min
Because a change keeps some nights and drops others, and a no-show or a late cancel pays for one night. With only a total, both would need the price of a single night, which may have changed since booking. With a list, the first night’s price is simply the first item.
Stage 4: Cancellations and no-shows
Section titled “Stage 4: Cancellations and no-shows”Guests can cancel for free until two days before arrival. After that, they pay the first night. And some guests just never turn up. Add both.
Whether the cancellation rule is a policy. Whether every way of giving up nights returns them to the inventory. And whether a no-show’s nights from today on are put back on sale. Forgetting the no-show leaves rooms empty for the rest of a stay nobody is using.
The deadline for free cancellation belongs to the hotel, and changes by season and rate. So it’s a
CancellationPolicy that the hotel asks, with the reservation and today’s date (Strategy
again). Cancelling records the fee, marks the reservation cancelled, and gives every night back. A
second cancel finds it already cancelled, so the nights are only given back once.
A no-show is a guest due yesterday or earlier who never checked in. A job each morning finds them, charges the first night, and gives back the nights from today on, so the room can be sold tonight. Running the job twice changes nothing the second time, because it only looks at reservations still waiting for their guest.
Every way a reservation can end now gives back exactly the nights it won’t use:
- Color key
- State
- Ends well
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A reservation's life. CANCELLED and NO_SHOW keep the record, with any fee charged.enum Status { BOOKED, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW }
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. rates// holds each night's price, fixed when booked.class Reservation { final String id; final String guest; String type; Stay stay; List<Long> rates; Status status = Status.BOOKED; Room room; long fee; // charged to the card on file for a late cancellation or a no-show
Reservation(String id, String guest, String type, Stay stay, List<Long> rates) { this.id = id; this.guest = guest; this.type = type; this.stay = stay; this.rates = rates; }
long total() { return rates.stream().mapToLong(Long::longValue).sum(); }}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.interface CancellationPolicy { long feeFor(Reservation r, LocalDate today); // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.class StandardCancellation implements CancellationPolicy { public long feeFor(Reservation r, LocalDate today) { long daysBefore = ChronoUnit.DAYS.between(today, r.stay.checkIn()); return daysBefore >= 2 ? 0 : r.rates.get(0); }}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.class Hotel { private final List<Room> rooms = new ArrayList<>(); private final Inventory inventory = new Inventory(); private final Map<String, Reservation> reservations = new HashMap<>(); private final Map<String, Reservation> inHouse = new HashMap<>(); // room number -> guest staying now private final RatePlan rates; private final CancellationPolicy cancellations; private final Supplier<String> ids; private final Supplier<LocalDate> clock;
Hotel(RatePlan rates, CancellationPolicy cancellations, Supplier<String> ids, Supplier<LocalDate> clock) { this.rates = rates; this.cancellations = cancellations; this.ids = ids; this.clock = clock; }
void addRoom(Room r) { rooms.add(r); inventory.addRoom(r.type()); }
ReserveResult reserve(String guest, String type, Stay stay) { Result bad = check(type, stay); if (bad != Result.OK) return new ReserveResult(bad, null); if (!inventory.take(type, stay.nights())) return new ReserveResult(Result.SOLD_OUT, null); Reservation r = new Reservation(ids.get(), guest, type, stay, quote(type, stay)); reservations.put(r.id, r); return new ReserveResult(Result.OK, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. Result modify(String reservationId, String newType, Stay newStay) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; Result bad = check(newType, newStay); if (bad != Result.OK) return bad; boolean sameType = newType.equals(r.type); List<LocalDate> oldNights = r.stay.nights(); List<LocalDate> newNights = newStay.nights(); List<LocalDate> toTake = sameType ? minus(newNights, oldNights) : newNights; List<LocalDate> toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!inventory.take(newType, toTake)) return Result.SOLD_OUT; inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = quote(newType, newStay); return Result.OK; }
// Only on a night of the stay. Any vacant room of the booked type will do: this is where a // reservation becomes a room. Result checkIn(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; LocalDate today = clock.get(); if (today.isBefore(r.stay.checkIn())) return Result.TOO_EARLY; if (!today.isBefore(r.stay.checkOut())) return Result.TOO_LATE; Room room = rooms.stream().filter(x -> x.type().equals(r.type) && isVacant(x, today)).findFirst() .orElseThrow(() -> new IllegalStateException("inventory says a " + r.type + " is free, rooms disagree")); r.room = room; r.status = Status.CHECKED_IN; inHouse.put(room.number(), r); return Result.OK; }
// Leaving early puts the unused nights back on sale. Result checkOut(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.CHECKED_IN) return Result.WRONG_STATUS; LocalDate today = clock.get(); inHouse.remove(r.room.number()); r.status = Status.CHECKED_OUT; if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); return Result.OK; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. Result cancel(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; r.fee = cancellations.feeFor(r, clock.get()); r.status = Status.CANCELLED; inventory.give(r.type, r.stay.nights()); return Result.OK; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. int markNoShows() { LocalDate today = clock.get(); int marked = 0; for (Reservation r : reservations.values()) { if (r.status != Status.BOOKED || !r.stay.checkIn().isBefore(today)) continue; r.status = Status.NO_SHOW; r.fee = r.rates.get(0); if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); marked++; } return marked; }
int free(String type, LocalDate night) { return inventory.free(type, night); }
// What every new or changed booking needs: real dates, not in the past, a type we sell. private Result check(String type, Stay stay) { if (!stay.isValid() || stay.checkIn().isBefore(clock.get())) return Result.INVALID_DATES; if (!inventory.sells(type)) return Result.NO_SUCH_TYPE; return Result.OK; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private List<Long> quote(String type, Stay stay) { return stay.nights().stream().map(n -> rates.nightlyRate(type, n)).toList(); }
// Free tonight if nobody is in it, or the guest in it leaves today. private boolean isVacant(Room room, LocalDate night) { Reservation r = inHouse.get(room.number()); return r == null || !r.stay.checkOut().isAfter(night); }
// The nights in a that aren't in b. private static List<LocalDate> minus(List<LocalDate> a, List<LocalDate> b) { Set<LocalDate> drop = new HashSet<>(b); return a.stream().filter(n -> !drop.contains(n)).toList(); }}# A reservation's life. Cancelled and no-show keep the record, with any fee charged.class Status(Enum): BOOKED = auto() CHECKED_IN = auto() CHECKED_OUT = auto() CANCELLED = auto() NO_SHOW = auto()
# One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates# hold each night's price, fixed when booked.@dataclass(eq=False)class Reservation: id: str guest: str type: str stay: Stay rates: list[int] status: Status = Status.BOOKED room: Room | None = None fee: int = 0 # charged to the card on file for a late cancellation or a no-show
def total(self) -> int: return sum(self.rates)
# Hotels set the deadline for free cancellation, and change it by season and rate, so it's a# policy the hotel asks.class CancellationPolicy(Protocol): def fee_for(self, r: Reservation, today: date) -> int: ... # in cents; 0 means free
# Free until two days before arrival; after that, the first night.class StandardCancellation: def fee_for(self, r: Reservation, today: date) -> int: return 0 if (r.stay.check_in - today).days >= 2 else r.rates[0]
# The front desk and the booking site both go through here. The inventory answers "is there a# room?"; actual rooms are given out only at check-in.class Hotel: def __init__(self, rates: RatePlan, cancellations: CancellationPolicy, ids: Callable[[], str], clock: Callable[[], date]): self._rooms: list[Room] = [] self._inventory = Inventory() self._reservations: dict[str, Reservation] = {} self._in_house: dict[str, Reservation] = {} self._rates = rates self._cancellations = cancellations self._ids = ids self._clock = clock
def add_room(self, r: Room) -> None: self._rooms.append(r) self._inventory.add_room(r.type)
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: bad = self._check(type, stay) if bad is not Result.OK: return bad, None if not self._inventory.take(type, stay.nights()): return Result.SOLD_OUT, None r = Reservation(self._ids(), guest, type, stay, self._quote(type, stay)) self._reservations[r.id] = r return Result.OK, r
# Change the dates or the room type. Take the nights the new stay needs that the old one # doesn't already hold, and only then give back the nights it no longer needs. If the new # nights aren't free, nothing has changed and the guest keeps the original booking. Moving # Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the # hotel is full on Wednesday. def modify(self, reservation_id: str, new_type: str, new_stay: Stay) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS bad = self._check(new_type, new_stay) if bad is not Result.OK: return bad old_nights, new_nights = r.stay.nights(), new_stay.nights() same_type = new_type == r.type to_take = [n for n in new_nights if n not in old_nights] if same_type else new_nights to_give = [n for n in old_nights if n not in new_nights] if same_type else old_nights if not self._inventory.take(new_type, to_take): return Result.SOLD_OUT self._inventory.give(r.type, to_give) r.type, r.stay, r.rates = new_type, new_stay, self._quote(new_type, new_stay) return Result.OK
# Only on a night of the stay. Any vacant room of the booked type will do: this is where a # reservation becomes a room. def check_in(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS today = self._clock() if today < r.stay.check_in: return Result.TOO_EARLY if today >= r.stay.check_out: return Result.TOO_LATE room = next((x for x in self._rooms if x.type == r.type and self._is_vacant(x, today)), None) if room is None: raise RuntimeError(f"inventory says a {r.type} is free, rooms disagree") r.room = room r.status = Status.CHECKED_IN self._in_house[room.number] = r return Result.OK
# Leaving early puts the unused nights back on sale. def check_out(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.CHECKED_IN: return Result.WRONG_STATUS today = self._clock() del self._in_house[r.room.number] r.status = Status.CHECKED_OUT if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) return Result.OK
# The nights go back on sale and the policy decides the fee. Cancelling twice finds the # reservation already cancelled, so the nights are given back once. def cancel(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS r.fee = self._cancellations.fee_for(r, self._clock()) r.status = Status.CANCELLED self._inventory.give(r.type, r.stay.nights()) return Result.OK
# Run each morning. A guest who was due yesterday or earlier and never arrived is a # no-show: they're charged the first night, and the nights from today on go back on sale # so the room can be sold tonight. Running it twice changes nothing the second time. def mark_no_shows(self) -> int: today = self._clock() marked = 0 for r in self._reservations.values(): if r.status is not Status.BOOKED or r.stay.check_in >= today: continue r.status = Status.NO_SHOW r.fee = r.rates[0] if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) marked += 1 return marked
def free(self, type: str, night: date) -> int: return self._inventory.free(type, night)
# What every new or changed booking needs: real dates, not in the past, a type we sell. def _check(self, type: str, stay: Stay) -> Result: if not stay.is_valid() or stay.check_in < self._clock(): return Result.INVALID_DATES if not self._inventory.sells(type): return Result.NO_SUCH_TYPE return Result.OK
# Each night's price, fixed now, so a later rate change doesn't touch this booking. def _quote(self, type: str, stay: Stay) -> list[int]: return [self._rates.nightly_rate(type, n) for n in stay.nights()]
# Free tonight if nobody is in it, or the guest in it leaves today. def _is_vacant(self, room: Room, night: date) -> bool: r = self._in_house.get(room.number) return r is None or r.stay.check_out <= night// A reservation's life. Cancelled and no-show keep the record, with any fee charged.public enum Status { Booked, CheckedIn, CheckedOut, Cancelled, NoShow }
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates// hold each night's price, fixed when booked.public sealed class Reservation(string id, string guest, string type, Stay stay, List<long> rates){ public string Id { get; } = id; public string Guest { get; } = guest; public string Type { get; set; } = type; public Stay Stay { get; set; } = stay; public List<long> Rates { get; set; } = rates; public Status Status { get; set; } = Status.Booked; public Room? Room { get; set; } public long Fee { get; set; } // charged to the card on file for a late cancellation or a no-show
public long Total => Rates.Sum();}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.public interface ICancellationPolicy{ long FeeFor(Reservation r, DateOnly today); // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.public sealed class StandardCancellation : ICancellationPolicy{ public long FeeFor(Reservation r, DateOnly today) => r.Stay.CheckIn.DayNumber - today.DayNumber >= 2 ? 0 : r.Rates[0];}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.public sealed class Hotel(IRatePlan rates, ICancellationPolicy cancellations, Func<string> ids, Func<DateOnly> clock){ private readonly List<Room> _rooms = []; private readonly Inventory _inventory = new(); private readonly Dictionary<string, Reservation> _reservations = []; private readonly Dictionary<string, Reservation> _inHouse = [];
public void AddRoom(Room r) { _rooms.Add(r); _inventory.AddRoom(r.Type); }
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { var bad = Check(type, stay); if (bad != Result.Ok) return (bad, null); if (!_inventory.Take(type, stay.Nights())) return (Result.SoldOut, null); var r = new Reservation(ids(), guest, type, stay, Quote(type, stay)); _reservations[r.Id] = r; return (Result.Ok, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. public Result Modify(string id, string newType, Stay newStay) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var bad = Check(newType, newStay); if (bad != Result.Ok) return bad; var sameType = newType == r.Type; var oldNights = r.Stay.Nights(); var newNights = newStay.Nights(); var toTake = sameType ? newNights.Except(oldNights).ToList() : newNights; var toGive = sameType ? oldNights.Except(newNights).ToList() : oldNights; if (!_inventory.Take(newType, toTake)) return Result.SoldOut; _inventory.Give(r.Type, toGive); r.Type = newType; r.Stay = newStay; r.Rates = Quote(newType, newStay); return Result.Ok; }
// Only on a night of the stay. Any vacant room of the booked type will do: this is where a // reservation becomes a room. public Result CheckIn(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var today = clock(); if (today < r.Stay.CheckIn) return Result.TooEarly; if (today >= r.Stay.CheckOut) return Result.TooLate; var room = _rooms.FirstOrDefault(x => x.Type == r.Type && IsVacant(x, today)) ?? throw new InvalidOperationException($"inventory says a {r.Type} is free, rooms disagree"); r.Room = room; r.Status = Status.CheckedIn; _inHouse[room.Number] = r; return Result.Ok; }
// Leaving early puts the unused nights back on sale. public Result CheckOut(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.CheckedIn) return Result.WrongStatus; var today = clock(); _inHouse.Remove(r.Room!.Number); r.Status = Status.CheckedOut; if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); return Result.Ok; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. public Result Cancel(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; r.Fee = cancellations.FeeFor(r, clock()); r.Status = Status.Cancelled; _inventory.Give(r.Type, r.Stay.Nights()); return Result.Ok; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. public int MarkNoShows() { var today = clock(); var marked = 0; foreach (var r in _reservations.Values) { if (r.Status != Status.Booked || r.Stay.CheckIn >= today) continue; r.Status = Status.NoShow; r.Fee = r.Rates[0]; if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); marked++; } return marked; }
public int Free(string type, DateOnly night) => _inventory.Free(type, night);
// What every new or changed booking needs: real dates, not in the past, a type we sell. private Result Check(string type, Stay stay) { if (!stay.IsValid || stay.CheckIn < clock()) return Result.InvalidDates; if (!_inventory.Sells(type)) return Result.NoSuchType; return Result.Ok; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private List<long> Quote(string type, Stay stay) => stay.Nights().Select(n => rates.NightlyRate(type, n)).ToList();
// Free tonight if nobody is in it, or the guest in it leaves today. private bool IsVacant(Room room, DateOnly night) => !_inHouse.TryGetValue(room.Number, out var r) || r.Stay.CheckOut <= night;}const ( Booked Status = iota CheckedIn CheckedOut Cancelled NoShow)
// Reservation is one guest's booking: a room type for a stay. The room itself is chosen at// check-in. Rates holds each night's price, fixed when booked.type Reservation struct { ID, Guest string Type string Stay Stay Rates []int64 Status Status Room *Room Fee int64 // charged to the card on file for a late cancellation or a no-show}
func (r *Reservation) Total() int64 { var sum int64 for _, x := range r.Rates { sum += x } return sum}
// CancellationPolicy sets the deadline for free cancellation. Hotels change it by season and// rate, so it's a policy the hotel asks.type CancellationPolicy interface { FeeFor(r *Reservation, today time.Time) int64 // in cents; 0 means free}
// StandardCancellation is free until two days before arrival; after that, the first night.type StandardCancellation struct{}
func (StandardCancellation) FeeFor(r *Reservation, today time.Time) int64 { if r.Stay.CheckIn.Sub(today) >= 48*time.Hour { return 0 } return r.Rates[0]}
// Hotel is where the front desk and the booking site both go. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.type Hotel struct { rooms []Room inventory *Inventory reservations map[string]*Reservation inHouse map[string]*Reservation rates RatePlan cancels CancellationPolicy ids func() string clock func() time.Time}
func NewHotel(rates RatePlan, cancels CancellationPolicy, ids func() string, clock func() time.Time) *Hotel { return &Hotel{inventory: NewInventory(), reservations: map[string]*Reservation{}, inHouse: map[string]*Reservation{}, rates: rates, cancels: cancels, ids: ids, clock: clock}}
func (h *Hotel) AddRoom(r Room) { h.rooms = append(h.rooms, r) h.inventory.AddRoom(r.Type)}
func (h *Hotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { if bad := h.check(roomType, stay); bad != OK { return bad, nil } if !h.inventory.Take(roomType, stay.Nights()) { return SoldOut, nil } r := &Reservation{ID: h.ids(), Guest: guest, Type: roomType, Stay: stay, Rates: h.quote(roomType, stay)} h.reservations[r.ID] = r return OK, r}
// Modify changes the dates or the room type. It takes the nights the new stay needs that the// old one doesn't already hold, and only then gives back the nights it no longer needs. If the// new nights aren't free, nothing has changed and the guest keeps the original booking. Moving// Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the// hotel is full on Wednesday.func (h *Hotel) Modify(id, newType string, newStay Stay) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } if bad := h.check(newType, newStay); bad != OK { return bad } oldNights, newNights := r.Stay.Nights(), newStay.Nights() toTake, toGive := newNights, oldNights if newType == r.Type { toTake, toGive = minus(newNights, oldNights), minus(oldNights, newNights) } if !h.inventory.Take(newType, toTake) { return SoldOut } h.inventory.Give(r.Type, toGive) r.Type, r.Stay, r.Rates = newType, newStay, h.quote(newType, newStay) return OK}
// CheckIn works only on a night of the stay. Any vacant room of the booked type will do: this// is where a reservation becomes a room.func (h *Hotel) CheckIn(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } today := h.clock() if today.Before(r.Stay.CheckIn) { return TooEarly } if !today.Before(r.Stay.CheckOut) { return TooLate } for i := range h.rooms { room := &h.rooms[i] if room.Type == r.Type && h.isVacant(*room, today) { r.Room = room r.Status = CheckedIn h.inHouse[room.Number] = r return OK } } panic("inventory says a " + r.Type + " is free, rooms disagree")}
// CheckOut on an early departure puts the unused nights back on sale.func (h *Hotel) CheckOut(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != CheckedIn { return WrongStatus } today := h.clock() delete(h.inHouse, r.Room.Number) r.Status = CheckedOut if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } return OK}
// Cancel puts the nights back on sale and lets the policy decide the fee. Cancelling twice// finds the reservation already cancelled, so the nights are given back once.func (h *Hotel) Cancel(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } r.Fee = h.cancels.FeeFor(r, h.clock()) r.Status = Cancelled h.inventory.Give(r.Type, r.Stay.Nights()) return OK}
// MarkNoShows runs each morning. A guest who was due yesterday or earlier and never arrived is// a no-show: they're charged the first night, and the nights from today on go back on sale so// the room can be sold tonight. Running it twice changes nothing the second time.func (h *Hotel) MarkNoShows() int { today := h.clock() marked := 0 for _, r := range h.reservations { if r.Status != Booked || !r.Stay.CheckIn.Before(today) { continue } r.Status = NoShow r.Fee = r.Rates[0] if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } marked++ } return marked}
func (h *Hotel) Free(roomType string, night time.Time) int { return h.inventory.Free(roomType, night) }
// check is what every new or changed booking needs: real dates, not in the past, a type we// sell.func (h *Hotel) check(roomType string, stay Stay) Result { if !stay.Valid() || stay.CheckIn.Before(h.clock()) { return InvalidDates } if !h.inventory.Sells(roomType) { return NoSuchType } return OK}
// quote is each night's price, fixed now, so a later rate change doesn't touch this booking.func (h *Hotel) quote(roomType string, stay Stay) []int64 { var out []int64 for _, n := range stay.Nights() { out = append(out, h.rates.NightlyRate(roomType, n)) } return out}
// isVacant: free tonight if nobody is in it, or the guest in it leaves today.func (h *Hotel) isVacant(room Room, night time.Time) bool { r := h.inHouse[room.Number] return r == nil || !r.Stay.CheckOut.After(night)}// A reservation's life. Cancelled and no-show keep the record, with any fee charged.type Status = 'booked' | 'checked-in' | 'checked-out' | 'cancelled' | 'no-show';
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates// hold each night's price, fixed when booked.class Reservation { status: Status = 'booked'; room: Room | null = null; fee = 0; // charged to the card on file for a late cancellation or a no-show
constructor( readonly id: string, readonly guest: string, public type: string, public stay: Stay, public rates: number[], ) {}
total(): number { return this.rates.reduce((a, b) => a + b, 0); }}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.interface CancellationPolicy { feeFor(r: Reservation, today: Day): number; // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.class StandardCancellation implements CancellationPolicy { feeFor(r: Reservation, today: Day): number { return r.stay.checkIn - today >= 2 ? 0 : r.rates[0]; }}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.class Hotel { private readonly rooms: Room[] = []; private readonly inventory = new Inventory(); private readonly reservations = new Map<string, Reservation>(); private readonly inHouse = new Map<string, Reservation>(); // room number -> guest in it
constructor( private readonly rates: RatePlan, private readonly cancellations: CancellationPolicy, private readonly ids: () => string, private readonly clock: () => Day, ) {}
addRoom(r: Room): void { this.rooms.push(r); this.inventory.addRoom(r.type); }
reserve(guest: string, type: string, stay: Stay): { result: Result; reservation?: Reservation } { const bad = this.check(type, stay); if (bad !== 'ok') return { result: bad }; if (!this.inventory.take(type, stay.nights())) return { result: 'sold-out' }; const r = new Reservation(this.ids(), guest, type, stay, this.quote(type, stay)); this.reservations.set(r.id, r); return { result: 'ok', reservation: r }; }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. modify(id: string, newType: string, newStay: Stay): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const bad = this.check(newType, newStay); if (bad !== 'ok') return bad; const sameType = newType === r.type; const oldNights = r.stay.nights(); const newNights = newStay.nights(); const toTake = sameType ? minus(newNights, oldNights) : newNights; const toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!this.inventory.take(newType, toTake)) return 'sold-out'; this.inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = this.quote(newType, newStay); return 'ok'; }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The error would mean the counts are // wrong, not bad luck. checkIn(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const today = this.clock(); if (today < r.stay.checkIn) return 'too-early'; if (today >= r.stay.checkOut) return 'too-late'; const room = this.rooms.find((x) => x.type === r.type && this.isVacant(x, today)); if (!room) throw new Error(`inventory says a ${r.type} is free, rooms disagree`); r.room = room; r.status = 'checked-in'; this.inHouse.set(room.number, r); return 'ok'; }
// Leaving early puts the unused nights back on sale. checkOut(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'checked-in') return 'wrong-status'; const today = this.clock(); this.inHouse.delete(r.room!.number); r.status = 'checked-out'; // Leaving early gives the remaining nights back to sell. if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); return 'ok'; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. cancel(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; r.fee = this.cancellations.feeFor(r, this.clock()); r.status = 'cancelled'; this.inventory.give(r.type, r.stay.nights()); return 'ok'; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. markNoShows(): number { const today = this.clock(); let marked = 0; for (const r of this.reservations.values()) { if (r.status !== 'booked' || r.stay.checkIn >= today) continue; r.status = 'no-show'; r.fee = r.rates[0]; if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); marked++; } return marked; }
free(type: string, night: Day): number { return this.inventory.free(type, night); }
// What every new or changed booking needs: real dates, not in the past, a type we sell. private check(type: string, stay: Stay): Result { if (!stay.isValid() || stay.checkIn < this.clock()) return 'invalid-dates'; if (!this.inventory.sells(type)) return 'no-such-type'; return 'ok'; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private quote(type: string, stay: Stay): number[] { return stay.nights().map((n) => this.rates.nightlyRate(type, n)); }
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private isVacant(room: Room, night: Day): boolean { const r = this.inHouse.get(room.number); return !r || r.stay.checkOut <= night; }}
// The nights in a that aren't in b.function minus(a: Day[], b: Day[]): Day[] { const drop = new Set(b); return a.filter((n) => !drop.has(n));}This is enough for a mid-level answer. Stays are half-open. Room types are sold per night, all or nothing. And the room is chosen at check-in. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it. Rooms appear only at check-in.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.locks.*;import java.util.function.*;import java.time.*;import java.time.temporal.*;
// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.record Room(String number, String type) {}
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.record Stay(LocalDate checkIn, LocalDate checkOut) { boolean isValid() { return checkIn.isBefore(checkOut); }
boolean overlaps(Stay o) { return checkIn.isBefore(o.checkOut) && o.checkIn.isBefore(checkOut); }
List<LocalDate> nights() { return checkIn.datesUntil(checkOut).toList(); }}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.class Hotel { private final List<Room> rooms = new ArrayList<>(); private final Inventory inventory = new Inventory(); private final Map<String, Reservation> reservations = new HashMap<>(); private final Map<String, Reservation> inHouse = new HashMap<>(); // room number -> guest staying now private final RatePlan rates; private final CancellationPolicy cancellations; private final Supplier<String> ids; private final Supplier<LocalDate> clock;
Hotel(RatePlan rates, CancellationPolicy cancellations, Supplier<String> ids, Supplier<LocalDate> clock) { this.rates = rates; this.cancellations = cancellations; this.ids = ids; this.clock = clock; }
void addRoom(Room r) { rooms.add(r); inventory.addRoom(r.type()); }
ReserveResult reserve(String guest, String type, Stay stay) { Result bad = check(type, stay); if (bad != Result.OK) return new ReserveResult(bad, null); if (!inventory.take(type, stay.nights())) return new ReserveResult(Result.SOLD_OUT, null); Reservation r = new Reservation(ids.get(), guest, type, stay, quote(type, stay)); reservations.put(r.id, r); return new ReserveResult(Result.OK, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. Result modify(String reservationId, String newType, Stay newStay) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; Result bad = check(newType, newStay); if (bad != Result.OK) return bad; boolean sameType = newType.equals(r.type); List<LocalDate> oldNights = r.stay.nights(); List<LocalDate> newNights = newStay.nights(); List<LocalDate> toTake = sameType ? minus(newNights, oldNights) : newNights; List<LocalDate> toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!inventory.take(newType, toTake)) return Result.SOLD_OUT; inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = quote(newType, newStay); return Result.OK; }
// Only on a night of the stay. Any vacant room of the booked type will do: this is where a // reservation becomes a room. Result checkIn(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; LocalDate today = clock.get(); if (today.isBefore(r.stay.checkIn())) return Result.TOO_EARLY; if (!today.isBefore(r.stay.checkOut())) return Result.TOO_LATE; Room room = rooms.stream().filter(x -> x.type().equals(r.type) && isVacant(x, today)).findFirst() .orElseThrow(() -> new IllegalStateException("inventory says a " + r.type + " is free, rooms disagree")); r.room = room; r.status = Status.CHECKED_IN; inHouse.put(room.number(), r); return Result.OK; }
// Leaving early puts the unused nights back on sale. Result checkOut(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.CHECKED_IN) return Result.WRONG_STATUS; LocalDate today = clock.get(); inHouse.remove(r.room.number()); r.status = Status.CHECKED_OUT; if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); return Result.OK; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. Result cancel(String reservationId) { Reservation r = reservations.get(reservationId); if (r == null) return Result.NO_SUCH_RESERVATION; if (r.status != Status.BOOKED) return Result.WRONG_STATUS; r.fee = cancellations.feeFor(r, clock.get()); r.status = Status.CANCELLED; inventory.give(r.type, r.stay.nights()); return Result.OK; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. int markNoShows() { LocalDate today = clock.get(); int marked = 0; for (Reservation r : reservations.values()) { if (r.status != Status.BOOKED || !r.stay.checkIn().isBefore(today)) continue; r.status = Status.NO_SHOW; r.fee = r.rates.get(0); if (today.isBefore(r.stay.checkOut())) inventory.give(r.type, new Stay(today, r.stay.checkOut()).nights()); marked++; } return marked; }
int free(String type, LocalDate night) { return inventory.free(type, night); }
// What every new or changed booking needs: real dates, not in the past, a type we sell. private Result check(String type, Stay stay) { if (!stay.isValid() || stay.checkIn().isBefore(clock.get())) return Result.INVALID_DATES; if (!inventory.sells(type)) return Result.NO_SUCH_TYPE; return Result.OK; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private List<Long> quote(String type, Stay stay) { return stay.nights().stream().map(n -> rates.nightlyRate(type, n)).toList(); }
// Free tonight if nobody is in it, or the guest in it leaves today. private boolean isVacant(Room room, LocalDate night) { Reservation r = inHouse.get(room.number()); return r == null || !r.stay.checkOut().isAfter(night); }
// The nights in a that aren't in b. private static List<LocalDate> minus(List<LocalDate> a, List<LocalDate> b) { Set<LocalDate> drop = new HashSet<>(b); return a.stream().filter(n -> !drop.contains(n)).toList(); }}
// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.class Inventory { private final Map<String, Integer> capacity = new HashMap<>(); // type -> rooms private final Map<String, Map<LocalDate, Integer>> sold = new HashMap<>(); // type -> night -> sold
void addRoom(String type) { capacity.merge(type, 1, Integer::sum); }
boolean sells(String type) { return capacity.containsKey(type); }
int free(String type, LocalDate night) { return capacity.getOrDefault(type, 0) - sold.getOrDefault(type, Map.of()).getOrDefault(night, 0); }
// All or nothing: check every night, then take every night. boolean take(String type, List<LocalDate> nights) { for (LocalDate n : nights) if (free(type, n) <= 0) return false; Map<LocalDate, Integer> m = sold.computeIfAbsent(type, k -> new HashMap<>()); for (LocalDate n : nights) m.merge(n, 1, Integer::sum); return true; }
// The opposite of take: these nights are on sale again. void give(String type, List<LocalDate> nights) { Map<LocalDate, Integer> m = sold.get(type); for (LocalDate n : nights) m.merge(n, -1, (a, b) -> a + b == 0 ? null : a + b); // drop zeros }}
// A reservation's life. CANCELLED and NO_SHOW keep the record, with any fee charged.enum Status { BOOKED, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW }
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. rates// holds each night's price, fixed when booked.class Reservation { final String id; final String guest; String type; Stay stay; List<Long> rates; Status status = Status.BOOKED; Room room; long fee; // charged to the card on file for a late cancellation or a no-show
Reservation(String id, String guest, String type, Stay stay, List<Long> rates) { this.id = id; this.guest = guest; this.type = type; this.stay = stay; this.rates = rates; }
long total() { return rates.stream().mapToLong(Long::longValue).sum(); }}
// What a request gets back. TOO_EARLY and TOO_LATE are about the day of check-in.enum Result { OK, INVALID_DATES, NO_SUCH_TYPE, SOLD_OUT, NO_SUCH_RESERVATION, WRONG_STATUS, TOO_EARLY, TOO_LATE }
// The result, plus the new reservation when it is OK.record ReserveResult(Result result, Reservation reservation) {}
// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.interface RatePlan { long nightlyRate(String type, LocalDate night); // in cents}
// One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates implements RatePlan { private final Map<String, Long> base;
StandardRates(Map<String, Long> base) { this.base = base; }
// Friday and Saturday nights cost a quarter more. public long nightlyRate(String type, LocalDate night) { long rate = base.get(type); DayOfWeek d = night.getDayOfWeek(); return d == DayOfWeek.FRIDAY || d == DayOfWeek.SATURDAY ? rate * 5 / 4 : rate; }}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.interface CancellationPolicy { long feeFor(Reservation r, LocalDate today); // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.class StandardCancellation implements CancellationPolicy { public long feeFor(Reservation r, LocalDate today) { long daysBefore = ChronoUnit.DAYS.between(today, r.stay.checkIn()); return daysBefore >= 2 ? 0 : r.rates.get(0); }}
// The website, a travel agency's feed and the front desk all book the same hotel at once. Two// requests for the last double on Friday both find free("double", Friday) == 1 and both take// it, and the hotel has sold a room it doesn't have. Every operation reads counts, then// changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock// per hotel is plenty; a booking platform runs many hotels side by side, and they never// share a lock.class SharedHotel { private final Hotel hotel; private final ReentrantLock lock = new ReentrantLock();
SharedHotel(Hotel hotel) { this.hotel = hotel; }
ReserveResult reserve(String guest, String type, Stay stay) { return locked(() -> hotel.reserve(guest, type, stay)); } Result modify(String id, String type, Stay stay) { return locked(() -> hotel.modify(id, type, stay)); } Result cancel(String id) { return locked(() -> hotel.cancel(id)); } Result checkIn(String id) { return locked(() -> hotel.checkIn(id)); } Result checkOut(String id) { return locked(() -> hotel.checkOut(id)); } int markNoShows() { return locked(hotel::markNoShows); } int free(String type, LocalDate night) { return locked(() -> hotel.free(type, night)); }
// Runs one operation with the lock held. finally releases it even if the operation throws. private <T> T locked(Supplier<T> op) { lock.lock(); try { return op.get(); } finally { lock.unlock(); } }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom dataclasses import dataclassfrom datetime import date, timedeltafrom enum import Enum, autofrom typing import Callable, Protocol
# A physical room, known by its number. Its type ("double", "suite") decides the price and# what's in it.@dataclass(frozen=True)class Room: number: str type: str
# The nights from check-in up to, but not including, check-out: arriving on the 10th and# leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay# end on the day the next begins without a special case.@dataclass(frozen=True)class Stay: check_in: date check_out: date
def is_valid(self) -> bool: return self.check_in < self.check_out
def overlaps(self, o: "Stay") -> bool: return self.check_in < o.check_out and o.check_in < self.check_out
def nights(self) -> list[date]: return [self.check_in + timedelta(days=i) for i in range((self.check_out - self.check_in).days)]
# The front desk and the booking site both go through here. The inventory answers "is there a# room?"; actual rooms are given out only at check-in.class Hotel: def __init__(self, rates: RatePlan, cancellations: CancellationPolicy, ids: Callable[[], str], clock: Callable[[], date]): self._rooms: list[Room] = [] self._inventory = Inventory() self._reservations: dict[str, Reservation] = {} self._in_house: dict[str, Reservation] = {} self._rates = rates self._cancellations = cancellations self._ids = ids self._clock = clock
def add_room(self, r: Room) -> None: self._rooms.append(r) self._inventory.add_room(r.type)
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: bad = self._check(type, stay) if bad is not Result.OK: return bad, None if not self._inventory.take(type, stay.nights()): return Result.SOLD_OUT, None r = Reservation(self._ids(), guest, type, stay, self._quote(type, stay)) self._reservations[r.id] = r return Result.OK, r
# Change the dates or the room type. Take the nights the new stay needs that the old one # doesn't already hold, and only then give back the nights it no longer needs. If the new # nights aren't free, nothing has changed and the guest keeps the original booking. Moving # Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the # hotel is full on Wednesday. def modify(self, reservation_id: str, new_type: str, new_stay: Stay) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS bad = self._check(new_type, new_stay) if bad is not Result.OK: return bad old_nights, new_nights = r.stay.nights(), new_stay.nights() same_type = new_type == r.type to_take = [n for n in new_nights if n not in old_nights] if same_type else new_nights to_give = [n for n in old_nights if n not in new_nights] if same_type else old_nights if not self._inventory.take(new_type, to_take): return Result.SOLD_OUT self._inventory.give(r.type, to_give) r.type, r.stay, r.rates = new_type, new_stay, self._quote(new_type, new_stay) return Result.OK
# Only on a night of the stay. Any vacant room of the booked type will do: this is where a # reservation becomes a room. def check_in(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS today = self._clock() if today < r.stay.check_in: return Result.TOO_EARLY if today >= r.stay.check_out: return Result.TOO_LATE room = next((x for x in self._rooms if x.type == r.type and self._is_vacant(x, today)), None) if room is None: raise RuntimeError(f"inventory says a {r.type} is free, rooms disagree") r.room = room r.status = Status.CHECKED_IN self._in_house[room.number] = r return Result.OK
# Leaving early puts the unused nights back on sale. def check_out(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.CHECKED_IN: return Result.WRONG_STATUS today = self._clock() del self._in_house[r.room.number] r.status = Status.CHECKED_OUT if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) return Result.OK
# The nights go back on sale and the policy decides the fee. Cancelling twice finds the # reservation already cancelled, so the nights are given back once. def cancel(self, reservation_id: str) -> Result: r = self._reservations.get(reservation_id) if r is None: return Result.NO_SUCH_RESERVATION if r.status is not Status.BOOKED: return Result.WRONG_STATUS r.fee = self._cancellations.fee_for(r, self._clock()) r.status = Status.CANCELLED self._inventory.give(r.type, r.stay.nights()) return Result.OK
# Run each morning. A guest who was due yesterday or earlier and never arrived is a # no-show: they're charged the first night, and the nights from today on go back on sale # so the room can be sold tonight. Running it twice changes nothing the second time. def mark_no_shows(self) -> int: today = self._clock() marked = 0 for r in self._reservations.values(): if r.status is not Status.BOOKED or r.stay.check_in >= today: continue r.status = Status.NO_SHOW r.fee = r.rates[0] if today < r.stay.check_out: self._inventory.give(r.type, Stay(today, r.stay.check_out).nights()) marked += 1 return marked
def free(self, type: str, night: date) -> int: return self._inventory.free(type, night)
# What every new or changed booking needs: real dates, not in the past, a type we sell. def _check(self, type: str, stay: Stay) -> Result: if not stay.is_valid() or stay.check_in < self._clock(): return Result.INVALID_DATES if not self._inventory.sells(type): return Result.NO_SUCH_TYPE return Result.OK
# Each night's price, fixed now, so a later rate change doesn't touch this booking. def _quote(self, type: str, stay: Stay) -> list[int]: return [self._rates.nightly_rate(type, n) for n in stay.nights()]
# Free tonight if nobody is in it, or the guest in it leaves today. def _is_vacant(self, room: Room, night: date) -> bool: r = self._in_house.get(room.number) return r is None or r.stay.check_out <= night
# What the hotel sells is a type of room for a set of nights, so that is what it counts:# for each type, how many rooms exist and how many are sold on each night.class Inventory: def __init__(self): self._capacity: dict[str, int] = {} # type -> rooms self._sold: dict[str, dict[date, int]] = {} # type -> night -> sold
def add_room(self, type: str) -> None: self._capacity[type] = self._capacity.get(type, 0) + 1
def sells(self, type: str) -> bool: return type in self._capacity
def free(self, type: str, night: date) -> int: return self._capacity.get(type, 0) - self._sold.get(type, {}).get(night, 0)
# All or nothing: check every night, then take every night. def take(self, type: str, nights: list[date]) -> bool: if any(self.free(type, n) <= 0 for n in nights): return False m = self._sold.setdefault(type, {}) for n in nights: m[n] = m.get(n, 0) + 1 return True
# The opposite of take: these nights are on sale again. def give(self, type: str, nights: list[date]) -> None: m = self._sold[type] for n in nights: m[n] -= 1 if m[n] == 0: del m[n]
# A reservation's life. Cancelled and no-show keep the record, with any fee charged.class Status(Enum): BOOKED = auto() CHECKED_IN = auto() CHECKED_OUT = auto() CANCELLED = auto() NO_SHOW = auto()
# One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates# hold each night's price, fixed when booked.@dataclass(eq=False)class Reservation: id: str guest: str type: str stay: Stay rates: list[int] status: Status = Status.BOOKED room: Room | None = None fee: int = 0 # charged to the card on file for a late cancellation or a no-show
def total(self) -> int: return sum(self.rates)
# What a request gets back. TooEarly and TooLate are about the day of check-in.class Result(Enum): OK = auto() INVALID_DATES = auto() NO_SUCH_TYPE = auto() SOLD_OUT = auto() NO_SUCH_RESERVATION = auto() WRONG_STATUS = auto() TOO_EARLY = auto() TOO_LATE = auto()
# Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate# plan answers "what does one night of this type cost?", and a stay is the sum of its nights.class RatePlan(Protocol): def nightly_rate(self, type: str, night: date) -> int: ... # in cents
# One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates: def __init__(self, base: dict[str, int]): self._base = base
# Friday and Saturday nights cost a quarter more. def nightly_rate(self, type: str, night: date) -> int: rate = self._base[type] return rate * 5 // 4 if night.weekday() in (4, 5) else rate
# Hotels set the deadline for free cancellation, and change it by season and rate, so it's a# policy the hotel asks.class CancellationPolicy(Protocol): def fee_for(self, r: Reservation, today: date) -> int: ... # in cents; 0 means free
# Free until two days before arrival; after that, the first night.class StandardCancellation: def fee_for(self, r: Reservation, today: date) -> int: return 0 if (r.stay.check_in - today).days >= 2 else r.rates[0]
# The website, a travel agency's feed and the front desk all book the same hotel at once. Two# requests for the last double on Friday both find free("double", friday) == 1 and both take# it, and the hotel has sold a room it doesn't have. Every operation reads counts, then# changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock# per hotel is plenty; a booking platform runs many hotels side by side, and they never share# a lock.class SharedHotel: def __init__(self, hotel: Hotel): self._hotel = hotel self._lock = threading.Lock()
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: with self._lock: return self._hotel.reserve(guest, type, stay)
def modify(self, reservation_id: str, type: str, stay: Stay) -> Result: with self._lock: return self._hotel.modify(reservation_id, type, stay)
def cancel(self, reservation_id: str) -> Result: with self._lock: return self._hotel.cancel(reservation_id)
def check_in(self, reservation_id: str) -> Result: with self._lock: return self._hotel.check_in(reservation_id)
def check_out(self, reservation_id: str) -> Result: with self._lock: return self._hotel.check_out(reservation_id)
def mark_no_shows(self) -> int: with self._lock: return self._hotel.mark_no_shows()
def free(self, type: str, night: date) -> int: with self._lock: return self._hotel.free(type, night)// The complete design, built from every stage on this page.
// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.public sealed record Room(string Number, string Type);
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.public sealed record Stay(DateOnly CheckIn, DateOnly CheckOut){ public bool IsValid => CheckIn < CheckOut;
public bool Overlaps(Stay o) => CheckIn < o.CheckOut && o.CheckIn < CheckOut;
public List<DateOnly> Nights() { var nights = new List<DateOnly>(); for (var d = CheckIn; d < CheckOut; d = d.AddDays(1)) nights.Add(d); return nights; }}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.public sealed class Hotel(IRatePlan rates, ICancellationPolicy cancellations, Func<string> ids, Func<DateOnly> clock){ private readonly List<Room> _rooms = []; private readonly Inventory _inventory = new(); private readonly Dictionary<string, Reservation> _reservations = []; private readonly Dictionary<string, Reservation> _inHouse = [];
public void AddRoom(Room r) { _rooms.Add(r); _inventory.AddRoom(r.Type); }
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { var bad = Check(type, stay); if (bad != Result.Ok) return (bad, null); if (!_inventory.Take(type, stay.Nights())) return (Result.SoldOut, null); var r = new Reservation(ids(), guest, type, stay, Quote(type, stay)); _reservations[r.Id] = r; return (Result.Ok, r); }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. public Result Modify(string id, string newType, Stay newStay) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var bad = Check(newType, newStay); if (bad != Result.Ok) return bad; var sameType = newType == r.Type; var oldNights = r.Stay.Nights(); var newNights = newStay.Nights(); var toTake = sameType ? newNights.Except(oldNights).ToList() : newNights; var toGive = sameType ? oldNights.Except(newNights).ToList() : oldNights; if (!_inventory.Take(newType, toTake)) return Result.SoldOut; _inventory.Give(r.Type, toGive); r.Type = newType; r.Stay = newStay; r.Rates = Quote(newType, newStay); return Result.Ok; }
// Only on a night of the stay. Any vacant room of the booked type will do: this is where a // reservation becomes a room. public Result CheckIn(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; var today = clock(); if (today < r.Stay.CheckIn) return Result.TooEarly; if (today >= r.Stay.CheckOut) return Result.TooLate; var room = _rooms.FirstOrDefault(x => x.Type == r.Type && IsVacant(x, today)) ?? throw new InvalidOperationException($"inventory says a {r.Type} is free, rooms disagree"); r.Room = room; r.Status = Status.CheckedIn; _inHouse[room.Number] = r; return Result.Ok; }
// Leaving early puts the unused nights back on sale. public Result CheckOut(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.CheckedIn) return Result.WrongStatus; var today = clock(); _inHouse.Remove(r.Room!.Number); r.Status = Status.CheckedOut; if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); return Result.Ok; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. public Result Cancel(string id) { if (!_reservations.TryGetValue(id, out var r)) return Result.NoSuchReservation; if (r.Status != Status.Booked) return Result.WrongStatus; r.Fee = cancellations.FeeFor(r, clock()); r.Status = Status.Cancelled; _inventory.Give(r.Type, r.Stay.Nights()); return Result.Ok; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. public int MarkNoShows() { var today = clock(); var marked = 0; foreach (var r in _reservations.Values) { if (r.Status != Status.Booked || r.Stay.CheckIn >= today) continue; r.Status = Status.NoShow; r.Fee = r.Rates[0]; if (today < r.Stay.CheckOut) _inventory.Give(r.Type, new Stay(today, r.Stay.CheckOut).Nights()); marked++; } return marked; }
public int Free(string type, DateOnly night) => _inventory.Free(type, night);
// What every new or changed booking needs: real dates, not in the past, a type we sell. private Result Check(string type, Stay stay) { if (!stay.IsValid || stay.CheckIn < clock()) return Result.InvalidDates; if (!_inventory.Sells(type)) return Result.NoSuchType; return Result.Ok; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private List<long> Quote(string type, Stay stay) => stay.Nights().Select(n => rates.NightlyRate(type, n)).ToList();
// Free tonight if nobody is in it, or the guest in it leaves today. private bool IsVacant(Room room, DateOnly night) => !_inHouse.TryGetValue(room.Number, out var r) || r.Stay.CheckOut <= night;}
// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.public sealed class Inventory{ private readonly Dictionary<string, int> _capacity = []; // type -> rooms private readonly Dictionary<string, Dictionary<DateOnly, int>> _sold = []; // type -> night -> sold
public void AddRoom(string type) => _capacity[type] = _capacity.GetValueOrDefault(type) + 1;
public bool Sells(string type) => _capacity.ContainsKey(type);
public int Free(string type, DateOnly night) => _capacity.GetValueOrDefault(type) - (_sold.TryGetValue(type, out var m) ? m.GetValueOrDefault(night) : 0);
// All or nothing: check every night, then take every night. public bool Take(string type, IReadOnlyList<DateOnly> nights) { if (!nights.All(n => Free(type, n) > 0)) return false; if (!_sold.TryGetValue(type, out var m)) _sold[type] = m = []; foreach (var n in nights) m[n] = m.GetValueOrDefault(n) + 1; return true; }
// The opposite of take: these nights are on sale again. public void Give(string type, IReadOnlyList<DateOnly> nights) { var m = _sold[type]; foreach (var n in nights) if (--m[n] == 0) m.Remove(n); // drop zeros }}
// A reservation's life. Cancelled and no-show keep the record, with any fee charged.public enum Status { Booked, CheckedIn, CheckedOut, Cancelled, NoShow }
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates// hold each night's price, fixed when booked.public sealed class Reservation(string id, string guest, string type, Stay stay, List<long> rates){ public string Id { get; } = id; public string Guest { get; } = guest; public string Type { get; set; } = type; public Stay Stay { get; set; } = stay; public List<long> Rates { get; set; } = rates; public Status Status { get; set; } = Status.Booked; public Room? Room { get; set; } public long Fee { get; set; } // charged to the card on file for a late cancellation or a no-show
public long Total => Rates.Sum();}
// What a request gets back. TooEarly and TooLate are about the day of check-in.public enum Result { Ok, InvalidDates, NoSuchType, SoldOut, NoSuchReservation, WrongStatus, TooEarly, TooLate }
// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.public interface IRatePlan{ long NightlyRate(string type, DateOnly night); // in cents}
// One hotel's prices: a base rate per room type, and more for weekend nights.public sealed class StandardRates(IReadOnlyDictionary<string, long> baseRates) : IRatePlan{ // Friday and Saturday nights cost a quarter more. public long NightlyRate(string type, DateOnly night) { var rate = baseRates[type]; return night.DayOfWeek is DayOfWeek.Friday or DayOfWeek.Saturday ? rate * 5 / 4 : rate; }}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.public interface ICancellationPolicy{ long FeeFor(Reservation r, DateOnly today); // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.public sealed class StandardCancellation : ICancellationPolicy{ public long FeeFor(Reservation r, DateOnly today) => r.Stay.CheckIn.DayNumber - today.DayNumber >= 2 ? 0 : r.Rates[0];}
// The website, a travel agency's feed and the front desk all book the same hotel at once. Two// requests for the last double on Friday both find Free("double", friday) == 1 and both take// it, and the hotel has sold a room it doesn't have. Every operation reads counts, then// changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock// per hotel is plenty; a booking platform runs many hotels side by side, and they never// share a lock.public sealed class SharedHotel(Hotel hotel){ private readonly Lock _gate = new();
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { lock (_gate) return hotel.Reserve(guest, type, stay); } public Result Modify(string id, string type, Stay stay) { lock (_gate) return hotel.Modify(id, type, stay); } public Result Cancel(string id) { lock (_gate) return hotel.Cancel(id); } public Result CheckIn(string id) { lock (_gate) return hotel.CheckIn(id); } public Result CheckOut(string id) { lock (_gate) return hotel.CheckOut(id); } public int MarkNoShows() { lock (_gate) return hotel.MarkNoShows(); } public int Free(string type, DateOnly night) { lock (_gate) return hotel.Free(type, night); }}// The complete design, built from every stage on this page.
package hotelbooking
import ( "slices" "sync" "time")
// Room is a physical room, known by its number. Its type ("double", "suite") decides the// price and what's in it.type Room struct { Number, Type string}
// Stay is the nights from check-in up to, but not including, check-out: arriving on the 10th// and leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one// stay end on the day the next begins without a special case. Dates are midnight UTC values.type Stay struct { CheckIn, CheckOut time.Time}
func (s Stay) Valid() bool { return s.CheckIn.Before(s.CheckOut) }
func (s Stay) Overlaps(o Stay) bool { return s.CheckIn.Before(o.CheckOut) && o.CheckIn.Before(s.CheckOut)}
func (s Stay) Nights() []time.Time { var out []time.Time for n := s.CheckIn; n.Before(s.CheckOut); n = n.AddDate(0, 0, 1) { out = append(out, n) } return out}
// Inventory counts what the hotel sells: a type of room for a set of nights. For each type, it// knows how many rooms exist and how many are sold on each night.type Inventory struct { capacity map[string]int // type -> rooms sold map[string]map[time.Time]int // type -> night -> sold}
func NewInventory() *Inventory { return &Inventory{capacity: map[string]int{}, sold: map[string]map[time.Time]int{}}}
func (inv *Inventory) AddRoom(roomType string) { inv.capacity[roomType]++ }
func (inv *Inventory) Sells(roomType string) bool { _, ok := inv.capacity[roomType]; return ok }
func (inv *Inventory) Free(roomType string, night time.Time) int { return inv.capacity[roomType] - inv.sold[roomType][night]}
// Take is all or nothing: check every night, then take every night.func (inv *Inventory) Take(roomType string, nights []time.Time) bool { for _, n := range nights { if inv.Free(roomType, n) <= 0 { return false } } if inv.sold[roomType] == nil { inv.sold[roomType] = map[time.Time]int{} } for _, n := range nights { inv.sold[roomType][n]++ } return true}
// Give is the opposite of Take: these nights are on sale again.func (inv *Inventory) Give(roomType string, nights []time.Time) { for _, n := range nights { if inv.sold[roomType][n]--; inv.sold[roomType][n] == 0 { delete(inv.sold[roomType], n) } }}
// Status is a reservation's life. Cancelled and NoShow keep the record, with any fee charged.type Status int
// Result is what a request gets back. TooEarly and TooLate are about the day of check-in.type Result int
const ( OK Result = iota InvalidDates NoSuchType SoldOut NoSuchReservation WrongStatus TooEarly TooLate)
// RatePlan answers "what does one night of this type cost?". Prices change by night: weekends// cost more, and a hotel adjusts rates constantly. A stay is the sum of its nights.type RatePlan interface { NightlyRate(roomType string, night time.Time) int64 // in cents}
// StandardRates is one hotel's prices: a base rate per room type, and more for weekend nights.type StandardRates struct { Base map[string]int64}
// NightlyRate charges a quarter more for Friday and Saturday nights.func (s StandardRates) NightlyRate(roomType string, night time.Time) int64 { rate := s.Base[roomType] if d := night.Weekday(); d == time.Friday || d == time.Saturday { return rate * 5 / 4 } return rate}
// minus returns the nights in a that aren't in b.func minus(a, b []time.Time) []time.Time { var out []time.Time for _, n := range a { if !slices.ContainsFunc(b, n.Equal) { out = append(out, n) } } return out}
const ( Booked Status = iota CheckedIn CheckedOut Cancelled NoShow)
// Reservation is one guest's booking: a room type for a stay. The room itself is chosen at// check-in. Rates holds each night's price, fixed when booked.type Reservation struct { ID, Guest string Type string Stay Stay Rates []int64 Status Status Room *Room Fee int64 // charged to the card on file for a late cancellation or a no-show}
func (r *Reservation) Total() int64 { var sum int64 for _, x := range r.Rates { sum += x } return sum}
// CancellationPolicy sets the deadline for free cancellation. Hotels change it by season and// rate, so it's a policy the hotel asks.type CancellationPolicy interface { FeeFor(r *Reservation, today time.Time) int64 // in cents; 0 means free}
// StandardCancellation is free until two days before arrival; after that, the first night.type StandardCancellation struct{}
func (StandardCancellation) FeeFor(r *Reservation, today time.Time) int64 { if r.Stay.CheckIn.Sub(today) >= 48*time.Hour { return 0 } return r.Rates[0]}
// Hotel is where the front desk and the booking site both go. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.type Hotel struct { rooms []Room inventory *Inventory reservations map[string]*Reservation inHouse map[string]*Reservation rates RatePlan cancels CancellationPolicy ids func() string clock func() time.Time}
func NewHotel(rates RatePlan, cancels CancellationPolicy, ids func() string, clock func() time.Time) *Hotel { return &Hotel{inventory: NewInventory(), reservations: map[string]*Reservation{}, inHouse: map[string]*Reservation{}, rates: rates, cancels: cancels, ids: ids, clock: clock}}
func (h *Hotel) AddRoom(r Room) { h.rooms = append(h.rooms, r) h.inventory.AddRoom(r.Type)}
func (h *Hotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { if bad := h.check(roomType, stay); bad != OK { return bad, nil } if !h.inventory.Take(roomType, stay.Nights()) { return SoldOut, nil } r := &Reservation{ID: h.ids(), Guest: guest, Type: roomType, Stay: stay, Rates: h.quote(roomType, stay)} h.reservations[r.ID] = r return OK, r}
// Modify changes the dates or the room type. It takes the nights the new stay needs that the// old one doesn't already hold, and only then gives back the nights it no longer needs. If the// new nights aren't free, nothing has changed and the guest keeps the original booking. Moving// Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the// hotel is full on Wednesday.func (h *Hotel) Modify(id, newType string, newStay Stay) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } if bad := h.check(newType, newStay); bad != OK { return bad } oldNights, newNights := r.Stay.Nights(), newStay.Nights() toTake, toGive := newNights, oldNights if newType == r.Type { toTake, toGive = minus(newNights, oldNights), minus(oldNights, newNights) } if !h.inventory.Take(newType, toTake) { return SoldOut } h.inventory.Give(r.Type, toGive) r.Type, r.Stay, r.Rates = newType, newStay, h.quote(newType, newStay) return OK}
// CheckIn works only on a night of the stay. Any vacant room of the booked type will do: this// is where a reservation becomes a room.func (h *Hotel) CheckIn(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } today := h.clock() if today.Before(r.Stay.CheckIn) { return TooEarly } if !today.Before(r.Stay.CheckOut) { return TooLate } for i := range h.rooms { room := &h.rooms[i] if room.Type == r.Type && h.isVacant(*room, today) { r.Room = room r.Status = CheckedIn h.inHouse[room.Number] = r return OK } } panic("inventory says a " + r.Type + " is free, rooms disagree")}
// CheckOut on an early departure puts the unused nights back on sale.func (h *Hotel) CheckOut(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != CheckedIn { return WrongStatus } today := h.clock() delete(h.inHouse, r.Room.Number) r.Status = CheckedOut if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } return OK}
// Cancel puts the nights back on sale and lets the policy decide the fee. Cancelling twice// finds the reservation already cancelled, so the nights are given back once.func (h *Hotel) Cancel(id string) Result { r := h.reservations[id] if r == nil { return NoSuchReservation } if r.Status != Booked { return WrongStatus } r.Fee = h.cancels.FeeFor(r, h.clock()) r.Status = Cancelled h.inventory.Give(r.Type, r.Stay.Nights()) return OK}
// MarkNoShows runs each morning. A guest who was due yesterday or earlier and never arrived is// a no-show: they're charged the first night, and the nights from today on go back on sale so// the room can be sold tonight. Running it twice changes nothing the second time.func (h *Hotel) MarkNoShows() int { today := h.clock() marked := 0 for _, r := range h.reservations { if r.Status != Booked || !r.Stay.CheckIn.Before(today) { continue } r.Status = NoShow r.Fee = r.Rates[0] if today.Before(r.Stay.CheckOut) { h.inventory.Give(r.Type, Stay{today, r.Stay.CheckOut}.Nights()) } marked++ } return marked}
func (h *Hotel) Free(roomType string, night time.Time) int { return h.inventory.Free(roomType, night) }
// check is what every new or changed booking needs: real dates, not in the past, a type we// sell.func (h *Hotel) check(roomType string, stay Stay) Result { if !stay.Valid() || stay.CheckIn.Before(h.clock()) { return InvalidDates } if !h.inventory.Sells(roomType) { return NoSuchType } return OK}
// quote is each night's price, fixed now, so a later rate change doesn't touch this booking.func (h *Hotel) quote(roomType string, stay Stay) []int64 { var out []int64 for _, n := range stay.Nights() { out = append(out, h.rates.NightlyRate(roomType, n)) } return out}
// isVacant: free tonight if nobody is in it, or the guest in it leaves today.func (h *Hotel) isVacant(room Room, night time.Time) bool { r := h.inHouse[room.Number] return r == nil || !r.Stay.CheckOut.After(night)}
// SharedHotel exists because the website, a travel agency's feed and the front desk all book// the same hotel at once. Two requests for the last double on Friday both find// Free("double", friday) == 1 and both take it, and the hotel has sold a room it doesn't have.// Every operation reads counts, then changes them, so each must run alone. One hotel takes a// few bookings a minute, so one mutex per hotel is plenty; a booking platform runs many hotels// side by side, and they never share a lock.type SharedHotel struct { mu sync.Mutex hotel *Hotel}
func NewSharedHotel(h *Hotel) *SharedHotel { return &SharedHotel{hotel: h} }
func (s *SharedHotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Reserve(guest, roomType, stay)}
func (s *SharedHotel) Modify(id, roomType string, stay Stay) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Modify(id, roomType, stay)}
func (s *SharedHotel) Cancel(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Cancel(id)}
func (s *SharedHotel) CheckIn(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.CheckIn(id)}
func (s *SharedHotel) CheckOut(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.CheckOut(id)}
func (s *SharedHotel) MarkNoShows() int { s.mu.Lock() defer s.mu.Unlock() return s.hotel.MarkNoShows()}
func (s *SharedHotel) Free(roomType string, night time.Time) int { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Free(roomType, night)}// The complete design, built from every stage on this page.
// A date with no time and no time zone, counted in days since 1970-01-01. A hotel thinks in// nights in its own town, so a plain day number avoids every midnight-in-which-zone bug, and// "the next night" is just day + 1.type Day = number;
// A physical room, known by its number. Its type ("double", "suite") decides the price and// what's in it.interface Room { readonly number: string; readonly type: string;}
// The nights from check-in up to, but not including, check-out: arriving on the 10th and// leaving on the 12th uses the nights of the 10th and the 11th. Half-open ranges let one stay// end on the day the next begins without a special case.class Stay { constructor( readonly checkIn: Day, readonly checkOut: Day, ) {}
isValid(): boolean { return this.checkIn < this.checkOut; }
overlaps(o: Stay): boolean { return this.checkIn < o.checkOut && o.checkIn < this.checkOut; }
nights(): Day[] { const out: Day[] = []; for (let d = this.checkIn; d < this.checkOut; d++) out.push(d); return out; }}
// The front desk and the booking site both go through here. The inventory answers "is there a// room?"; actual rooms are given out only at check-in.class Hotel { private readonly rooms: Room[] = []; private readonly inventory = new Inventory(); private readonly reservations = new Map<string, Reservation>(); private readonly inHouse = new Map<string, Reservation>(); // room number -> guest in it
constructor( private readonly rates: RatePlan, private readonly cancellations: CancellationPolicy, private readonly ids: () => string, private readonly clock: () => Day, ) {}
addRoom(r: Room): void { this.rooms.push(r); this.inventory.addRoom(r.type); }
reserve(guest: string, type: string, stay: Stay): { result: Result; reservation?: Reservation } { const bad = this.check(type, stay); if (bad !== 'ok') return { result: bad }; if (!this.inventory.take(type, stay.nights())) return { result: 'sold-out' }; const r = new Reservation(this.ids(), guest, type, stay, this.quote(type, stay)); this.reservations.set(r.id, r); return { result: 'ok', reservation: r }; }
// Change the dates or the room type. Take the nights the new stay needs that the old one // doesn't already hold, and only then give back the nights it no longer needs. If the new // nights aren't free, nothing has changed and the guest keeps the original booking. Moving // Tuesday-Thursday to Wednesday-Friday needs only Thursday night, so it works even when the // hotel is full on Wednesday. modify(id: string, newType: string, newStay: Stay): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const bad = this.check(newType, newStay); if (bad !== 'ok') return bad; const sameType = newType === r.type; const oldNights = r.stay.nights(); const newNights = newStay.nights(); const toTake = sameType ? minus(newNights, oldNights) : newNights; const toGive = sameType ? minus(oldNights, newNights) : oldNights; if (!this.inventory.take(newType, toTake)) return 'sold-out'; this.inventory.give(r.type, toGive); r.type = newType; r.stay = newStay; r.rates = this.quote(newType, newStay); return 'ok'; }
// The room is chosen when the guest arrives, and any vacant room of the type will do. Every // guest in the house tonight holds one of tonight's sold rooms, and so does this guest, so // if tonight isn't oversold a vacant room exists. The error would mean the counts are // wrong, not bad luck. checkIn(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; const today = this.clock(); if (today < r.stay.checkIn) return 'too-early'; if (today >= r.stay.checkOut) return 'too-late'; const room = this.rooms.find((x) => x.type === r.type && this.isVacant(x, today)); if (!room) throw new Error(`inventory says a ${r.type} is free, rooms disagree`); r.room = room; r.status = 'checked-in'; this.inHouse.set(room.number, r); return 'ok'; }
// Leaving early puts the unused nights back on sale. checkOut(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'checked-in') return 'wrong-status'; const today = this.clock(); this.inHouse.delete(r.room!.number); r.status = 'checked-out'; // Leaving early gives the remaining nights back to sell. if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); return 'ok'; }
// The nights go back on sale and the policy decides the fee. Cancelling twice finds the // reservation already cancelled, so the nights are given back once. cancel(id: string): Result { const r = this.reservations.get(id); if (!r) return 'no-such-reservation'; if (r.status !== 'booked') return 'wrong-status'; r.fee = this.cancellations.feeFor(r, this.clock()); r.status = 'cancelled'; this.inventory.give(r.type, r.stay.nights()); return 'ok'; }
// Run each morning. A guest who was due yesterday or earlier and never arrived is a no-show: // they're charged the first night, and the nights from today on go back on sale so the // room can be sold tonight. Running it twice changes nothing the second time. markNoShows(): number { const today = this.clock(); let marked = 0; for (const r of this.reservations.values()) { if (r.status !== 'booked' || r.stay.checkIn >= today) continue; r.status = 'no-show'; r.fee = r.rates[0]; if (today < r.stay.checkOut) this.inventory.give(r.type, new Stay(today, r.stay.checkOut).nights()); marked++; } return marked; }
free(type: string, night: Day): number { return this.inventory.free(type, night); }
// What every new or changed booking needs: real dates, not in the past, a type we sell. private check(type: string, stay: Stay): Result { if (!stay.isValid() || stay.checkIn < this.clock()) return 'invalid-dates'; if (!this.inventory.sells(type)) return 'no-such-type'; return 'ok'; }
// Each night's price, fixed now, so a later rate change doesn't touch this booking. private quote(type: string, stay: Stay): number[] { return stay.nights().map((n) => this.rates.nightlyRate(type, n)); }
// A room is vacant tonight if nobody in it is staying tonight. A guest due out today may // still be packing; housekeeping has the room ready by the afternoon. private isVacant(room: Room, night: Day): boolean { const r = this.inHouse.get(room.number); return !r || r.stay.checkOut <= night; }}
// What the hotel sells is a type of room for a set of nights, so that is what it counts:// for each type, how many rooms exist and how many are sold on each night.class Inventory { private readonly capacity = new Map<string, number>(); // type -> rooms private readonly sold = new Map<string, Map<Day, number>>(); // type -> night -> sold
addRoom(type: string): void { this.capacity.set(type, (this.capacity.get(type) ?? 0) + 1); }
sells(type: string): boolean { return this.capacity.has(type); }
free(type: string, night: Day): number { return (this.capacity.get(type) ?? 0) - (this.sold.get(type)?.get(night) ?? 0); }
// All or nothing: check every night, then take every night. take(type: string, nights: Day[]): boolean { if (!nights.every((n) => this.free(type, n) > 0)) return false; let m = this.sold.get(type); if (!m) this.sold.set(type, (m = new Map())); for (const n of nights) m.set(n, (m.get(n) ?? 0) + 1); return true; }
// The opposite of take: these nights are on sale again. give(type: string, nights: Day[]): void { const m = this.sold.get(type)!; for (const n of nights) { const left = m.get(n)! - 1; if (left === 0) m.delete(n); // drop zeros else m.set(n, left); } }}
// A reservation's life. Cancelled and no-show keep the record, with any fee charged.type Status = 'booked' | 'checked-in' | 'checked-out' | 'cancelled' | 'no-show';
// One guest's booking: a room type for a stay. The room itself is chosen at check-in. The rates// hold each night's price, fixed when booked.class Reservation { status: Status = 'booked'; room: Room | null = null; fee = 0; // charged to the card on file for a late cancellation or a no-show
constructor( readonly id: string, readonly guest: string, public type: string, public stay: Stay, public rates: number[], ) {}
total(): number { return this.rates.reduce((a, b) => a + b, 0); }}
// What a request gets back. TooEarly and TooLate are about the day of check-in.type Result = | 'ok' | 'invalid-dates' | 'no-such-type' | 'sold-out' | 'no-such-reservation' | 'wrong-status' | 'too-early' | 'too-late';
// Prices change by night: weekends cost more, and a hotel adjusts rates constantly. The rate// plan answers "what does one night of this type cost?", and a stay is the sum of its nights.interface RatePlan { nightlyRate(type: string, night: Day): number; // in cents}
const FRIDAY = 5;
const SATURDAY = 6;
// Day 0 was a Thursday, so (day + 4) % 7 counts Sunday as 0, like Date.getUTCDay().function weekday(d: Day): number { return (d + 4) % 7;}
// One hotel's prices: a base rate per room type, and more for weekend nights.class StandardRates implements RatePlan { constructor(private readonly base: Map<string, number>) {}
// Friday and Saturday nights cost a quarter more. nightlyRate(type: string, night: Day): number { const rate = this.base.get(type)!; const d = weekday(night); return d === FRIDAY || d === SATURDAY ? (rate * 5) / 4 : rate; }}
// The nights in a that aren't in b.function minus(a: Day[], b: Day[]): Day[] { const drop = new Set(b); return a.filter((n) => !drop.has(n));}
// Hotels set the deadline for free cancellation, and change it by season and rate, so it's a// policy the hotel asks.interface CancellationPolicy { feeFor(r: Reservation, today: Day): number; // in cents; 0 means free}
// Free until two days before arrival; after that, the first night.class StandardCancellation implements CancellationPolicy { feeFor(r: Reservation, today: Day): number { return r.stay.checkIn - today >= 2 ? 0 : r.rates[0]; }}
// In one Node process Hotel needs no lock: reserve() checks and takes every night without an// await in between, so no other request can run in the middle. The race comes back when the// counts live in a database shared by many servers. Then "check every night, then take every// night" has to happen inside one transaction that holds the rows it read.interface Db { // Runs one statement and returns its rows, or how many rows it changed. query<T>(sql: string, ...args: unknown[]): Promise<T[]>; run(sql: string, ...args: unknown[]): Promise<number>; transaction<T>(work: (db: Db) => Promise<T>): Promise<T>;}
// room_nights has one row per hotel, type and night: (hotel_id, type, night, capacity, sold).async function reserveInDb(db: Db, hotelId: string, type: string, stay: Stay): Promise<Result> { const nights = stay.nights(); return db .transaction(async (tx) => { // Lock the nights in date order. Two bookings that overlap then queue on their first // shared night instead of each locking half and waiting for the other forever. await tx.query( `SELECT night FROM room_nights WHERE hotel_id = $1 AND type = $2 AND night = ANY($3) ORDER BY night FOR UPDATE`, hotelId, type, nights, ); // Only nights with a room left match, so a sold-out night isn't counted. const taken = await tx.run( `UPDATE room_nights SET sold = sold + 1 WHERE hotel_id = $1 AND type = $2 AND night = ANY($3) AND sold < capacity`, hotelId, type, nights, ); // All or nothing: if any night was full, throw so the transaction undoes the others. if (taken !== nights.length) throw new SoldOutError(); return 'ok' as const; }) .catch((e: unknown) => { if (e instanceof SoldOutError) return 'sold-out' as const; throw e; });}
class SoldOutError extends Error {}Three ideas hold it together:
- Nights are the unit. Stays, prices, availability and refunds are all lists of nights, and a half-open stay makes the list clear.
- The count is the promise. A reservation holds one of each night’s sold rooms of its type. The room is chosen at arrival, and the counting argument guarantees one is free. Choosing as late as possible is what fixed stage 1.
- Nights move one way at a time. Taking always happens before giving back, so a failed operation
leaves everything as it was. Rates and cancellation rules sit behind policies (Strategy), and
every channel calls the
Hotelclass, a simple front door (a facade).
Stress it
Section titled “Stress it”So far, one request runs at a time. But the hotel’s website, two travel agencies and the front desk all sell the same rooms.
Four channels, one last suite
Section titled “Four channels, one last suite”The hotel’s website, two travel agencies and the front desk all sell the last suite on Friday at the same moment. What stops it being sold four times?
Whether you name the race (each checks the count, each takes it), make check-and-take one step, and pick a sensible thing to lock. In a database answer, they listen for locking the nights in a fixed order, or a conditional update with a row count check.
Every operation reads counts and then changes them. Without protection, four requests ask “is a suite free on Friday?”, all see 1, and all add one to Friday’s count. The hotel has sold four suites it doesn’t have. Three guests will be sent to another hotel, at this hotel’s expense. A stay of several nights makes it worse: two requests can each take some of their nights before finding the other has the rest.
The clearly correct fix is a lock per hotel, and it’s the one we choose. Every operation runs
alone. Is that too slow? One hotel takes a few bookings a minute, so nobody waits in practice. A
platform with thousands of hotels runs one Hotel per hotel, and they never share a lock.
Why not lock each night instead? A stay covers several nights, so a booking would have to take several locks, always in the same order. That’s more rules for no real gain at a hotel’s traffic.
- Color key
- Facade
| Option | How | Correct? | Cost |
|---|---|---|---|
| 1. One lock per hotel (our choice in memory) | Every SharedHotel method holds the lock |
Yes | One server owns each hotel’s inventory |
| 2. Lock the night rows, in date order | SELECT ... ORDER BY night FOR UPDATE, then update |
Yes | The real answer with many servers |
| 3. One conditional update with a count check | UPDATE ... SET sold = sold + 1 WHERE sold < capacity; compare rows changed |
Yes, with rollback | Without ordered locks, overlapping stays can block each other forever |
| 4. A version number per night row | Update only where the version matches; retry on conflict | Yes | Retries pile up on a popular weekend |
| 5. Read the counts, then write | Two separate statements | No: the race above | None, which is why it ships by mistake |
// The website, a travel agency's feed and the front desk all book the same hotel at once. Two// requests for the last double on Friday both find free("double", Friday) == 1 and both take// it, and the hotel has sold a room it doesn't have. Every operation reads counts, then// changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock// per hotel is plenty; a booking platform runs many hotels side by side, and they never// share a lock.class SharedHotel { private final Hotel hotel; private final ReentrantLock lock = new ReentrantLock();
SharedHotel(Hotel hotel) { this.hotel = hotel; }
ReserveResult reserve(String guest, String type, Stay stay) { return locked(() -> hotel.reserve(guest, type, stay)); } Result modify(String id, String type, Stay stay) { return locked(() -> hotel.modify(id, type, stay)); } Result cancel(String id) { return locked(() -> hotel.cancel(id)); } Result checkIn(String id) { return locked(() -> hotel.checkIn(id)); } Result checkOut(String id) { return locked(() -> hotel.checkOut(id)); } int markNoShows() { return locked(hotel::markNoShows); } int free(String type, LocalDate night) { return locked(() -> hotel.free(type, night)); }
// Runs one operation with the lock held. finally releases it even if the operation throws. private <T> T locked(Supplier<T> op) { lock.lock(); try { return op.get(); } finally { lock.unlock(); } }}import threading
# The website, a travel agency's feed and the front desk all book the same hotel at once. Two# requests for the last double on Friday both find free("double", friday) == 1 and both take# it, and the hotel has sold a room it doesn't have. Every operation reads counts, then# changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock# per hotel is plenty; a booking platform runs many hotels side by side, and they never share# a lock.class SharedHotel: def __init__(self, hotel: Hotel): self._hotel = hotel self._lock = threading.Lock()
def reserve(self, guest: str, type: str, stay: Stay) -> tuple[Result, Reservation | None]: with self._lock: return self._hotel.reserve(guest, type, stay)
def modify(self, reservation_id: str, type: str, stay: Stay) -> Result: with self._lock: return self._hotel.modify(reservation_id, type, stay)
def cancel(self, reservation_id: str) -> Result: with self._lock: return self._hotel.cancel(reservation_id)
def check_in(self, reservation_id: str) -> Result: with self._lock: return self._hotel.check_in(reservation_id)
def check_out(self, reservation_id: str) -> Result: with self._lock: return self._hotel.check_out(reservation_id)
def mark_no_shows(self) -> int: with self._lock: return self._hotel.mark_no_shows()
def free(self, type: str, night: date) -> int: with self._lock: return self._hotel.free(type, night)// The website, a travel agency's feed and the front desk all book the same hotel at once. Two// requests for the last double on Friday both find Free("double", friday) == 1 and both take// it, and the hotel has sold a room it doesn't have. Every operation reads counts, then// changes them, so each must run alone. One hotel takes a few bookings a minute, so one lock// per hotel is plenty; a booking platform runs many hotels side by side, and they never// share a lock.public sealed class SharedHotel(Hotel hotel){ private readonly Lock _gate = new();
public (Result, Reservation?) Reserve(string guest, string type, Stay stay) { lock (_gate) return hotel.Reserve(guest, type, stay); } public Result Modify(string id, string type, Stay stay) { lock (_gate) return hotel.Modify(id, type, stay); } public Result Cancel(string id) { lock (_gate) return hotel.Cancel(id); } public Result CheckIn(string id) { lock (_gate) return hotel.CheckIn(id); } public Result CheckOut(string id) { lock (_gate) return hotel.CheckOut(id); } public int MarkNoShows() { lock (_gate) return hotel.MarkNoShows(); } public int Free(string type, DateOnly night) { lock (_gate) return hotel.Free(type, night); }}// SharedHotel exists because the website, a travel agency's feed and the front desk all book// the same hotel at once. Two requests for the last double on Friday both find// Free("double", friday) == 1 and both take it, and the hotel has sold a room it doesn't have.// Every operation reads counts, then changes them, so each must run alone. One hotel takes a// few bookings a minute, so one mutex per hotel is plenty; a booking platform runs many hotels// side by side, and they never share a lock.type SharedHotel struct { mu sync.Mutex hotel *Hotel}
func NewSharedHotel(h *Hotel) *SharedHotel { return &SharedHotel{hotel: h} }
func (s *SharedHotel) Reserve(guest, roomType string, stay Stay) (Result, *Reservation) { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Reserve(guest, roomType, stay)}
func (s *SharedHotel) Modify(id, roomType string, stay Stay) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Modify(id, roomType, stay)}
func (s *SharedHotel) Cancel(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Cancel(id)}
func (s *SharedHotel) CheckIn(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.CheckIn(id)}
func (s *SharedHotel) CheckOut(id string) Result { s.mu.Lock() defer s.mu.Unlock() return s.hotel.CheckOut(id)}
func (s *SharedHotel) MarkNoShows() int { s.mu.Lock() defer s.mu.Unlock() return s.hotel.MarkNoShows()}
func (s *SharedHotel) Free(roomType string, night time.Time) int { s.mu.Lock() defer s.mu.Unlock() return s.hotel.Free(roomType, night)}// In one Node process Hotel needs no lock: reserve() checks and takes every night without an// await in between, so no other request can run in the middle. The race comes back when the// counts live in a database shared by many servers. Then "check every night, then take every// night" has to happen inside one transaction that holds the rows it read.interface Db { // Runs one statement and returns its rows, or how many rows it changed. query<T>(sql: string, ...args: unknown[]): Promise<T[]>; run(sql: string, ...args: unknown[]): Promise<number>; transaction<T>(work: (db: Db) => Promise<T>): Promise<T>;}
// room_nights has one row per hotel, type and night: (hotel_id, type, night, capacity, sold).async function reserveInDb(db: Db, hotelId: string, type: string, stay: Stay): Promise<Result> { const nights = stay.nights(); return db .transaction(async (tx) => { // Lock the nights in date order. Two bookings that overlap then queue on their first // shared night instead of each locking half and waiting for the other forever. await tx.query( `SELECT night FROM room_nights WHERE hotel_id = $1 AND type = $2 AND night = ANY($3) ORDER BY night FOR UPDATE`, hotelId, type, nights, ); // Only nights with a room left match, so a sold-out night isn't counted. const taken = await tx.run( `UPDATE room_nights SET sold = sold + 1 WHERE hotel_id = $1 AND type = $2 AND night = ANY($3) AND sold < capacity`, hotelId, type, nights, ); // All or nothing: if any night was full, throw so the transaction undoes the others. if (taken !== nights.length) throw new SoldOutError(); return 'ok' as const; }) .catch((e: unknown) => { if (e instanceof SoldOutError) return 'sold-out' as const; throw e; });}
class SoldOutError extends Error {}Why is this correct? Every operation holds the hotel’s lock from its first check to its last change. So “is a suite free on Friday?” and “take it” happen together, and no other channel can act in between. That includes changes that take and give back nights of two types: one lock covers them all.
What does it cost? In-memory locks only work on one server. So each hotel must live on one server, or move to the database version. A huge resort on a holiday weekend might feel the single lock, and locking night rows in the database spreads that load.
Go deeperThe database version, step by step1 min
In TypeScript, the in-memory Hotel needs no lock. reserve checks and takes every night with no
await in between, so nothing else can run in the middle. So the TypeScript tab shows the database
version instead. That’s where the race comes back, once counts live in a table shared by many
servers.
It works in three steps. Lock the rows for the stay’s nights, in date order. Add one to each where a room is left. If fewer rows changed than nights requested, roll everything back.
Why date order? Picture two overlapping stays. One locks Tuesday and wants Wednesday. The other locks Wednesday and wants Tuesday. Each waits for the other forever. That’s a deadlock. If everyone locks nights from earliest to latest, that circle can’t form.
How you’d prove it works
Section titled “How you’d prove it works”The clock is a function the test controls, set to a Monday, so weekend prices and the two-day cancellation deadline land on known dates. The tests that matter:
- The four bookings from stage 1 are all accepted, and check-in gives Dee and Cy rooms.
- Back-to-back stays fit in one suite. Bad dates and unknown types are refused.
- Friday and Saturday nights are priced higher, night by night.
- Moving a stay by one night succeeds when only the new night is free.
- A change that doesn’t fit leaves the reservation and the counts unchanged.
- Cancelling early is free, late costs one night, and twice is refused.
- A no-show pays one night, its remaining nights go back on sale, and the job is safe to rerun.
- Leaving early returns the remaining nights.
- Sixteen threads booking the last suite produce exactly one booking.
Go deeperThe tests in five languages2 min
The first test protects the main decision. It replays the exact sequence that stage 1 refused, then walks the days through check-in. If someone brings back room assignment at booking time, Dee’s reservation fails.
LocalDate today = LocalDate.of(2026, 6, 1); // a MondayAtomicInteger nextId = new AtomicInteger();Hotel hotel;
static LocalDate june(int day) { return LocalDate.of(2026, 6, day); }
static Stay stay(int from, int to) { return new Stay(june(from), june(to)); }
// Two doubles and one suite; a double is 100.00 a night and a suite 200.00.@BeforeEachvoid setUp() { hotel = new Hotel(new StandardRates(Map.of("double", 10000L, "suite", 20000L)), new StandardCancellation(), () -> "r" + nextId.incrementAndGet(), () -> today); hotel.addRoom(new Room("101", "double")); hotel.addRoom(new Room("102", "double")); hotel.addRoom(new Room("201", "suite"));}
Reservation reserve(String guest, String type, Stay s) { ReserveResult r = hotel.reserve(guest, type, s); assertEquals(Result.OK, r.result()); return r.reservation();}
@Testvoid countingNightsSellsAStayThatFixedRoomsWouldRefuse() { Reservation ana = reserve("ana", "double", stay(8, 9)); Reservation ben = reserve("ben", "double", stay(8, 10)); Reservation cy = reserve("cy", "double", stay(10, 12)); Reservation dee = reserve("dee", "double", stay(9, 11)); // refused by stage 1's fixed rooms assertEquals(Result.SOLD_OUT, hotel.reserve("eve", "double", stay(9, 10)).result());
today = june(8); hotel.checkIn(ana.id); hotel.checkIn(ben.id); today = june(9); hotel.checkOut(ana.id); assertEquals(Result.OK, hotel.checkIn(dee.id)); assertEquals("101", dee.room.number()); // Ana's room, ready the day she leaves today = june(10); hotel.checkOut(ben.id); assertEquals(Result.OK, hotel.checkIn(cy.id)); assertEquals("102", cy.room.number());}
@Testvoid oneStayCanEndTheDayTheNextBegins() { reserve("ana", "suite", stay(8, 10)); reserve("ben", "suite", stay(10, 12));}
@Testvoid badRequestsAreRefused() { assertEquals(Result.INVALID_DATES, hotel.reserve("ana", "double", stay(10, 10)).result()); assertEquals(Result.INVALID_DATES, hotel.reserve("ana", "double", new Stay(LocalDate.of(2026, 5, 30), june(2))).result()); assertEquals(Result.NO_SUCH_TYPE, hotel.reserve("ana", "penthouse", stay(8, 9)).result());}
@Testvoid weekendNightsCostMoreAndThePriceIsQuotedPerNight() { assertEquals(25000, reserve("ana", "double", stay(12, 14)).total()); // Friday and Saturday assertEquals(10000, reserve("ben", "double", stay(11, 12)).total()); // Thursday}
@Testvoid movingAStayOnlyNeedsTheNightsItDoesNotAlreadyHold() { Reservation ana = reserve("ana", "suite", stay(9, 11)); // the only suite, Tuesday and Wednesday assertEquals(Result.OK, hotel.modify(ana.id, "suite", stay(10, 12))); // full on Wednesday, still fine assertEquals(1, hotel.free("suite", june(9))); assertEquals(0, hotel.free("suite", june(11)));}
@Testvoid aChangeThatDoesNotFitLeavesTheBookingAlone() { Reservation ana = reserve("ana", "double", stay(8, 10)); reserve("ben", "suite", stay(9, 10)); assertEquals(Result.SOLD_OUT, hotel.modify(ana.id, "suite", stay(8, 10))); assertEquals("double", ana.type); assertEquals(1, hotel.free("double", june(8)));}
@Testvoid cancellingIsFreeUntilTwoDaysBeforeThenCostsTheFirstNight() { Reservation early = reserve("ana", "double", stay(8, 10)); assertEquals(Result.OK, hotel.cancel(early.id)); assertEquals(0, early.fee); assertEquals(2, hotel.free("double", june(8)));
Reservation late = reserve("ben", "double", stay(2, 4)); assertEquals(Result.OK, hotel.cancel(late.id)); assertEquals(10000, late.fee); assertEquals(Result.WRONG_STATUS, hotel.cancel(late.id));}
@Testvoid aNoShowPaysOneNightAndTheRestGoesBackOnSale() { Reservation ana = reserve("ana", "suite", stay(2, 5)); today = june(3); assertEquals(1, hotel.markNoShows()); assertEquals(20000, ana.fee); assertEquals(1, hotel.free("suite", june(3))); assertEquals(0, hotel.markNoShows()); // running it again changes nothing}
@Testvoid leavingEarlyGivesTheRemainingNightsBack() { Reservation ana = reserve("ana", "suite", stay(2, 6)); today = june(2); hotel.checkIn(ana.id); today = june(4); hotel.checkOut(ana.id); assertEquals(1, hotel.free("suite", june(4))); assertEquals(1, hotel.free("suite", june(5)));}
@Testvoid racingRequestsForTheLastRoomSellItOnce() throws Exception { SharedHotel shared = new SharedHotel(hotel); ExecutorService pool = Executors.newFixedThreadPool(16); CountDownLatch start = new CountDownLatch(1); List<Future<Result>> results = new ArrayList<>(); for (int i = 0; i < 16; i++) { String guest = "g" + i; results.add(pool.submit(() -> { start.await(); return shared.reserve(guest, "suite", stay(12, 13)).result(); })); } start.countDown(); int ok = 0; for (Future<Result> f : results) if (f.get() == Result.OK) ok++; pool.shutdown(); assertEquals(1, ok); assertEquals(0, shared.free("suite", june(12)));}import itertoolsimport threadingfrom concurrent.futures import ThreadPoolExecutorfrom datetime import date
import pytest
def june(day: int) -> date: return date(2026, 6, day)
def stay(a: int, b: int) -> Stay: return Stay(june(a), june(b))
class World: # Two doubles and one suite; a double is 100.00 a night and a suite 200.00. def __init__(self): self.today = june(1) # a Monday counter = itertools.count(1) self.hotel = Hotel(StandardRates({"double": 10000, "suite": 20000}), StandardCancellation(), lambda: f"r{next(counter)}", lambda: self.today) for number, type in (("101", "double"), ("102", "double"), ("201", "suite")): self.hotel.add_room(Room(number, type))
def reserve(self, guest: str, type: str, s: Stay) -> Reservation: result, r = self.hotel.reserve(guest, type, s) assert result is Result.OK return r
@pytest.fixturedef w(): return World()
def test_counting_nights_sells_a_stay_that_fixed_rooms_would_refuse(w): ana = w.reserve("ana", "double", stay(8, 9)) ben = w.reserve("ben", "double", stay(8, 10)) cy = w.reserve("cy", "double", stay(10, 12)) dee = w.reserve("dee", "double", stay(9, 11)) # refused by stage 1's fixed rooms assert w.hotel.reserve("eve", "double", stay(9, 10))[0] is Result.SOLD_OUT
w.today = june(8) w.hotel.check_in(ana.id) w.hotel.check_in(ben.id) w.today = june(9) w.hotel.check_out(ana.id) assert w.hotel.check_in(dee.id) is Result.OK assert dee.room.number == "101" # Ana's room, ready the day she leaves w.today = june(10) w.hotel.check_out(ben.id) assert w.hotel.check_in(cy.id) is Result.OK assert cy.room.number == "102"
def test_one_stay_can_end_the_day_the_next_begins(w): w.reserve("ana", "suite", stay(8, 10)) w.reserve("ben", "suite", stay(10, 12))
def test_bad_requests_are_refused(w): assert w.hotel.reserve("ana", "double", stay(10, 10))[0] is Result.INVALID_DATES assert w.hotel.reserve("ana", "double", Stay(date(2026, 5, 30), june(2)))[0] is Result.INVALID_DATES assert w.hotel.reserve("ana", "penthouse", stay(8, 9))[0] is Result.NO_SUCH_TYPE
def test_weekend_nights_cost_more_and_the_price_is_quoted_per_night(w): assert w.reserve("ana", "double", stay(12, 14)).total() == 25000 # Friday and Saturday assert w.reserve("ben", "double", stay(11, 12)).total() == 10000 # Thursday
def test_moving_a_stay_only_needs_the_nights_it_does_not_already_hold(w): ana = w.reserve("ana", "suite", stay(9, 11)) # the only suite, Tuesday and Wednesday assert w.hotel.modify(ana.id, "suite", stay(10, 12)) is Result.OK # full on Wednesday, still fine assert w.hotel.free("suite", june(9)) == 1 assert w.hotel.free("suite", june(11)) == 0
def test_a_change_that_does_not_fit_leaves_the_booking_alone(w): ana = w.reserve("ana", "double", stay(8, 10)) w.reserve("ben", "suite", stay(9, 10)) assert w.hotel.modify(ana.id, "suite", stay(8, 10)) is Result.SOLD_OUT assert ana.type == "double" assert w.hotel.free("double", june(8)) == 1
def test_cancelling_is_free_until_two_days_before_then_costs_the_first_night(w): early = w.reserve("ana", "double", stay(8, 10)) assert w.hotel.cancel(early.id) is Result.OK assert early.fee == 0 assert w.hotel.free("double", june(8)) == 2
late = w.reserve("ben", "double", stay(2, 4)) assert w.hotel.cancel(late.id) is Result.OK assert late.fee == 10000 assert w.hotel.cancel(late.id) is Result.WRONG_STATUS
def test_a_no_show_pays_one_night_and_the_rest_goes_back_on_sale(w): ana = w.reserve("ana", "suite", stay(2, 5)) w.today = june(3) assert w.hotel.mark_no_shows() == 1 assert ana.fee == 20000 assert w.hotel.free("suite", june(3)) == 1 assert w.hotel.mark_no_shows() == 0 # running it again changes nothing
def test_leaving_early_gives_the_remaining_nights_back(w): ana = w.reserve("ana", "suite", stay(2, 6)) w.today = june(2) w.hotel.check_in(ana.id) w.today = june(4) w.hotel.check_out(ana.id) assert w.hotel.free("suite", june(4)) == 1 assert w.hotel.free("suite", june(5)) == 1
def test_racing_requests_for_the_last_room_sell_it_once(w): shared = SharedHotel(w.hotel) start = threading.Barrier(16)
def try_reserve(i: int) -> Result: start.wait() return shared.reserve(f"g{i}", "suite", stay(12, 13))[0]
with ThreadPoolExecutor(16) as pool: results = list(pool.map(try_reserve, range(16))) assert results.count(Result.OK) == 1 assert shared.free("suite", june(12)) == 0private static DateOnly June(int day) => new(2026, 6, day);private static Stay StayFor(int from, int to) => new(June(from), June(to));
private DateOnly _today = June(1); // a Mondayprivate readonly Hotel _hotel;private int _nextId;
// Two doubles and one suite; a double is 100.00 a night and a suite 200.00.public HotelTests(){ var rates = new StandardRates(new Dictionary<string, long> { ["double"] = 10000, ["suite"] = 20000 }); _hotel = new Hotel(rates, new StandardCancellation(), () => $"r{Interlocked.Increment(ref _nextId)}", () => _today); _hotel.AddRoom(new Room("101", "double")); _hotel.AddRoom(new Room("102", "double")); _hotel.AddRoom(new Room("201", "suite"));}
private Reservation Reserve(string guest, string type, Stay stay){ var (result, r) = _hotel.Reserve(guest, type, stay); Assert.Equal(Result.Ok, result); return r!;}
[Fact]public void CountingNightsSellsAStayThatFixedRoomsWouldRefuse(){ var ana = Reserve("ana", "double", StayFor(8, 9)); var ben = Reserve("ben", "double", StayFor(8, 10)); var cy = Reserve("cy", "double", StayFor(10, 12)); var dee = Reserve("dee", "double", StayFor(9, 11)); // refused by stage 1's fixed rooms Assert.Equal(Result.SoldOut, _hotel.Reserve("eve", "double", StayFor(9, 10)).Item1);
_today = June(8); _hotel.CheckIn(ana.Id); _hotel.CheckIn(ben.Id); _today = June(9); _hotel.CheckOut(ana.Id); Assert.Equal(Result.Ok, _hotel.CheckIn(dee.Id)); Assert.Equal("101", dee.Room!.Number); // Ana's room, ready the day she leaves _today = June(10); _hotel.CheckOut(ben.Id); Assert.Equal(Result.Ok, _hotel.CheckIn(cy.Id)); Assert.Equal("102", cy.Room!.Number);}
[Fact]public void OneStayCanEndTheDayTheNextBegins(){ Reserve("ana", "suite", StayFor(8, 10)); Reserve("ben", "suite", StayFor(10, 12));}
[Fact]public void BadRequestsAreRefused(){ Assert.Equal(Result.InvalidDates, _hotel.Reserve("ana", "double", StayFor(10, 10)).Item1); Assert.Equal(Result.InvalidDates, _hotel.Reserve("ana", "double", new Stay(new DateOnly(2026, 5, 30), June(2))).Item1); Assert.Equal(Result.NoSuchType, _hotel.Reserve("ana", "penthouse", StayFor(8, 9)).Item1);}
[Fact]public void WeekendNightsCostMoreAndThePriceIsQuotedPerNight(){ Assert.Equal(25000, Reserve("ana", "double", StayFor(12, 14)).Total); // Friday and Saturday Assert.Equal(10000, Reserve("ben", "double", StayFor(11, 12)).Total); // Thursday}
[Fact]public void MovingAStayOnlyNeedsTheNightsItDoesNotAlreadyHold(){ var ana = Reserve("ana", "suite", StayFor(9, 11)); // the only suite, Tuesday and Wednesday Assert.Equal(Result.Ok, _hotel.Modify(ana.Id, "suite", StayFor(10, 12))); Assert.Equal(1, _hotel.Free("suite", June(9))); Assert.Equal(0, _hotel.Free("suite", June(11)));}
[Fact]public void AChangeThatDoesNotFitLeavesTheBookingAlone(){ var ana = Reserve("ana", "double", StayFor(8, 10)); Reserve("ben", "suite", StayFor(9, 10)); Assert.Equal(Result.SoldOut, _hotel.Modify(ana.Id, "suite", StayFor(8, 10))); Assert.Equal("double", ana.Type); Assert.Equal(1, _hotel.Free("double", June(8)));}
[Fact]public void CancellingIsFreeUntilTwoDaysBeforeThenCostsTheFirstNight(){ var early = Reserve("ana", "double", StayFor(8, 10)); Assert.Equal(Result.Ok, _hotel.Cancel(early.Id)); Assert.Equal(0, early.Fee); Assert.Equal(2, _hotel.Free("double", June(8)));
var late = Reserve("ben", "double", StayFor(2, 4)); Assert.Equal(Result.Ok, _hotel.Cancel(late.Id)); Assert.Equal(10000, late.Fee); Assert.Equal(Result.WrongStatus, _hotel.Cancel(late.Id));}
[Fact]public void ANoShowPaysOneNightAndTheRestGoesBackOnSale(){ var ana = Reserve("ana", "suite", StayFor(2, 5)); _today = June(3); Assert.Equal(1, _hotel.MarkNoShows()); Assert.Equal(20000, ana.Fee); Assert.Equal(1, _hotel.Free("suite", June(3))); Assert.Equal(0, _hotel.MarkNoShows()); // running it again changes nothing}
[Fact]public void LeavingEarlyGivesTheRemainingNightsBack(){ var ana = Reserve("ana", "suite", StayFor(2, 6)); _today = June(2); _hotel.CheckIn(ana.Id); _today = June(4); _hotel.CheckOut(ana.Id); Assert.Equal(1, _hotel.Free("suite", June(4))); Assert.Equal(1, _hotel.Free("suite", June(5)));}
[Fact]public async Task RacingRequestsForTheLastRoomSellItOnce(){ var shared = new SharedHotel(_hotel); var start = new TaskCompletionSource(); var tries = Enumerable.Range(0, 16) .Select(i => Task.Run(async () => { await start.Task; return shared.Reserve($"g{i}", "suite", StayFor(12, 13)).Item1; })) .ToArray(); start.SetResult(); // release all sixteen at once var results = await Task.WhenAll(tries); Assert.Single(results, r => r == Result.Ok); Assert.Equal(0, shared.Free("suite", June(12)));}func june(day int) time.Time { return time.Date(2026, 6, day, 0, 0, 0, 0, time.UTC) }
func stay(a, b int) Stay { return Stay{june(a), june(b)} }
type world struct { mu sync.Mutex today time.Time hotel *Hotel}
// newWorld has two doubles and one suite; a double is 100.00 a night and a suite 200.00.func newWorld() *world { w := &world{today: june(1)} // a Monday var next atomic.Int64 ids := func() string { return fmt.Sprintf("r%d", next.Add(1)) } clock := func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.today } w.hotel = NewHotel(StandardRates{map[string]int64{"double": 10000, "suite": 20000}}, StandardCancellation{}, ids, clock) w.hotel.AddRoom(Room{"101", "double"}) w.hotel.AddRoom(Room{"102", "double"}) w.hotel.AddRoom(Room{"201", "suite"}) return w}
func (w *world) setDay(day int) { w.mu.Lock(); w.today = june(day); w.mu.Unlock() }
func (w *world) reserve(t *testing.T, guest, roomType string, s Stay) *Reservation { t.Helper() r, res := w.hotel.Reserve(guest, roomType, s) if r != OK { t.Fatalf("reserve %s: %v", guest, r) } return res}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestCountingNightsSellsAStayThatFixedRoomsWouldRefuse(t *testing.T) { w := newWorld() ana := w.reserve(t, "ana", "double", stay(8, 9)) ben := w.reserve(t, "ben", "double", stay(8, 10)) cy := w.reserve(t, "cy", "double", stay(10, 12)) dee := w.reserve(t, "dee", "double", stay(9, 11)) // refused by stage 1's fixed rooms r, _ := w.hotel.Reserve("eve", "double", stay(9, 10)) expect(t, SoldOut, r)
w.setDay(8) w.hotel.CheckIn(ana.ID) w.hotel.CheckIn(ben.ID) w.setDay(9) w.hotel.CheckOut(ana.ID) expect(t, OK, w.hotel.CheckIn(dee.ID)) expect(t, "101", dee.Room.Number) // Ana's room, ready the day she leaves w.setDay(10) w.hotel.CheckOut(ben.ID) expect(t, OK, w.hotel.CheckIn(cy.ID)) expect(t, "102", cy.Room.Number)}
func TestOneStayCanEndTheDayTheNextBegins(t *testing.T) { w := newWorld() w.reserve(t, "ana", "suite", stay(8, 10)) w.reserve(t, "ben", "suite", stay(10, 12))}
func TestBadRequestsAreRefused(t *testing.T) { w := newWorld() r, _ := w.hotel.Reserve("ana", "double", stay(10, 10)) expect(t, InvalidDates, r) r, _ = w.hotel.Reserve("ana", "double", Stay{time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC), june(2)}) expect(t, InvalidDates, r) r, _ = w.hotel.Reserve("ana", "penthouse", stay(8, 9)) expect(t, NoSuchType, r)}
func TestWeekendNightsCostMoreAndThePriceIsQuotedPerNight(t *testing.T) { w := newWorld() expect(t, int64(25000), w.reserve(t, "ana", "double", stay(12, 14)).Total()) // Friday and Saturday expect(t, int64(10000), w.reserve(t, "ben", "double", stay(11, 12)).Total()) // Thursday}
func TestMovingAStayOnlyNeedsTheNightsItDoesNotAlreadyHold(t *testing.T) { w := newWorld() ana := w.reserve(t, "ana", "suite", stay(9, 11)) // the only suite, Tuesday and Wednesday expect(t, OK, w.hotel.Modify(ana.ID, "suite", stay(10, 12))) expect(t, 1, w.hotel.Free("suite", june(9))) expect(t, 0, w.hotel.Free("suite", june(11)))}
func TestAChangeThatDoesNotFitLeavesTheBookingAlone(t *testing.T) { w := newWorld() ana := w.reserve(t, "ana", "double", stay(8, 10)) w.reserve(t, "ben", "suite", stay(9, 10)) expect(t, SoldOut, w.hotel.Modify(ana.ID, "suite", stay(8, 10))) expect(t, "double", ana.Type) expect(t, 1, w.hotel.Free("double", june(8)))}
func TestCancellingIsFreeUntilTwoDaysBeforeThenCostsTheFirstNight(t *testing.T) { w := newWorld() early := w.reserve(t, "ana", "double", stay(8, 10)) expect(t, OK, w.hotel.Cancel(early.ID)) expect(t, int64(0), early.Fee) expect(t, 2, w.hotel.Free("double", june(8)))
late := w.reserve(t, "ben", "double", stay(2, 4)) expect(t, OK, w.hotel.Cancel(late.ID)) expect(t, int64(10000), late.Fee) expect(t, WrongStatus, w.hotel.Cancel(late.ID))}
func TestANoShowPaysOneNightAndTheRestGoesBackOnSale(t *testing.T) { w := newWorld() ana := w.reserve(t, "ana", "suite", stay(2, 5)) w.setDay(3) expect(t, 1, w.hotel.MarkNoShows()) expect(t, int64(20000), ana.Fee) expect(t, 1, w.hotel.Free("suite", june(3))) expect(t, 0, w.hotel.MarkNoShows()) // running it again changes nothing}
func TestLeavingEarlyGivesTheRemainingNightsBack(t *testing.T) { w := newWorld() ana := w.reserve(t, "ana", "suite", stay(2, 6)) w.setDay(2) w.hotel.CheckIn(ana.ID) w.setDay(4) w.hotel.CheckOut(ana.ID) expect(t, 1, w.hotel.Free("suite", june(4))) expect(t, 1, w.hotel.Free("suite", june(5)))}
func TestRacingRequestsForTheLastRoomSellItOnce(t *testing.T) { w := newWorld() shared := NewSharedHotel(w.hotel) var ok atomic.Int32 var wg sync.WaitGroup start := make(chan struct{}) for i := range 16 { wg.Add(1) go func() { defer wg.Done() <-start if r, _ := shared.Reserve(fmt.Sprintf("g%d", i), "suite", stay(12, 13)); r == OK { ok.Add(1) } }() } close(start) wg.Wait() expect(t, int32(1), ok.Load()) expect(t, 0, shared.Free("suite", june(12)))}import { test } from 'node:test';import assert from 'node:assert/strict';
function june(day: number): Day { return Date.UTC(2026, 5, day) / 86_400_000;}
function stay(a: number, b: number): Stay { return new Stay(june(a), june(b));}
// Two doubles and one suite; a double is 100.00 a night and a suite 200.00. Today is Monday 1 June.function world() { let next = 0; const w = { today: june(1) } as { today: Day; hotel: Hotel }; const rates = new StandardRates( new Map([ ['double', 10000], ['suite', 20000], ]), ); w.hotel = new Hotel(rates, new StandardCancellation(), () => `r${++next}`, () => w.today); w.hotel.addRoom({ number: '101', type: 'double' }); w.hotel.addRoom({ number: '102', type: 'double' }); w.hotel.addRoom({ number: '201', type: 'suite' }); return w;}
function reserve(hotel: Hotel, guest: string, type: string, s: Stay): Reservation { const { result, reservation } = hotel.reserve(guest, type, s); assert.equal(result, 'ok'); return reservation!;}
test('counting nights sells a stay that fixed rooms would refuse', () => { const w = world(); const ana = reserve(w.hotel, 'ana', 'double', stay(8, 9)); const ben = reserve(w.hotel, 'ben', 'double', stay(8, 10)); const cy = reserve(w.hotel, 'cy', 'double', stay(10, 12)); const dee = reserve(w.hotel, 'dee', 'double', stay(9, 11)); // refused by stage 1's fixed rooms assert.equal(w.hotel.reserve('eve', 'double', stay(9, 10)).result, 'sold-out');
w.today = june(8); w.hotel.checkIn(ana.id); w.hotel.checkIn(ben.id); w.today = june(9); w.hotel.checkOut(ana.id); assert.equal(w.hotel.checkIn(dee.id), 'ok'); assert.equal(dee.room!.number, '101'); // Ana's room, ready the day she leaves w.today = june(10); w.hotel.checkOut(ben.id); assert.equal(w.hotel.checkIn(cy.id), 'ok'); assert.equal(cy.room!.number, '102');});
test('one stay can end the day the next begins', () => { const w = world(); reserve(w.hotel, 'ana', 'suite', stay(8, 10)); reserve(w.hotel, 'ben', 'suite', stay(10, 12));});
test('bad requests are refused', () => { const w = world(); assert.equal(w.hotel.reserve('ana', 'double', stay(10, 10)).result, 'invalid-dates'); assert.equal(w.hotel.reserve('ana', 'double', new Stay(june(1) - 2, june(2))).result, 'invalid-dates'); assert.equal(w.hotel.reserve('ana', 'penthouse', stay(8, 9)).result, 'no-such-type');});
test('weekend nights cost more and the price is quoted per night', () => { const w = world(); assert.equal(reserve(w.hotel, 'ana', 'double', stay(12, 14)).total(), 25000); // Friday and Saturday assert.equal(reserve(w.hotel, 'ben', 'double', stay(11, 12)).total(), 10000); // Thursday});
test('moving a stay only needs the nights it does not already hold', () => { const w = world(); const ana = reserve(w.hotel, 'ana', 'suite', stay(9, 11)); // the only suite, Tuesday and Wednesday assert.equal(w.hotel.modify(ana.id, 'suite', stay(10, 12)), 'ok'); assert.equal(w.hotel.free('suite', june(9)), 1); assert.equal(w.hotel.free('suite', june(11)), 0);});
test('a change that does not fit leaves the booking alone', () => { const w = world(); const ana = reserve(w.hotel, 'ana', 'double', stay(8, 10)); reserve(w.hotel, 'ben', 'suite', stay(9, 10)); assert.equal(w.hotel.modify(ana.id, 'suite', stay(8, 10)), 'sold-out'); assert.equal(ana.type, 'double'); assert.equal(w.hotel.free('double', june(8)), 1);});
test('cancelling is free until two days before, then costs the first night', () => { const w = world(); const early = reserve(w.hotel, 'ana', 'double', stay(8, 10)); assert.equal(w.hotel.cancel(early.id), 'ok'); assert.equal(early.fee, 0); assert.equal(w.hotel.free('double', june(8)), 2);
const late = reserve(w.hotel, 'ben', 'double', stay(2, 4)); assert.equal(w.hotel.cancel(late.id), 'ok'); assert.equal(late.fee, 10000); assert.equal(w.hotel.cancel(late.id), 'wrong-status');});
test('a no-show pays one night and the rest goes back on sale', () => { const w = world(); const ana = reserve(w.hotel, 'ana', 'suite', stay(2, 5)); w.today = june(3); assert.equal(w.hotel.markNoShows(), 1); assert.equal(ana.fee, 20000); assert.equal(w.hotel.free('suite', june(3)), 1); assert.equal(w.hotel.markNoShows(), 0); // running it again changes nothing});
test('leaving early gives the remaining nights back', () => { const w = world(); const ana = reserve(w.hotel, 'ana', 'suite', stay(2, 6)); w.today = june(2); w.hotel.checkIn(ana.id); w.today = june(4); w.hotel.checkOut(ana.id); assert.equal(w.hotel.free('suite', june(4)), 1); assert.equal(w.hotel.free('suite', june(5)), 1);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Check-out on or before check-in | INVALID_DATES |
Hotel.check |
| Check-in in the past | INVALID_DATES |
Hotel.check |
| Unknown room type | NO_SUCH_TYPE |
Hotel.check |
| One night of several is full | SOLD_OUT, no night taken |
Inventory.take |
| Back-to-back stays in one room | Allowed: the stays share no night | Stay.nights |
| Change that doesn’t fit | SOLD_OUT, original booking untouched |
Hotel.modify |
| Change that only adds one night | Only that night is taken | Hotel.modify |
| Cancel twice | WRONG_STATUS, nights given back once |
Hotel.cancel |
| Check in before arrival, or after departure | TOO_EARLY or TOO_LATE |
Hotel.checkIn |
| Guest never arrives | Morning job: one night’s fee, nights from today back on sale | Hotel.markNoShows |
| Guest leaves early | Remaining nights back on sale | Hotel.checkOut |
| Previous guest hasn’t left yet at check-in | Their room counts as empty tonight | Hotel.isVacant |
| A room is out of order | Lower the type’s capacity for those nights | an extension |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Date ranges | Half-open stays, a list of nights | Nothing; it removes an off-by-one error |
| What is sold | Room types per night, all nights or none | A room number promised at booking |
| When a room is chosen | At check-in, any empty room of the type | Room numbers in confirmation emails |
| Prices | A rate plan, quoted per night and stored | A rate change doesn’t reach existing bookings |
| Changes | Take the difference, then give back | A little more code than cancel and rebook |
| Endings | Every ending gives back exactly the nights it won’t use | A morning job for no-shows |
| The last room | One lock per hotel; ordered row locks in the database | Each hotel’s inventory lives in one place |
Go deeperGenuinely different designs, and when to pick them2 min
Assign rooms at booking, and reshuffle. Some hotel systems assign rooms early, so a guest can be told their room number. They run an optimizer each night that moves future bookings between rooms, to close gaps. It works, but the optimizer is the hard part. And a guest promised room 101 can’t be moved. Counting per type gets the same capacity with no optimizer.
One row per room per night. Instead of counts, mark each room-night as sold, choosing the room at booking. It makes “who is in 101 on Tuesday?” a quick lookup, but it brings back the gaps unless rooms are reassigned before arrival. Counts with rooms chosen at check-in avoid both problems.
A channel manager in front. Real hotels sell through many websites that each keep a copy of the availability, updated every few minutes. The hotel’s own inventory stays the truth. A booking that arrives for a night that just sold out is refused, or overbooked by policy. The design here is the inventory those copies are updated from.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Room type or room, one hotel, half-open stays, changes and cancellations.
- 3 to 5. The promises. Name “no night oversold” as the hard one.
- 5 to 25. The four-booking example, counts per night with rooms at check-in, prices per night
and
modify, cancellations and no-shows. CodeInventory.take,modifyandcheckIn, and describe the policies. - 25 to 27. Walk through the full diagram: nights are the unit, the count is the promise.
- 27 to 33. The last suite: a lock per hotel, and the database version with ordered locks.
- 33 to 45. Out-of-order rooms or overbooking, then your own questions.
If time is short, skip no-shows and spend the time on stage 2’s check-in argument and on modify.
Those two are where this question separates candidates.
Follow-up questions
Section titled “Follow-up questions”A room has a burst pipe and is out of order for a week. What changes?
Whether you see that capacity is per night too, and what to do when nights are already sold beyond the new capacity. Deleting the room breaks the reservations that counted on it.
Most extensions fit into one place:
| Extension | What changes | Touches the counts? |
|---|---|---|
| Out-of-order rooms | Capacity per night, not per type | Yes, capacity only |
| Deliberate overbooking | Capacity is rooms plus an allowance based on past no-shows | Capacity only |
| Holding a room while paying | A reservation with a deadline, as in the movie booking | No |
| Minimum stay on weekends | check refuses a one-night Saturday arrival |
No |
| Prices that follow demand | A different RatePlan, maybe one that reads how full the night is |
No |
| Room preferences (high floor) | A preference on the reservation that check-in tries first | No |
| A specific room sold on its own | Its own type with a capacity of one | No |
Go deeperHandling an out-of-order room, step by step2 min
Today, capacity is one number per type: “8 doubles”. A burst pipe in room 104 for a week means there are only 7 doubles on those seven nights, and 8 again after.
- Capacity becomes per night. The inventory keeps a capacity for each type and night, with the usual number as the default. Taking room 104 out lowers capacity by one on each night of the repair.
- Check for oversold nights. If any of those nights already had 8 doubles sold, it’s now oversold by one. The inventory shows this the moment the room is taken out, not on the evening a guest arrives to find no room.
- The hotel fixes it, using tools it already has. It can upgrade one guest to a suite. That’s
just a
modifyto a type with space, using the same “take the new nights first” rule. Or it can move a guest to a partner hotel. Either way, the count goes back to 7. - Check-in skips room 104. It’s simply not offered as an empty room during the repair. The counting argument still holds, because the capacity already went down.
Notice what didn’t change: reservations, prices, the check-in rule, and the lock. One number became a number per night.
Go deeperAdding deliberate overbooking, step by step2 min
Airlines and hotels both sell a little more than they have, on purpose. If one guest in twenty never turns up, a full hotel of 100 rooms has 5 empty rooms every night. Selling 103 rooms fills most of them. This sounds like it breaks our main promise, so let’s be careful about what changes.
- Capacity gets an allowance. For each type and night, capacity becomes “rooms that exist plus an allowance”. The allowance comes from history: how many guests usually don’t arrive on that kind of night. A revenue manager sets it, so it’s a policy, not a number in the code.
- The booking code doesn’t change at all. It still checks “is the count below capacity?” and takes the night. It just sees a bigger capacity.
- The counting argument changes, honestly. At check-in, an empty room is now only guaranteed if
enough guests didn’t arrive. So check-in can fail, and it becomes a normal answer, not an error:
NO_ROOM. The front desk then upgrades the guest with amodify, or moves them to a partner hotel, at the hotel’s cost. - Keep the two kinds of capacity apart. The inventory must still know the real number of rooms. Then a report can show “tonight we sold 103 against 100 rooms”. Mixing them up is how hotels lose track of how much risk they took.
The main promise becomes “never sell beyond capacity”, and the hotel chooses what capacity means. The lock, the counts and the change logic all stay as they were.
Go deeperFour more follow-up questions2 min
Interviewer asksHow do you show availability for a whole month quickly?
Ask the inventory for each night’s free count, which is one lookup per night, so a month is thirty lookups per type. For a search across many hotels, a platform keeps a ready-made “lowest free count over the stay” index, updated as bookings change. The booking itself still checks the real counts.
Interviewer asksWhy does check-in pick any empty room, not the best one?
It can pick the best one: the argument only needs one to exist. Preferences (a quiet floor, near the lift, a room ready early) just sort the empty rooms. None of them can make check-in fail, because any choice leaves the remaining nights with enough rooms.
Interviewer asksWhat if a guest stays past their check-out date?
The hotel treats it as a new night: a modify that adds it, which takes the night if one is free.
If tonight is full, the front desk knows before the next guest arrives. The counting argument
still holds, because the extra night was sold like any other.
Interviewer asksWhy is a no-show charged one night, not the whole stay?
It’s the usual hotel policy, and it’s a policy here too. The nights from today on go back on sale, so the hotel can still earn them. Charging for them as well would charge twice for a room someone else may sleep in tonight.
Further reading
Section titled “Further reading”- Strategy pattern, State pattern
- Design a movie ticket booking system, seats sold per show with holds that expire
- Design a library management system, copies lent by count with a hold queue
- Interval graph, the theory behind why rooms always fit at check-in: stays as intervals, colored greedily by arrival
- PostgreSQL: explicit locking, row locks and how deadlocks arise
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.