Loading
Loading
Modelling Case Studies
Searching availability by night, holding inventory while a guest pays, and cancellation policies that cannot change after the fact.
By the end of this chapter
Guests search a hotel for available rooms between two dates, choose a room type, hold it while they enter payment details, and confirm. They may cancel later and receive a refund according to the cancellation terms they booked under. Staff check guests in, assign rooms and check them out. Rate management, housekeeping and loyalty schemes are out of scope.
| Entity | Is the only thing that knows |
|---|---|
| Hotel | Its room types, rooms and local time zone |
| RoomType | A category (Deluxe King) and how many rooms of it exist |
| Room | One physical room's number and condition |
| Stay | A check-in and check-out date — an immutable value |
| NightInventory | For one room type and one night: total, held and booked counts |
| Reservation | One guest's claim on a room type for a stay, its status and agreed terms |
| CancellationPolicy | How much is refunded when a booking is cancelled at a given time |
Concept
A guest does not care whether they get room 412 or 415, only that it is a Deluxe King. Booking against the room type, and assigning a room at check-in, lets the hotel fill gaps that room-level booking would leave empty.
Before and after
Book room 412 for Mon–Wed
Book DELUXE for Mon–Wed
public record Stay(LocalDate checkIn, LocalDate checkOut) { public Stay { if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("a stay is at least one night"); } public long nights() { return ChronoUnit.DAYS.between(checkIn, checkOut); } /** Half-open: [checkIn, checkOut). Back-to-back stays do not overlap. */ public boolean overlaps(Stay other) { return checkIn.isBefore(other.checkOut) && other.checkIn.isBefore(checkOut); } public Stream<LocalDate> eachNight() { return checkIn.datesUntil(checkOut); }}Availability questions are per night: is there a Deluxe King free on the 12th, the 13th and the 14th? So store one inventory row per room type per night, holding the total and how many are held or booked. Available is total minus held minus booked. A three-night stay is bookable only if all three rows have room, and it must take all three or none.
-- One row per room type per night: total, held, booked.UPDATE room_inventory SET held = held + 1 WHERE room_type_id = 7 AND night >= DATE '2026-11-12' AND night < DATE '2026-11-15' AND held + booked < total; -- Expect 3 rows (one per night). Fewer means a night is full: ROLLBACK.final class InventoryService { @Transactional Reservation hold(RoomTypeId type, Stay stay, GuestId guest, CancellationPolicy terms, Money price) { int updated = inventory.incrementHeldIfAvailable(type, stay); // the UPDATE above if (updated != stay.nights()) throw new SoldOut(type, stay); // exception rolls back every night Instant expiresAt = clock.instant().plus(HOLD_DURATION); return reservations.save(Reservation.held(type, stay, guest, terms, price, expiresAt)); }}Entering card details takes minutes. The guest needs the room kept for them during that time, but a database lock must last milliseconds. So the hold is the reservation's first state: inventory is counted as held, and the reservation carries an expiry. Confirming moves held to booked; expiry releases it.
| From | Event | To | Inventory effect |
|---|---|---|---|
| — | Guest holds a stay | HELD | held + 1 on each night |
| HELD | Payment confirmed before expiry | CONFIRMED | held − 1, booked + 1 |
| HELD | Expiry passes | EXPIRED | held − 1 |
| CONFIRMED | Guest cancels | CANCELLED | booked − 1; refund per policy |
| CONFIRMED | Guest arrives | CHECKED_IN | Room assigned |
| CONFIRMED | Check-in day ends without arrival | NO_SHOW | Charged per policy |
| CHECKED_IN | Guest leaves | CHECKED_OUT |
final class Reservation { private final ReservationId id; private Status status; private final Instant holdExpiresAt; private final Money price; // agreed at booking private final CancellationPolicy terms; // agreed at booking private PaymentReference payment; void confirm(PaymentReference payment, Instant now) { if (status != Status.HELD) throw new IllegalStateException("cannot confirm a " + status + " reservation"); if (!now.isBefore(holdExpiresAt)) throw new HoldExpired(id); // a sweeper releases the inventory this.payment = payment; this.status = Status.CONFIRMED; } Money cancel(ZonedDateTime checkInAt, ZonedDateTime now) { if (status != Status.CONFIRMED) throw new IllegalStateException("cannot cancel a " + status + " reservation"); status = Status.CANCELLED; return terms.refund(price, checkInAt, now); }}interface CancellationPolicy { Money refund(Money paid, ZonedDateTime checkInAt, ZonedDateTime cancelledAt);} final class FreeUntil implements CancellationPolicy { private final Duration notice; // e.g. 48 hours public Money refund(Money paid, ZonedDateTime checkInAt, ZonedDateTime at) { return at.isBefore(checkInAt.minus(notice)) ? paid : Money.zero(paid.currency()); }} final class NonRefundable implements CancellationPolicy { public Money refund(Money paid, ZonedDateTime checkInAt, ZonedDateTime at) { return Money.zero(paid.currency()); }}from dataclasses import dataclassfrom datetime import datetime, timedelta @dataclass(frozen=True)class Tier: at_least_before: timedelta refund_percent: int @dataclass(frozen=True)class TieredPolicy: tiers: tuple[Tier, ...] # most generous first def refund_cents(self, paid_cents: int, check_in: datetime, cancelled_at: datetime) -> int: notice = check_in - cancelled_at for tier in self.tiers: if notice >= tier.at_least_before: return paid_cents * tier.refund_percent // 100 return 0 policy = TieredPolicy((Tier(timedelta(days=7), 100), Tier(timedelta(days=2), 50)))Approaches
Very hot dates where conflicting retries would be wasteful. Keep the transaction to the update and nothing else.
The default. The check is part of the write, all nights succeed or the transaction rolls back.
Very complex allocation rules that are hard to express as one conditional update; throughput per hotel is modest anyway.
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for:
Room-level booking turns away a guest the hotel could have housed by moving one booking between two identical rooms.
| None |
Guests choose a specific room — a particular view, accessibility needs. Pays the fragmentation cost for the guarantee.