0

Design a hotel booking system (object-oriented)

by · 1 day ago· ⑂ 0 remixes

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 a BookingStatus (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; FlexiblePolicy and NonRefundablePolicy implement 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

  1. BookingService.search takes the hotel lock so it reads a consistent snapshot.
  2. For each Room with enough capacity, Room.is_free(stay) checks every booking that still holds the room (PENDING, CONFIRMED, CHECKED_IN) with DateRange.overlaps, and counts free rooms per type. The result is advisory: it can change before the guest books.

Book

  1. Under the hotel lock: Hotel.free_rooms finds 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 a PENDING Booking (total = nightly rate x nights) and attaches it to the room. A pending booking already holds the room.
  2. Release the lock and call PaymentGateway.charge. A slow network call must not block other bookings for the hotel.
  3. On success, mark the booking CONFIRMED. On failure, mark it CANCELLED, which frees the room, and re-raise.

Cancel

  1. Under the hotel lock: only a CONFIRMED booking can be cancelled. Its own CancellationPolicy (fixed at booking time) computes the refund for today. Mark it CANCELLED (the room is free again), then call PaymentGateway.refund outside 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, and BookingService does not change. The booking keeps the policy object it was sold with. PaymentGateway is the same idea at the payment boundary: it hides the provider. Tests use FakeGateway; production wraps a real processor.
  • Explicit state machine — BookingStatus with guarded transitions (cancel only from CONFIRMED; 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 — BookingService is 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 replaces hotel.lock. The object model does not change; only Room.is_free and the hold step go behind a repository.
  • Holds with expiry: a PENDING booking gets hold_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) replaces nightly_rate * nights. It stores the price breakdown on the booking so refunds use the price that was paid.

Interviewer expectations

Mid-level (L4)

  • Separates RoomType from Room and Booking from Guest; 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 if chains.

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.

Sign in to comment, vote or remix.