Question: Design a hotel booking system (object-oriented)
Companies: Amazon
Difficulty: Medium
Problem statement
Model hotels, room types, rooms, bookings, guests and payments: search availability for dates, book, and cancel with a policy.
Requirements
- A hotel has rooms; each room has one room type (name, capacity, nightly rate).
- Search: given a hotel, check-in and check-out dates and a guest count, return how many rooms of each type are free for the whole stay. Dates are half-open
[check_in, check_out): checking out on the 23rd does not block a check-in on the 23rd. - Book: a guest books one room of a chosen type for a date range. The system picks a concrete free room, charges the total, and confirms.
- Cancel: a guest cancels a confirmed booking. The refund comes from the cancellation policy attached at booking time (flexible, non-refundable, ...).
- Two guests must never get the same room for overlapping nights, even when they book at the same moment. If payment fails, the room is released.
- Out of scope: multi-room bookings, dynamic pricing, loyalty points, housekeeping, overbooking, a real payment provider, persistence and the UI.
Core objects
-
Hotel— owns its rooms and the lock that serializes inventory changes for that hotel. -
RoomType— value object: name, capacity, nightly rate. Guests book a type, not a room number. -
Room— a physical room of one type; knows its bookings and whether it is free for a stay. -
DateRange— immutable half-open stay; validates itself, counts nights, tests overlap. -
Guest— who books and pays.Payment— the charge reference and the amount refunded so far. -
Booking— one guest, one room, one stay, the price, the policy, the payment and aBookingStatus(PENDING,CONFIRMED,CANCELLED,CHECKED_IN,CHECKED_OUT). -
PaymentGateway(interface) — charge and refund; real providers plug in behind it. -
CancellationPolicy(interface) — computes the refund for a booking on a given day;FlexiblePolicyandNonRefundablePolicyimplement it. -
BookingService— the entry point: search, book, cancel. Coordinates rooms, payments and policies.
Class design
{"type":"excalidraw","elements":[
{"type":"rectangle","id":"hotel","x":0,"y":0,"width":220,"height":90,"label":{"text":"Hotel\nrooms, lock\nfree_rooms(type, stay)"}},
{"type":"rectangle","id":"room","x":380,"y":0,"width":180,"height":90,"label":{"text":"Room\nbookings\nis_free(stay)"}},
{"type":"rectangle","id":"roomtype","x":720,"y":10,"width":180,"height":70,"label":{"text":"RoomType"}},
{"type":"rectangle","id":"range","x":380,"y":250,"width":180,"height":70,"label":{"text":"DateRange"}},
{"type":"rectangle","id":"booking","x":760,"y":240,"width":180,"height":90,"label":{"text":"Booking\nstatus\nholds_room()"}},
{"type":"rectangle","id":"guest","x":1100,"y":250,"width":180,"height":70,"label":{"text":"Guest"}},
{"type":"rectangle","id":"payment","x":760,"y":480,"width":180,"height":70,"label":{"text":"Payment"}},
{"type":"rectangle","id":"policy","x":1100,"y":460,"width":290,"height":90,"label":{"text":"<<interface>>\nCancellationPolicy\nrefund_amount(booking, today)"}},
{"type":"rectangle","id":"flex","x":1000,"y":700,"width":180,"height":70,"label":{"text":"FlexiblePolicy"}},
{"type":"rectangle","id":"nonref","x":1300,"y":700,"width":230,"height":70,"label":{"text":"NonRefundablePolicy"}},
{"type":"rectangle","id":"service","x":300,"y":480,"width":260,"height":120,"label":{"text":"BookingService\nsearch(hotel, stay, guests)\nbook(...)\ncancel(booking_id, today)"}},
{"type":"rectangle","id":"gateway","x":310,"y":720,"width":240,"height":120,"label":{"text":"<<interface>>\nPaymentGateway\ncharge(guest, amount)\nrefund(payment, amount)"}},
{"type":"arrow","id":"hotel-room","x":220,"y":45,"width":160,"height":0,"start":{"id":"hotel"},"end":{"id":"room"},"label":{"text":"has"}},
{"type":"arrow","id":"room-type","x":560,"y":45,"width":160,"height":0,"start":{"id":"room"},"end":{"id":"roomtype"},"label":{"text":"has"}},
{"type":"arrow","id":"room-range","x":470,"y":90,"width":1,"height":160,"start":{"id":"room"},"end":{"id":"range"},"label":{"text":"overlaps"},"strokeStyle":"dashed"},
{"type":"arrow","id":"room-booking","x":560,"y":70,"width":290,"height":170,"start":{"id":"room"},"end":{"id":"booking"},"label":{"text":"holds"}},
{"type":"arrow","id":"booking-range","x":760,"y":285,"width":-200,"height":0,"start":{"id":"booking"},"end":{"id":"range"},"label":{"text":"has"}},
{"type":"arrow","id":"booking-guest","x":940,"y":285,"width":160,"height":0,"start":{"id":"booking"},"end":{"id":"guest"},"label":{"text":"has"}},
{"type":"arrow","id":"booking-payment","x":850,"y":330,"width":1,"height":150,"start":{"id":"booking"},"end":{"id":"payment"},"label":{"text":"has"}},
{"type":"arrow","id":"booking-policy","x":940,"y":315,"width":160,"height":190,"start":{"id":"booking"},"end":{"id":"policy"},"label":{"text":"uses"}},
{"type":"arrow","id":"flex-policy","x":1090,"y":700,"width":95,"height":-150,"start":{"id":"flex"},"end":{"id":"policy"},"label":{"text":"implements"}},
{"type":"arrow","id":"nonref-policy","x":1415,"y":700,"width":-110,"height":-150,"start":{"id":"nonref"},"end":{"id":"policy"},"label":{"text":"implements"}},
{"type":"arrow","id":"service-gateway","x":430,"y":600,"width":1,"height":120,"start":{"id":"service"},"end":{"id":"gateway"},"label":{"text":"uses"}},
{"type":"arrow","id":"service-booking","x":560,"y":500,"width":200,"height":-185,"start":{"id":"service"},"end":{"id":"booking"},"label":{"text":"uses"}}
]}
Key flows
Search availability
-
BookingService.searchtakes the hotel lock so it reads a consistent snapshot. - For each
Roomwith enough capacity,Room.is_free(stay)checks every booking that still holds the room (PENDING,CONFIRMED,CHECKED_IN) withDateRange.overlaps, and counts free rooms per type. The result is advisory: it can change before the guest books.
Book
- Under the hotel lock:
Hotel.free_roomsfinds a free room of the requested type and re-checks availability (the search result may be stale); none free means it fails. Still under the lock, it creates aPENDINGBooking(total = nightly rate x nights) and attaches it to the room. A pending booking already holds the room. - Release the lock and call
PaymentGateway.charge. A slow network call must not block other bookings for the hotel. - On success, mark the booking
CONFIRMED. On failure, mark itCANCELLED, which frees the room, and re-raise.
Cancel
- Under the hotel lock: only a
CONFIRMEDbooking can be cancelled. Its ownCancellationPolicy(fixed at booking time) computes the refund for today. Mark itCANCELLED(the room is free again), then callPaymentGateway.refundoutside the lock.
{"type":"excalidraw","elements":[
{"type":"ellipse","id":"start","x":0,"y":5,"width":140,"height":60,"label":{"text":"start"}},
{"type":"rectangle","id":"pending","x":380,"y":0,"width":180,"height":70,"label":{"text":"PENDING"}},
{"type":"rectangle","id":"confirmed","x":820,"y":0,"width":180,"height":70,"label":{"text":"CONFIRMED"}},
{"type":"rectangle","id":"cancelled","x":380,"y":260,"width":180,"height":70,"label":{"text":"CANCELLED"}},
{"type":"rectangle","id":"checkedin","x":1240,"y":0,"width":200,"height":70,"label":{"text":"CHECKED_IN"}},
{"type":"rectangle","id":"checkedout","x":1240,"y":260,"width":200,"height":70,"label":{"text":"CHECKED_OUT"}},
{"type":"arrow","id":"book","x":140,"y":35,"width":240,"height":0,"start":{"id":"start"},"end":{"id":"pending"},"label":{"text":"book (room held)"}},
{"type":"arrow","id":"pay-ok","x":560,"y":35,"width":260,"height":0,"start":{"id":"pending"},"end":{"id":"confirmed"},"label":{"text":"payment succeeds"}},
{"type":"arrow","id":"pay-fail","x":470,"y":70,"width":1,"height":190,"start":{"id":"pending"},"end":{"id":"cancelled"},"label":{"text":"payment fails"}},
{"type":"arrow","id":"cancel","x":870,"y":70,"width":-310,"height":225,"start":{"id":"confirmed"},"end":{"id":"cancelled"},"label":{"text":"cancel (policy refund)"}},
{"type":"arrow","id":"arrive","x":1000,"y":35,"width":240,"height":0,"start":{"id":"confirmed"},"end":{"id":"checkedin"},"label":{"text":"guest arrives"}},
{"type":"arrow","id":"leave","x":1340,"y":70,"width":1,"height":190,"start":{"id":"checkedin"},"end":{"id":"checkedout"},"label":{"text":"guest leaves"}}
]}
Code
from __future__ import annotations
import threading, uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal
from enum import Enum
class BookingStatus(Enum):
PENDING = "pending"; CONFIRMED = "confirmed"; CANCELLED = "cancelled"
CHECKED_IN = "checked_in"; CHECKED_OUT = "checked_out"
HOLDING = {BookingStatus.PENDING, BookingStatus.CONFIRMED, BookingStatus.CHECKED_IN}
@dataclass(frozen=True)
class DateRange:
check_in: date
check_out: date # exclusive
def __post_init__(self) -> None:
if self.check_out <= self.check_in:
raise ValueError("check_out must be after check_in")
def nights(self) -> int:
return (self.check_out - self.check_in).days
def overlaps(self, other: DateRange) -> bool:
return self.check_in < other.check_out and other.check_in < self.check_out
@dataclass(frozen=True)
class RoomType:
name: str
capacity: int
nightly_rate: Decimal
@dataclass(frozen=True)
class Guest:
guest_id: str
name: str
@dataclass(eq=False)
class Room:
number: str
room_type: RoomType
bookings: list[Booking] = field(default_factory=list)
def is_free(self, stay: DateRange) -> bool:
return not any(b.holds_room() and b.stay.overlaps(stay) for b in self.bookings)
class Hotel:
def __init__(self, name: str, rooms: list[Room]) -> None:
self.name, self.rooms, self.lock = name, rooms, threading.Lock()
def free_rooms(self, room_type: RoomType, stay: DateRange) -> list[Room]:
return [r for r in self.rooms if r.room_type == room_type and r.is_free(stay)]
@dataclass
class Payment:
amount: Decimal
reference: str
refunded: Decimal = Decimal("0")
class PaymentGateway(ABC):
@abstractmethod
def charge(self, guest: Guest, amount: Decimal) -> Payment: ...
@abstractmethod
def refund(self, payment: Payment, amount: Decimal) -> None: ...
class CancellationPolicy(ABC):
@abstractmethod
def refund_amount(self, booking: Booking, today: date) -> Decimal: ...
class FlexiblePolicy(CancellationPolicy):
def __init__(self, days: int = 2) -> None:
self.days = days
def refund_amount(self, booking: Booking, today: date) -> Decimal:
if today >= booking.stay.check_in:
return Decimal("0")
if today <= booking.stay.check_in - timedelta(days=self.days):
return booking.total
return max(booking.total - booking.room.room_type.nightly_rate, Decimal("0"))
class NonRefundablePolicy(CancellationPolicy):
def refund_amount(self, booking: Booking, today: date) -> Decimal:
return Decimal("0")
@dataclass(eq=False)
class Booking:
booking_id: str
hotel: Hotel
guest: Guest
room: Room
stay: DateRange
total: Decimal
policy: CancellationPolicy
status: BookingStatus = BookingStatus.PENDING
payment: Payment | None = None
def holds_room(self) -> bool:
return self.status in HOLDING
class BookingService:
def __init__(self, gateway: PaymentGateway) -> None:
self.gateway = gateway
self.bookings: dict[str, Booking] = {}
def search(self, hotel: Hotel, stay: DateRange, guests: int) -> dict[str, int]:
counts: dict[str, int] = {}
with hotel.lock:
for room in hotel.rooms:
if room.room_type.capacity >= guests and room.is_free(stay):
counts[room.room_type.name] = counts.get(room.room_type.name, 0) + 1
return counts
def book(self, hotel: Hotel, guest: Guest, room_type: RoomType,
stay: DateRange, policy: CancellationPolicy) -> Booking:
with hotel.lock: # re-check and hold atomically
free = hotel.free_rooms(room_type, stay)
if not free:
raise LookupError(f"no {room_type.name} free for {stay}")
room = free[0]
booking = Booking(uuid.uuid4().hex, hotel, guest, room, stay,
room_type.nightly_rate * stay.nights(), policy)
room.bookings.append(booking) # PENDING already holds the room
self.bookings[booking.booking_id] = booking
try:
payment = self.gateway.charge(guest, booking.total) # outside the lock
except Exception:
with hotel.lock:
booking.status = BookingStatus.CANCELLED # release the room
raise
with hotel.lock:
booking.payment, booking.status = payment, BookingStatus.CONFIRMED
return booking
def cancel(self, booking_id: str, today: date) -> Decimal:
booking = self.bookings[booking_id]
with booking.hotel.lock:
if booking.status is not BookingStatus.CONFIRMED:
raise ValueError(f"cannot cancel a {booking.status.value} booking")
refund = booking.policy.refund_amount(booking, today)
booking.status = BookingStatus.CANCELLED
if refund > 0 and booking.payment is not None:
self.gateway.refund(booking.payment, refund)
return refund
class FakeGateway(PaymentGateway):
def charge(self, guest: Guest, amount: Decimal) -> Payment: return Payment(amount, "ch_1")
def refund(self, payment: Payment, amount: Decimal) -> None: payment.refunded += amount
if __name__ == "__main__":
king = RoomType("Deluxe King", 2, Decimal("200"))
hotel = Hotel("Seaside", [Room("101", king), Room("102", king)])
svc = BookingService(FakeGateway())
stay = DateRange(date(2026, 12, 20), date(2026, 12, 23))
alice = Guest("g1", "Alice")
print(svc.search(hotel, stay, guests=2)) # {'Deluxe King': 2}
b = svc.book(hotel, alice, king, stay, FlexiblePolicy(days=2))
print(b.total, svc.search(hotel, stay, 2)) # 600 {'Deluxe King': 1}
print(svc.cancel(b.booking_id, date(2026, 12, 19))) # 400: inside window, first night kept
Design patterns
-
Strategy —
CancellationPolicy. Each rate plan carries its own refund rule; adding "free cancellation until 6 pm on arrival day" is a new class, andBookingServicedoes not change. The booking keeps the policy object it was sold with.PaymentGatewayis the same idea at the payment boundary: it hides the provider. Tests useFakeGateway; production wraps a real processor. -
Explicit state machine —
BookingStatuswith guarded transitions (cancelonly fromCONFIRMED; check-in and check-out use the same guards and are left out of the code). With two or three more states (no-show, modification) this becomes a State pattern with one class per state. Today an enum and guards are enough. -
Facade —
BookingServiceis the one entry point for search, book and cancel, so callers never touch rooms or locks directly.
Extensions
-
Scale and a real database: replace the in-memory scan with a
room_nights(room_id, night)table with a unique key, or an inventory row per (room type, night) with a counter. Booking inserts all nights in one transaction; the unique constraint replaceshotel.lock. The object model does not change; onlyRoom.is_freeand the hold step go behind a repository. -
Holds with expiry: a
PENDINGbooking getshold_expires_at. A sweeper cancels expired holds, so a crashed checkout cannot block a room forever. Payment then becomes authorize-on-book, capture-on-confirm. -
Pricing: a
PricingStrategy(per-night rates, weekend surcharge, length-of-stay discount, taxes) replacesnightly_rate * nights. It stores the price breakdown on the booking so refunds use the price that was paid.
Interviewer expectations
Mid-level (L4)
- Separates
RoomTypefromRoomandBookingfromGuest; gets the overlap check right, including back-to-back stays. - Working search, book and cancel with a status enum and basic validation (check-out after check-in, capacity); cancellation rules live somewhere replaceable, not in
ifchains.
Senior (L5)
- Notices the search-then-book race: re-checks availability and holds the room atomically, and keeps the slow payment call outside the lock.
- Handles payment failure by releasing the hold. Explains why the policy is attached to the booking, not looked up at cancel time.
Staff (L6+)
- Moves the invariant to storage: per-night inventory rows or unique constraints, holds with TTL, idempotency keys on book and on payment retries.
- Discusses lock granularity (per hotel vs per room type vs per night), overbooking as a business choice and exactly-once refunds; explains how channel managers or partner inventory and pricing plug in without changing the core model.