Design Hotel Management
A hotel is a wall of keys behind the front desk. Each key hangs on a hook with a room number painted underneath, and at any moment a hook is one of two things — a key still hanging there means the room is open, a card slipped behind the hook with a guest name and a number of nights left means the room is taken. The clerk never has to walk the halls to know what is for sale. He turns around, looks at the wall, and answers. Every booking, every checkout, every passing night is a hand reaching for a hook and rearranging what hangs there.

Property management systems used to be that wall and nothing else. Conrad Hilton ran his Mobley hotel in Cisco, Texas in 1919 with a key board, a paper ledger, and a single shift manager who walked the wall at midnight to count what was still hanging. The first computer system that replaced the board was HOLIDEX at Holiday Inn in 1965 — an IBM mainframe over leased phone lines so a clerk in Memphis could see whether room 412 in Atlanta was open before promising it to a caller. The data model HOLIDEX used was the wall, written out as records. One row per room, one column for the current guest, one column for nights remaining. Every hotel system shipped since then is a fancier wall.
The room is the smallest noun, so it gets its own type first. A room has a number, a kind, a price per night, and a state that is either Free or Booked. Booked is not a flag — it is the variant that carries the guest name and the count of nights still to run. A free room cannot have a guest. A booked room cannot be without one. The type makes the illegal combination impossible.
#[derive(Clone)]
enum RoomKind {
Single,
Double,
Suite,
}
#[derive(Clone)]
enum RoomState {
Free,
Booked { guest: String, nights_remaining: u32 },
}
struct Room {
number: u32,
kind: RoomKind,
price_per_night: u32,
state: RoomState,
}
struct Hotel {
rooms: Vec<Room>,
revenue: u32,
}
enum Event<'a> {
Book { room: u32, guest: &'a str, nights: u32 },
Checkout { room: u32 },
Tick,
}RoomState is the move that pays back the most. A naive design would give every room a guest: Option<String> and a nights_remaining: u32 sitting side by side, and nothing would stop a caller from writing guest = None while leaving nights_remaining = 5 — a ghost staying past checkout. The enum welds the two facts into one variant so the wall cannot lie. RoomKind is the smaller version of the same idea for the room's physical shape, and price_per_night lives outside the state because the room costs the same whether or not anyone is in it.
The hotel itself is a list of those rooms plus a running revenue counter. The Event enum is the list of things the front desk can do. Book takes a room number, a guest name, and a nights count. Checkout takes a room number. Tick takes nothing — it is the clock advancing by one night, which is the part that makes the system feel alive without ever calling SystemTime::now. The lesson stays deterministic because the only clock is a function call you make on purpose.

Every change to the hotel goes through one method. The shape is the same as the ATM lesson, but the table has three columns instead of one — Book, Checkout, Tick — and the row is the current state of the specific room each event names.
impl Hotel {
fn new() -> Self {
Self {
rooms: vec![
Room { number: 101, kind: RoomKind::Single, price_per_night: 80, state: RoomState::Free },
Room { number: 102, kind: RoomKind::Single, price_per_night: 80, state: RoomState::Free },
Room { number: 201, kind: RoomKind::Double, price_per_night: 140, state: RoomState::Free },
Room { number: 301, kind: RoomKind::Suite, price_per_night: 260, state: RoomState::Free },
],
revenue: 0,
}
}
fn find(&mut self, number: u32) -> Option<&mut Room> {
self.rooms.iter_mut().find(|r| r.number == number)
}
fn handle(&mut self, event: &Event) -> Result<String, String> {
match event {
Event::Book { room, guest, nights } => {
let n = *nights;
if n == 0 {
return Err("nights must be at least 1".into());
}
let target = self.find(*room).ok_or_else(|| format!("no room {room}"))?;
match &target.state {
RoomState::Booked { guest: who, .. } => {
Err(format!("room {room} already booked by {who}"))
}
RoomState::Free => {
let charge = target.price_per_night * n;
target.state = RoomState::Booked {
guest: (*guest).to_string(),
nights_remaining: n,
};
self.revenue += charge;
Ok(format!("booked room {room} for {guest} ({n} nights, ${charge})"))
}
}
}
Event::Checkout { room } => {
let target = self.find(*room).ok_or_else(|| format!("no room {room}"))?;
match &target.state {
RoomState::Free => Err(format!("room {room} already free")),
RoomState::Booked { guest, .. } => {
let who = guest.clone();
target.state = RoomState::Free;
Ok(format!("checked out {who} from room {room}"))
}
}
}
Event::Tick => {
let mut freed = Vec::new();
for room in self.rooms.iter_mut() {
if let RoomState::Booked { guest, nights_remaining } = &mut room.state {
*nights_remaining -= 1;
if *nights_remaining == 0 {
freed.push((room.number, guest.clone()));
room.state = RoomState::Free;
}
}
}
if freed.is_empty() {
Ok("tick: one night passed".into())
} else {
let names: Vec<String> = freed
.iter()
.map(|(n, g)| format!("{g} from {n}"))
.collect();
Ok(format!("tick: one night passed, auto-freed {}", names.join(", ")))
}
}
}
}
}Read Book first. It refuses a 0-night booking, refuses an unknown room number, and refuses a room that is already taken — the overbooking check is the one a paper ledger gets wrong every weekend and the one the wall gets right every time. If all three checks pass, it computes the charge, flips the state to Booked with the guest name and nights, and adds the charge to revenue. The revenue lives on the hotel, not the room, because revenue is a fact about the business and a room being booked is a fact about the room.
Checkout is the shorter cousin. It refuses an unknown room, refuses a room that was already free, and otherwise clones the guest name out of the booked variant before flipping the room back to Free. The clone is here because Rust will not let you move a string out of an enum variant while you still need the rest of the variant to live, and the cheapest workaround is to pay one allocation and keep the borrow checker happy.
Tick is the most interesting one because it is the only event that touches every room. It walks the list, decrements the nights remaining on each booked room, and the moment a counter hits 0 it adds the freed room to a small list and flips the state to Free. The freed list is what the message at the end prints — auto-freed Maya from 201 is the hotel telling you a guest's last night just ended and the room is back on the wall. A real PMS does the same thing every night at 11 a.m., except the trigger is a cron job instead of a function call.
fn kind_label(k: &RoomKind) -> &'static str {
match k {
RoomKind::Single => "Single",
RoomKind::Double => "Double",
RoomKind::Suite => "Suite ",
}
}
fn render(hotel: &Hotel) -> String {
let mut out = String::new();
out.push_str(" room | kind | $/night | status\n");
out.push_str(" -----+--------+---------+-----------------------------\n");
for room in &hotel.rooms {
let status = match &room.state {
RoomState::Free => "Free".to_string(),
RoomState::Booked { guest, nights_remaining } => {
format!("Booked by {guest} ({nights_remaining} left)")
}
};
out.push_str(&format!(
" {:3} | {} | {:>7} | {}\n",
room.number,
kind_label(&room.kind),
format!("${}", room.price_per_night),
status,
));
}
out.push_str(&format!(" revenue: ${}\n", hotel.revenue));
out
}The render is the wall printed as text. One row per room, columns for number and kind and price and current status. The status field is where the enum match earns its keep — Free becomes the string "Free", Booked becomes "Booked by Maya (1 left)", and the renderer is the only place that knows how to spell either one. Every other part of the program asks the hotel for its state and never builds a status string of its own.
Now drive it through a scripted night and watch the wall change.
fn show_run() {
let events = [
Event::Book { room: 101, guest: "Aarit", nights: 2 },
Event::Book { room: 201, guest: "Maya", nights: 1 },
Event::Book { room: 201, guest: "Leo", nights: 3 },
Event::Tick,
Event::Checkout { room: 101 },
Event::Tick,
];
let mut hotel = Hotel::new();
println!("--- opening board ---");
print!("{}", render(&hotel));
println!();
for (i, event) in events.iter().enumerate() {
let label = describe(event);
let result = hotel.handle(event);
let outcome = match result {
Ok(msg) => format!("ok: {msg}"),
Err(msg) => format!("err: {msg}"),
};
println!("event {}: {label}", i + 1);
println!(" {outcome}");
print!("{}", render(&hotel));
println!();
}
}
fn describe(event: &Event) -> String {
match event {
Event::Book { room, guest, nights } => {
format!("Book {{ room: {room}, guest: {guest:?}, nights: {nights} }}")
}
Event::Checkout { room } => format!("Checkout {{ room: {room} }}"),
Event::Tick => "Tick".to_string(),
}
}The script is six events. Book Aarit into 101 for two nights. Book Maya into 201 for one night. Try to overbook 201 with Leo — the hotel should refuse and the wall should not change. Tick the clock once — Aarit's counter drops from 2 to 1, Maya's drops from 1 to 0 and auto-frees, revenue stays put because revenue was charged at booking time. Check Aarit out of 101 a night early. Tick the clock one more time on an empty hotel and confirm nothing crashes.
--- opening board ---
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Free
102 | Single | $80 | Free
201 | Double | $140 | Free
301 | Suite | $260 | Free
revenue: $0
event 1: Book { room: 101, guest: "Aarit", nights: 2 }
ok: booked room 101 for Aarit (2 nights, $160)
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Booked by Aarit (2 left)
102 | Single | $80 | Free
201 | Double | $140 | Free
301 | Suite | $260 | Free
revenue: $160
event 2: Book { room: 201, guest: "Maya", nights: 1 }
ok: booked room 201 for Maya (1 nights, $140)
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Booked by Aarit (2 left)
102 | Single | $80 | Free
201 | Double | $140 | Booked by Maya (1 left)
301 | Suite | $260 | Free
revenue: $300
event 3: Book { room: 201, guest: "Leo", nights: 3 }
err: room 201 already booked by Maya
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Booked by Aarit (2 left)
102 | Single | $80 | Free
201 | Double | $140 | Booked by Maya (1 left)
301 | Suite | $260 | Free
revenue: $300
event 4: Tick
ok: tick: one night passed, auto-freed Maya from 201
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Booked by Aarit (1 left)
102 | Single | $80 | Free
201 | Double | $140 | Free
301 | Suite | $260 | Free
revenue: $300
event 5: Checkout { room: 101 }
ok: checked out Aarit from room 101
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Free
102 | Single | $80 | Free
201 | Double | $140 | Free
301 | Suite | $260 | Free
revenue: $300
event 6: Tick
ok: tick: one night passed
room | kind | $/night | status
-----+--------+---------+-----------------------------
101 | Single | $80 | Free
102 | Single | $80 | Free
201 | Double | $140 | Free
301 | Suite | $260 | Free
revenue: $300Read the output as a story. The opening board has all four rooms Free and revenue at $0. Event 1 books Aarit and revenue jumps to $160 — two nights at $80. Event 2 books Maya and revenue jumps to $300 — one night at $140. Event 3 is the one to watch — err: room 201 already booked by Maya and the board on the next four lines is byte-for-byte the same as the one before it. The overbook attempt never touched the wall. Event 4 is the tick that auto-frees 201 because Maya's counter rolled from 1 to 0, and the message names her on the way out. Event 5 checks Aarit out early and the wall flips 101 back to Free. Event 6 ticks an empty hotel and the message reads tick: one night passed with no names because no rooms had booked counters to touch.
One question worth asking — why does revenue not drop when Aarit checks out a night early? The hotel charged him for two nights when he booked, the same way a real hotel would, and the early checkout is on him to dispute. If the policy were different, the change would live in one place — the Checkout arm of handle — and every other part of the program would keep working without edits. That is what the centralized table buys you.
The thing this design cannot do is hold more than today. The Tick event advances time by one night, but the hotel does not know what date it is, cannot accept a booking for next Tuesday, and cannot show a calendar grid of who arrives when. The next bottleneck is reservations against a date, which is what a hotel actually sells — and which is why the next design lesson moves from "is this room free right now" to "is this room free between these two dates."