Low-Level Design: Hotel Booking Platform — Availability, Atomic Reservation, Dynamic Pricing

Requirements

Functional: search available rooms by date range and city, view room details and pricing, book a room (atomic reservation), cancel with refund policy, manage check-in/check-out, support multiple room types per property, dynamic pricing (rates change by date/season).

Non-functional: no double-booking (atomic reservation), search results show real-time availability, idempotent booking (no duplicate charges on retry), 99.99% uptime during peak travel periods.

Core Entities

from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, List, Dict
from datetime import date, datetime

class RoomType(Enum):
    STANDARD  = "STANDARD"
    DELUXE    = "DELUXE"
    SUITE     = "SUITE"
    PENTHOUSE = "PENTHOUSE"

class BookingStatus(Enum):
    PENDING   = "PENDING"     # payment not yet confirmed
    CONFIRMED = "CONFIRMED"
    CHECKED_IN  = "CHECKED_IN"
    CHECKED_OUT = "CHECKED_OUT"
    CANCELLED = "CANCELLED"
    NO_SHOW   = "NO_SHOW"

@dataclass
class Room:
    room_id: str
    hotel_id: str
    room_number: str
    room_type: RoomType
    max_occupancy: int
    amenities: List[str]
    base_price_cents: int    # nightly base rate
    is_active: bool = True

@dataclass
class RoomAvailability:
    room_id: str
    date: date
    is_available: bool
    price_cents: int         # may differ from base (dynamic pricing)

@dataclass
class Booking:
    booking_id: str
    user_id: str
    room_id: str
    hotel_id: str
    check_in: date
    check_out: date
    status: BookingStatus
    total_cents: int
    payment_intent_id: str
    idempotency_key: str
    created_at: datetime
    cancelled_at: Optional[datetime] = None
    refund_cents: Optional[int] = None

@dataclass
class Hotel:
    hotel_id: str
    name: str
    city: str
    country: str
    star_rating: int
    latitude: float
    longitude: float
    rooms: List[Room] = field(default_factory=list)

Availability Check

class AvailabilityService:
    def search_available_rooms(self, hotel_id: str, check_in: date,
                               check_out: date, guests: int) -> List[dict]:
        """Find rooms available for the entire requested stay."""
        # All dates in the requested range
        nights = (check_out - check_in).days
        dates = [check_in + timedelta(days=i) for i in range(nights)]

        # Rooms not booked for any date in range
        booked_room_ids = db.query(
            """SELECT DISTINCT room_id FROM bookings
               WHERE hotel_id = %s
               AND status IN ('CONFIRMED', 'CHECKED_IN')
               AND check_in  %s""",
            [hotel_id, check_out, check_in]
        )

        all_rooms = db.query(
            "SELECT * FROM rooms WHERE hotel_id = %s AND is_active = TRUE "
            "AND max_occupancy >= %s",
            [hotel_id, guests]
        )

        available = [r for r in all_rooms if r.room_id not in booked_room_ids]

        # Attach pricing per date (dynamic pricing)
        result = []
        for room in available:
            prices = self._get_prices(room.room_id, dates)
            result.append({
                'room': room,
                'nightly_prices': prices,
                'total_cents': sum(prices.values()),
            })
        return result

    def _get_prices(self, room_id: str, dates: list) -> Dict[date, int]:
        """Fetch dynamic pricing — falls back to base rate if no override."""
        overrides = {row.date: row.price_cents
                     for row in db.query(
                         "SELECT date, price_cents FROM room_availability "
                         "WHERE room_id = %s AND date IN %s",
                         [room_id, tuple(d.isoformat() for d in dates)]
                     )}
        room = db.get_room(room_id)
        return {d: overrides.get(d, room.base_price_cents) for d in dates}

Atomic Booking (Preventing Double-Booking)

class BookingService:
    def create_booking(self, user_id: str, room_id: str, hotel_id: str,
                       check_in: date, check_out: date,
                       idempotency_key: str) -> Booking:
        # Idempotency: return existing booking if key already used
        existing = db.get_booking_by_idempotency_key(idempotency_key)
        if existing:
            return existing

        with db.transaction():
            # Lock room for the date range (prevents concurrent booking)
            conflicting = db.query(
                """SELECT booking_id FROM bookings
                   WHERE room_id = %s
                   AND status IN ('CONFIRMED', 'CHECKED_IN', 'PENDING')
                   AND check_in  %s
                   FOR UPDATE""",     # pessimistic lock
                [room_id, check_out, check_in]
            )
            if conflicting:
                raise ValueError("Room is not available for the requested dates")

            prices = AvailabilityService()._get_prices(
                room_id, [(check_in + timedelta(days=i))
                          for i in range((check_out - check_in).days)]
            )
            total = sum(prices.values())

            booking = Booking(
                booking_id=generate_id(),
                user_id=user_id,
                room_id=room_id,
                hotel_id=hotel_id,
                check_in=check_in,
                check_out=check_out,
                status=BookingStatus.PENDING,
                total_cents=total,
                payment_intent_id='',
                idempotency_key=idempotency_key,
                created_at=datetime.utcnow(),
            )
            db.save(booking)

        # Charge payment outside transaction (avoid holding DB lock during network call)
        payment_result = payment_gateway.charge(
            user_id=user_id,
            amount_cents=total,
            idempotency_key=f"pay_{idempotency_key}",
        )
        if payment_result.success:
            booking.payment_intent_id = payment_result.intent_id
            booking.status = BookingStatus.CONFIRMED
        else:
            booking.status = BookingStatus.CANCELLED
        db.save(booking)
        return booking

Cancellation and Refund Policy

CANCELLATION_POLICY = {
    # (days_before_check_in) -> refund percentage
    7:  100,   # 7+ days before: full refund
    3:  50,    # 3-6 days before: 50% refund
    0:  0,     #  Booking:
    booking = db.get_booking(booking_id)
    if booking.user_id != user_id:
        raise PermissionError("Not your booking")
    if booking.status != BookingStatus.CONFIRMED:
        raise ValueError(f"Cannot cancel a {booking.status} booking")

    days_before = (booking.check_in - date.today()).days
    refund_pct = 0
    for min_days, pct in sorted(CANCELLATION_POLICY.items(), reverse=True):
        if days_before >= min_days:
            refund_pct = pct
            break

    refund_cents = int(booking.total_cents * refund_pct / 100)
    if refund_cents > 0:
        payment_gateway.refund(booking.payment_intent_id, refund_cents)
    booking.status = BookingStatus.CANCELLED
    booking.cancelled_at = datetime.utcnow()
    booking.refund_cents = refund_cents
    db.save(booking)
    return booking

Dynamic Pricing

def set_dynamic_price(room_id: str, date: date, price_cents: int):
    """Override room price for a specific date (holiday, event, low season)."""
    db.upsert(RoomAvailability(
        room_id=room_id, date=date,
        is_available=True, price_cents=price_cents
    ))

def apply_seasonal_pricing(hotel_id: str):
    """Apply multipliers for peak seasons."""
    PEAK_MULTIPLIER = 1.5
    OFF_PEAK_MULTIPLIER = 0.8
    rooms = db.get_rooms(hotel_id)
    for room in rooms:
        for month in range(1, 13):
            multiplier = PEAK_MULTIPLIER if month in [6, 7, 8, 12] else OFF_PEAK_MULTIPLIER
            # Set prices for all dates in this month
            for day_offset in range(31):
                try:
                    d = date(2025, month, day_offset + 1)
                    set_dynamic_price(room.room_id, d,
                                      int(room.base_price_cents * multiplier))
                except ValueError:
                    pass  # invalid date (e.g., Feb 30)

Interview Questions

Q: How do you prevent two users from booking the same room for overlapping dates?

Use pessimistic locking: SELECT … FOR UPDATE on bookings rows that overlap the requested dates. Only one transaction can hold the lock — the concurrent request waits. After acquiring the lock, check for conflicts again (the double-check pattern). If no conflict, insert the new booking and commit. For higher throughput, use optimistic locking: attempt the INSERT with a unique constraint on (room_id, check_in, check_out) and handle the constraint violation as a conflict. PostgreSQL also supports exclusion constraints for range overlap: EXCLUDE USING gist (room_id WITH =, daterange(check_in, check_out) WITH &&).

Q: How would you scale search to handle millions of hotel listings?

Use Elasticsearch for search: index each hotel with geo_point, amenities, star_rating, and room types. For availability, the index includes a pre-computed “available dates” array updated asynchronously when bookings change. On search: Elasticsearch filters by city + date range (using the availability array) and ranks by relevance/price. The booking service remains the authoritative source — Elasticsearch may show slightly stale data, but the booking step does a real-time availability check against the database. This separation handles millions of queries/second without hitting the transactional database on every search.

Asked at: Airbnb Interview Guide

Asked at: Stripe Interview Guide

Asked at: Uber Interview Guide

Asked at: Shopify Interview Guide

Scroll to Top