system-design56 min read

How to Design a Hotel Booking System - A Senior+ Guide | Ayodhyya

How to Design a Hotel Booking System

Building a Booking.com-Scale Platform - Inventory, Search, Dynamic Pricing, Payments & Channel Management

Senior+ System Design Guide 10,000+ Words 20 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Hotel Booking is Hard

Designing a hotel booking platform like Booking.com, Airbnb, or Expedia is one of the most nuanced challenges in distributed systems. Unlike e-commerce where products are fungible - a widget from warehouse A is identical to one from warehouse B - every hotel room is a unique, perishable inventory unit tied to a specific date, a specific room type, and a specific property. The moment a checkout date passes without a booking, that inventory's revenue potential vanishes forever.

Booking.com alone processes over 1.5 million room nights per day across more than 28 million reported listings. The platform serves approximately 100 million verified guests per year and handles peak search traffic of over 1 million queries per second during holiday seasons. The core technical challenges include: maintaining real-time inventory accuracy across dozens of online travel agencies (OTAs) simultaneously, computing dynamic prices that respond to demand signals within minutes, preventing double-bookings through distributed locking without destroying throughput, and orchestrating payment flows that span multiple currencies, payment methods, and refund policies.

The Perishability Problem: An unsold hotel room for tonight is permanently lost revenue - unlike an unsold product that can be sold tomorrow. This fundamental constraint drives every architectural decision in a booking system, from how we index inventory to how we handle concurrent reservations.

The hotel booking domain also involves complex business rules that trip up even experienced engineers. A single reservation may span multiple room types at the same property, require child-age calculations for pricing, involve loyalty program point redemptions, and trigger different cancellation policies depending on the rate plan selected. When you layer on the channel manager problem - synchronizing inventory across Booking.com, Expedia, Hotels.com, Agoda, and the hotel's own website in near real-time - the complexity grows exponentially.

This guide walks through a complete system design for a hotel booking platform, covering everything from the foundational data model to the nuances of overbooking strategies and channel manager integration. We will use C# for code examples, Mermaid for architecture diagrams, and real-world numbers from public Booking.com and Airbnb engineering disclosures to ground our design in practical reality.

Key Design Principles

  • Inventory accuracy is paramount. A double-booking destroys guest trust more than any other failure mode. The system must guarantee exactly-once reservation semantics even under concurrent access.
  • Search latency directly impacts conversion. Every 100ms of additional search latency reduces booking conversion by approximately 1%. The search path must return results in under 300ms at the 99th percentile.
  • Price freshness drives revenue. Dynamic pricing models that recalculate rates based on demand, competitor prices, and seasonality must propagate to search results within minutes, not hours.
  • Channel consistency prevents overbookings. When a room is booked on one channel, all other channels must reflect the updated availability within seconds to avoid selling the same room twice.
Real-World Context: Booking.com's engineering team has shared that they process over 1 billion price calculations per day and their search infrastructure spans thousands of microservices. The system we design here is a simplified but architecturally faithful representation of these production systems.

2. Functional & Non-Functional Requirements

Functional Requirements

  1. Property Search: Users can search for accommodations by location (city, airport, landmark), check-in/check-out dates, number of guests, and room configuration.
  2. Availability Check: The system must show real-time room availability for a given property and date range, including room types, occupancy limits, and included amenities.
  3. Dynamic Pricing: Room prices must be computed dynamically based on demand signals, seasonality, competitor pricing, length of stay, and user segment.
  4. Booking Creation: Users can reserve one or more rooms for a date range, providing guest details and payment information. The system must prevent double-bookings.
  5. Payment Processing: Support multiple payment methods (credit card, debit card, digital wallets), multi-currency pricing, and secure payment tokenization.
  6. Booking Management: Users can view, modify, or cancel their bookings. Cancellation must follow configurable refund policies tied to the rate plan.
  7. Review & Rating System: Verified guests can leave reviews with structured ratings (cleanliness, location, staff, comfort, value, facilities) and free-text comments.
  8. Property Management: Hotel partners can manage their listings, room types, photos, policies, and availability through a partner dashboard.
  9. Channel Manager: Inventory changes must propagate to external OTAs (Booking.com, Expedia, Agoda) via standard protocols (OTA/HTNG, OpenBooking).
  10. Recommendations: The system suggests properties based on user search history, past bookings, and collaborative filtering signals.

Non-Functional Requirements

RequirementTargetRationale
Search Latency (P99)< 300msDirect impact on conversion rate
Booking Throughput10,000 bookings/second peakFlash sales, holiday peaks
Inventory Accuracy100% (zero double-bookings)Trust-critical guarantee
Search Availability99.99%Revenue protection
Data ConsistencyStrong for inventory, eventual for reviewsDifferent tolerance per domain
Price Freshness< 5 minutesCompetitive parity with OTAs
Channel Sync Latency< 10 secondsPrevent cross-channel overbookings
Global ReachMulti-region with < 100ms TTFBServe users in 200+ countries
Interview Tip: Always ask clarifying questions about the scope. Is this a consumer-facing OTA like Booking.com, a hotel chain's direct booking platform like Marriott.com, or a property management system (PMS) like Cloudbeds? Each has different emphasis on search, inventory management, or operational tooling.

3. Capacity Estimation & Back-of-Envelope

Scale Assumptions

Let us model a Booking.com-scale platform:

  • Total properties: 28 million listings (including apartments, villas, hostels)
  • Daily room nights booked: 1.5 million
  • Daily unique search sessions: 200 million
  • Average rooms per property: 50
  • Room types per property: 4 on average
  • Date range indexed: 365 days forward
  • Average booking lead time: 14 days

Storage Calculations

Inventory matrix size: 28M properties x 4 room types x 365 days x 1 record each = ~40.9 billion availability records. At 200 bytes per record (property_id, room_type, date, available_count, price, version), total inventory storage is approximately 8.2 TB. With compression and partitioning strategies, active inventory (bookings within 30 days) fits in approximately 600 GB - easily manageable in a distributed key-value store.

Throughput Calculations

Calc
Search QPS (average):  200M / 86400 ~ 2,315 QPS
Search QPS (peak 3x):  ~7,000 QPS
Booking QPS (average): 1.5M / 86400 ~ 17 QPS
Booking QPS (peak 10x): ~170 QPS
Price calc per second: 7,000 searches x 50 results avg = 350K price evals/sec
Channel sync events:   1.5M bookings x 2 channels avg / 86400 ~ 35 sync events/sec
                       + availability changes ~ 200 sync events/sec peak

Bandwidth:
  Search request:   ~2 KB payload
  Search response:  ~200 KB (50 results x 4KB avg)
  Total inbound:    7K x 2KB = 14 MB/s
  Total outbound:   7K x 200KB = 1.4 GB/s (CDN-cacheable)

Key Numbers Summary

MetricValue
Total inventory records~40.9 billion
Active hot inventory (30 days)~3.4 billion records
Search QPS (peak)~7,000
Booking QPS (peak)~170
Price calculations/second~350,000
Storage (inventory alone)~8.2 TB raw
Review storage (5 years)~500 GB
Image storage (property photos)~50 TB

4. Data Model & Storage Schema

The data model for a hotel booking system must balance normalized relational integrity for transactional data (bookings, payments) with denormalized read-optimized structures for search and availability queries. We use a polyglot persistence approach: PostgreSQL for transactional data, Redis for hot inventory and pricing caches, Elasticsearch for full-text search, and object storage for media.

Core Entities

C#
public class Property
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public PropertyType Type { get; set; }
    public Address Address { get; set; }
    public GeoLocation Location { get; set; }
    public StarRating StarRating { get; set; }
    public string[] Amenities { get; set; }
    public string[] PhotoUrls { get; set; }
    public string Description { get; set; }
    public CheckInOutPolicy CheckInOut { get; set; }
    public Guid PartnerId { get; set; }
    public PropertyStatus Status { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

public class RoomType
{
    public Guid Id { get; set; }
    public Guid PropertyId { get; set; }
    public string Name { get; set; }
    public int MaxOccupancy { get; set; }
    public int TotalRooms { get; set; }
    public RoomBedConfiguration Beds { get; set; }
    public decimal BasePrice { get; set; }
    public string[] Amenities { get; set; }
    public int SquareMeters { get; set; }
    public bool IsEnabled { get; set; }
}

public class Booking
{
    public Guid Id { get; set; }
    public string ConfirmationCode { get; set; }
    public Guid PropertyId { get; set; }
    public Guid UserId { get; set; }
    public List<BookedRoom> Rooms { get; set; }
    public DateOnly CheckIn { get; set; }
    public DateOnly CheckOut { get; set; }
    public int TotalNights { get; set; }
    public int TotalGuests { get; set; }
    public BookingStatus Status { get; set; }
    public Money TotalPrice { get; set; }
    public Money TaxAmount { get; set; }
    public Money CommissionAmount { get; set; }
    public string CurrencyCode { get; set; }
    public PaymentInfo Payment { get; set; }
    public CancellationPolicy CancellationPolicy { get; set; }
    public GuestDetails PrimaryGuest { get; set; }
    public string SpecialRequests { get; set; }
    public string ChannelSource { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset? ConfirmedAt { get; set; }
    public DateTimeOffset? CheckedInAt { get; set; }
    public DateTimeOffset? CheckedOutAt { get; set; }
    public DateTimeOffset? CancelledAt { get; set; }
    public int Version { get; set; }
}

public class BookedRoom
{
    public Guid RoomTypeId { get; set; }
    public string RoomTypeName { get; set; }
    public int Quantity { get; set; }
    public Money PricePerNight { get; set; }
    public Money TotalRoomPrice { get; set; }
    public GuestDetails[] Guests { get; set; }
}

public class InventoryRecord
{
    public Guid PropertyId { get; set; }
    public Guid RoomTypeId { get; set; }
    public DateOnly Date { get; set; }
    public int TotalCount { get; set; }
    public int ReservedCount { get; set; }
    public int HoldCount { get; set; }
    public int OverbookedCount { get; set; }
    public int AvailableCount => TotalCount + OverbookedCount
                                  - ReservedCount - HoldCount;
    public decimal Price { get; set; }
    public string CurrencyCode { get; set; }
    public long Version { get; set; }
    public DateTimeOffset LastUpdated { get; set; }
}

Booking Status Enum

C#
public enum BookingStatus
{
    Pending = 0,
    Confirmed = 1,
    CheckedIn = 2,
    CheckedOut = 3,
    Cancelled = 4,
    NoShow = 5,
    ModificationPending = 6,
    Refunded = 7,
    PartiallyRefunded = 8
}

Database Partitioning Strategy

Inventory Table Partitioning: The inventory table is partitioned by PropertyId hash (for writes) and indexed by Date range (for reads). This allows the search path to query availability for a date range by scanning only relevant partitions. The booking table is partitioned by CreatedAt month, with hot partitions (current month) kept in SSD-backed storage and cold partitions migrated to cheaper storage tiers.
C#
// PostgreSQL partitioning for inventory
// CREATE TABLE inventory (
//   property_id UUID NOT NULL,
//   room_type_id UUID NOT NULL,
//   date DATE NOT NULL,
//   total_count INT NOT NULL,
//   reserved_count INT DEFAULT 0,
//   hold_count INT DEFAULT 0,
//   overbooked_count INT DEFAULT 0,
//   price DECIMAL(12,2) NOT NULL,
//   currency_code VARCHAR(3) NOT NULL,
//   version BIGINT DEFAULT 1,
//   last_updated TIMESTAMPTZ DEFAULT NOW(),
//   PRIMARY KEY (property_id, room_type_id, date)
// ) PARTITION BY HASH (property_id);

Redis Inventory Cache Schema

C#
public class RedisInventoryCache
{
    private readonly IConnectionMultiplexer _redis;

    public async Task<InventoryRecord?> GetAvailabilityAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date)
    {
        var key = $"inv:{propertyId}:{roomTypeId}:{date}";
        var db = _redis.GetDatabase();
        var value = await db.StringGetAsync(key);
        if (value.IsNullOrEmpty) return null;
        return JsonSerializer.Deserialize<InventoryRecord>(value!);
    }

    public async Task<bool> TryReserveAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date,
        long expectedVersion)
    {
        var key = $"inv:{propertyId}:{roomTypeId}:{date}";
        var db = _redis.GetDatabase();

        var script = @"
            local current = redis.call('GET', KEYS[1])
            if not current then return 0 end
            local data = cjson.decode(current)
            if data.version ~= tonumber(ARGV[1]) then return -1 end
            local avail = data.total + data.overbooked
                          - data.reserved - data.hold
            if avail <= 0 then return 0 end
            data.reserved = data.reserved + 1
            data.version = data.version + 1
            redis.call('SET', KEYS[1], cjson.encode(data))
            return 1";

        var result = await db.ScriptEvaluateAsync(script,
            new RedisKey[] { key },
            new RedisValue[] { expectedVersion });

        return (int)result == 1;
    }
}

Elasticsearch Index Mapping

For the search path, properties are indexed in Elasticsearch with denormalized availability and pricing data. The index is refreshed every 30 seconds to balance freshness with search performance.

C#
public class PropertySearchDocument
{
    public Guid PropertyId { get; set; }
    public string Name { get; set; }
    public string City { get; set; }
    public string Country { get; set; }
    public GeoPoint Location { get; set; }
    public double StarRating { get; set; }
    public double ReviewScore { get; set; }
    public int ReviewCount { get; set; }
    public string[] Amenities { get; set; }
    public string PropertyType { get; set; }
    public List<RoomAvailabilityInfo> RoomTypes { get; set; }
    public decimal MinPrice { get; set; }
    public string CurrencyCode { get; set; }
    public double RankingScore { get; set; }
}

public class RoomAvailabilityInfo
{
    public Guid RoomTypeId { get; set; }
    public string Name { get; set; }
    public int MaxOccupancy { get; set; }
    public decimal PricePerNight { get; set; }
    public decimal TotalPrice { get; set; }
    public int AvailableRooms { get; set; }
    public bool IsFreeCancellation { get; set; }
    public bool IsBreakfastIncluded { get; set; }
    public string[] BedTypes { get; set; }
}
Data Modeling Trade-off: Denormalizing availability into the Elasticsearch index means we need a near-real-time sync pipeline from the inventory service to Elasticsearch. We use CDC (Change Data Capture) via Debezium on the PostgreSQL inventory table, which streams changes to Kafka, from which a consumer updates the Elasticsearch documents. This keeps search freshness under 30 seconds while maintaining a highly denormalized, fast search index.

5. High-Level Architecture Overview

The system follows a microservice architecture with clear domain boundaries. Each service owns its data store and communicates via synchronous REST/gRPC for queries and asynchronous events via Apache Kafka for state changes. The architecture separates the read path (search, availability lookup) from the write path (booking creation, payment) to independently scale and optimize each.

graph TB Client["Web / Mobile Client"] --> Gateway["API Gateway
(Rate Limiting, Auth, Routing)"] Gateway --> SearchSvc["Search Service
(Elasticsearch Queries)"] Gateway --> PropertySvc["Property Service
(CRUD, Photos, Policies)"] Gateway --> BookingSvc["Booking Service
(Reservation Flow)"] Gateway --> UserSvc["User Service
(Auth, Profiles, Loyalty)"] Gateway --> ReviewSvc["Review Service
(Ratings, Reviews)"] Gateway --> PaymentSvc["Payment Service
(Stripe, PayPal, Cards)"] BookingSvc --> InventorySvc["Inventory Service
(Availability, Locking)"] BookingSvc --> PricingSvc["Pricing Engine
(Dynamic Rates)"] BookingSvc --> NotificationSvc["Notification Service
(Email, SMS, Push)"] InventorySvc --> InventoryDB[("Inventory DB
PostgreSQL + Redis")] PricingSvc --> PricingCache[("Pricing Cache
Redis Cluster")] SearchSvc --> ES[("Elasticsearch
Property Index")] BookingSvc --> Kafka["Apache Kafka
(Event Bus)"] Kafka --> ChannelMgrSvc["Channel Manager
(OTA Sync)"] Kafka --> AnalyticsSvc["Analytics Service
(BI, Revenue)"] ChannelMgrSvc --> BookingCom["Booking.com
API"] ChannelMgrSvc --> Expedia["Expedia
API"] ChannelMgrSvc --> Agoda["Agoda
API"] InventorySvc --> PMS["PMS Integration
(Opera, Cloudbeds)"]

Service Responsibilities

ServiceResponsibilityData StoreScaling Strategy
Search ServiceProperty search, filtering, sortingElasticsearchHorizontal (shard-based)
Property ServiceCRUD for listings, photos, policiesPostgreSQL + S3Read replicas
Inventory ServiceRoom availability tracking, lockingPostgreSQL + RedisSharded by property
Pricing EngineDynamic rate calculationRedis + Rules EngineHorizontal, CPU-bound
Booking ServiceReservation lifecycle managementPostgreSQLHorizontal, partitioned
Payment ServicePayment processing, refundsPostgreSQL (PCI scope)Horizontal, isolated
Channel ManagerOTA inventory synchronizationPostgreSQL + RedisPer-channel workers
User ServiceAuthentication, profiles, loyaltyPostgreSQL + RedisRead replicas
Review ServiceReviews, ratings, moderationPostgreSQL + ElasticsearchRead replicas
Notification ServiceEmail, SMS, push notificationsMessage QueueQueue-based workers

Read Path vs Write Path

The search flow is entirely on the read path: the client sends a search query, the API gateway routes to the Search Service, which queries Elasticsearch using denormalized property and availability data. Elasticsearch returns property matches with room availability and pricing pre-computed. This path never touches the inventory database directly, achieving sub-300ms latency.

The booking flow is on the write path: when a user selects a room and proceeds to checkout, the Booking Service acquires a distributed lock on the specific inventory record (property + room type + date), decrements available count, creates the booking record, processes payment, and only then commits the inventory change. If any step fails, the inventory lock is released. This path prioritizes correctness over latency and typically completes in 1-3 seconds.

CQRS Pattern: We effectively implement Command Query Responsibility Segregation (CQRS) at the infrastructure level. The write model (PostgreSQL inventory tables) is optimized for consistency and transactional guarantees. The read model (Elasticsearch index) is optimized for fast, denormalized queries. The two are kept in sync via CDC + Kafka event streaming with a typical lag of 10-30 seconds.

6. Inventory Management (Room Availability)

Inventory management is the heart of a hotel booking system. Every room on every night must be tracked with exact counts, and concurrent modifications must be serialized to prevent overbookings. The fundamental challenge is that inventory reads (availability checks during search) vastly outnumber writes (bookings, cancellations), but writes carry the highest stakes - a lost write means a double-booking.

Inventory Data Structure

Each inventory record represents a single room type at a single property on a single date. The record tracks total physical rooms, reserved count (confirmed bookings), hold count (in-progress transactions), and overbooked count (intentional overbooking buffer). The available count is derived: TotalCount + OverbookedCount - ReservedCount - HoldCount.

C#
public class InventoryService
{
    private readonly InventoryRepository _inventoryRepo;
    private readonly RedisInventoryCache _cache;
    private readonly IEventBus _eventBus;
    private readonly ILogger<InventoryService> _logger;

    public async Task<InventoryRecord> GetAvailabilityAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date)
    {
        var cached = await _cache.GetAvailabilityAsync(
            propertyId, roomTypeId, date);
        if (cached != null) return cached;

        var record = await _inventoryRepo.GetAsync(
            propertyId, roomTypeId, date);
        if (record != null)
        {
            await _cache.SetAsync(record);
        }
        return record ?? CreateDefaultRecord(
            propertyId, roomTypeId, date);
    }

    public async Task<ReservationHoldResult> TryHoldInventoryAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date,
        int quantity, Guid transactionId,
        TimeSpan holdDuration)
    {
        const int maxRetries = 3;
        for (int attempt = 0; attempt < maxRetries; attempt++)
        {
            var record = await _inventoryRepo.GetAsync(
                propertyId, roomTypeId, date);
            if (record == null)
                return ReservationHoldResult.NotFound();

            if (record.AvailableCount < quantity)
                return ReservationHoldResult.InsufficientInventory(
                    record.AvailableCount);

            var updatedRecord = record with
            {
                HoldCount = record.HoldCount + quantity,
                Version = record.Version + 1,
                LastUpdated = DateTimeOffset.UtcNow
            };

            var success = await _inventoryRepo.TryUpdateAsync(
                record, updatedRecord);

            if (success)
            {
                await _eventBus.PublishAsync(
                    new InventoryHoldCreatedEvent
                {
                    PropertyId = propertyId,
                    RoomTypeId = roomTypeId,
                    Date = date,
                    Quantity = quantity,
                    TransactionId = transactionId,
                    ExpiresAt = DateTimeOffset.UtcNow + holdDuration
                });

                await _cache.UpdateHoldCountAsync(
                    propertyId, roomTypeId, date,
                    updatedRecord.HoldCount);

                return ReservationHoldResult.Success(
                    record.AvailableCount - quantity,
                    holdDuration);
            }

            _logger.LogWarning(
                "Inventory hold conflict on attempt {Attempt} " +
                "for {PropertyId}/{RoomTypeId}/{Date}",
                attempt + 1, propertyId, roomTypeId, date);
        }

        return ReservationHoldResult.ConcurrencyConflict();
    }

    public async Task ConfirmReservationAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date,
        int quantity, Guid transactionId)
    {
        var record = await _inventoryRepo.GetAsync(
            propertyId, roomTypeId, date);
        if (record == null)
            throw new InventoryNotFoundException();

        var updatedRecord = record with
        {
            ReservedCount = record.ReservedCount + quantity,
            HoldCount = Math.Max(0, record.HoldCount - quantity),
            Version = record.Version + 1,
            LastUpdated = DateTimeOffset.UtcNow
        };

        await _inventoryRepo.TryUpdateAsync(record, updatedRecord);
        await _cache.SetAsync(updatedRecord);

        await _eventBus.PublishAsync(new InventoryChangedEvent
        {
            PropertyId = propertyId,
            RoomTypeId = roomTypeId,
            Date = date,
            PreviousAvailable = record.AvailableCount,
            NewAvailable = updatedRecord.AvailableCount
        });
    }

    public async Task ReleaseHoldAsync(
        Guid propertyId, Guid roomTypeId, DateOnly date,
        int quantity)
    {
        var record = await _inventoryRepo.GetAsync(
            propertyId, roomTypeId, date);
        if (record == null) return;

        var updatedRecord = record with
        {
            HoldCount = Math.Max(0, record.HoldCount - quantity),
            Version = record.Version + 1,
            LastUpdated = DateTimeOffset.UtcNow
        };

        await _inventoryRepo.TryUpdateAsync(record, updatedRecord);
        await _cache.SetAsync(updatedRecord);
    }
}

Hold Expiry Mechanism

When a user begins the booking process, we place a temporary hold on the inventory for 10 minutes. This prevents other users from booking the same room while the first user completes checkout. If the booking is not confirmed within the hold window, a background worker releases the hold and restores availability. We implement this using a delayed Kafka message: when the hold is created, we publish an InventoryHoldExpiryEvent with a 10-minute delivery delay. If the hold is confirmed before the event arrives, we cancel the scheduled expiry.

Failure Mode: If the hold expiry event is lost (Kafka broker failure during the delay), holds can leak - inventory remains locked until manual intervention. To mitigate this, we run a periodic reconciliation job that scans all active holds and releases any that are older than their configured expiry window. This job runs every 5 minutes and serves as the safety net for the event-driven mechanism.

Inventory Initialization

When a property partner sets up their listing, they define room types and total physical room counts. The system pre-generates inventory records for each room type for the next 365 days. This is done as a batch job that runs nightly to add records for day 366 as the window slides forward.

C#
public class InventoryInitializationService
{
    public async Task InitializePropertyInventoryAsync(
        Guid propertyId, List<RoomType> roomTypes)
    {
        var today = DateOnly.FromDateTime(DateTime.UtcNow);
        var endDate = today.AddDays(365);
        var records = new List<InventoryRecord>();

        foreach (var roomType in roomTypes)
        {
            for (var date = today; date <= endDate; date = date.AddDays(1))
            {
                records.Add(new InventoryRecord
                {
                    PropertyId = propertyId,
                    RoomTypeId = roomType.Id,
                    Date = date,
                    TotalCount = roomType.TotalRooms,
                    ReservedCount = 0,
                    HoldCount = 0,
                    OverbookedCount = CalculateOverbookingBuffer(
                        roomType.TotalRooms),
                    Price = roomType.BasePrice,
                    CurrencyCode = "USD",
                    Version = 1,
                    LastUpdated = DateTimeOffset.UtcNow
                });
            }
        }

        await _inventoryRepo.BulkUpsertAsync(records);

        var hotRecords = records.Where(
            r => r.Date <= today.AddDays(30));
        await _cache.BulkSetAsync(hotRecords);
    }

    private int CalculateOverbookingBuffer(int totalRooms)
    {
        return (int)Math.Ceiling(totalRooms * 0.03);
    }
}

8. Pricing Engine (Dynamic Pricing)

Dynamic pricing is one of the most impactful features for hotel revenue management. Booking.com's pricing engine evaluates over 1 billion prices per day, adjusting rates based on dozens of signals including demand patterns, competitor pricing, seasonality, day-of-week effects, special events, booking pace, and guest segment. The engine must compute a final displayed price for every room type at every property for every date within the search latency budget.

Pricing Factors and Weight Model

FactorWeight RangeExample
Base Rate100% (foundation)Partner-set rack rate: $150/night
Seasonality+/-40%Christmas week: +35%, January: -25%
Day of Week+/-15%Friday/Saturday: +12%, Tuesday: -8%
Occupancy Rate+/-50%90% occupied: +40%, 30% occupied: -30%
Demand Velocity+/-25%Booking pace 2x normal: +20%
Competitor Prices+/-20%Competitors 15% lower: -10%
Special Events+/-60%Conference in city: +45%
Length of Stay+/-10%7+ nights: -8% discount
Lead Time+/-15%Same-day: -12% (distress pricing)
User Segment+/-10%Loyalty platinum: -5%

Pricing Service Implementation

C#
public class DynamicPricingEngine
{
    private readonly IPricingRuleStore _ruleStore;
    private readonly ICompetitorPriceFeed _competitorFeed;
    private readonly IDemandSignalStore _demandStore;

    public async Task<PriceResult> CalculatePriceAsync(
        PricingRequest request)
    {
        var baseRate = await GetBaseRateAsync(
            request.PropertyId, request.RoomTypeId);
        var rules = await _ruleStore.GetActiveRulesAsync(
            request.PropertyId);

        var priceBreakdown = new PriceBreakdown
        {
            BaseRate = baseRate,
            Modifiers = new List<PriceModifier>()
        };

        var seasonality = await GetSeasonalityModifierAsync(
            request.PropertyId, request.CheckIn, request.CheckOut);
        ApplyModifier(priceBreakdown, "Seasonality", seasonality);

        var dowModifier = CalculateDayOfWeekModifier(request.CheckIn);
        ApplyModifier(priceBreakdown, "DayOfWeek", dowModifier);

        var occupancyModifier = await GetOccupancyModifierAsync(
            request.PropertyId, request.CheckIn, request.CheckOut);
        ApplyModifier(priceBreakdown, "Occupancy", occupancyModifier);

        var demandModifier = await GetDemandModifierAsync(
            request.PropertyId, request.CheckIn);
        ApplyModifier(priceBreakdown, "DemandVelocity", demandModifier);

        var competitorModifier = await GetCompetitorModifierAsync(
            request.PropertyId, request.RoomTypeId, request.CheckIn);
        ApplyModifier(priceBreakdown, "CompetitorPrice",
            competitorModifier);

        var eventModifier = await GetEventModifierAsync(
            request.PropertyId, request.CheckIn);
        ApplyModifier(priceBreakdown, "SpecialEvent", eventModifier);

        var losModifier = CalculateLengthOfStayModifier(
            request.TotalNights);
        ApplyModifier(priceBreakdown, "LengthOfStay", losModifier);

        var lastMinuteModifier = CalculateLeadTimeModifier(
            request.CheckIn);
        ApplyModifier(priceBreakdown, "LeadTime", lastMinuteModifier);

        var finalPrice = ApplyConstraints(
            priceBreakdown.CalculateTotal(),
            baseRate,
            rules.PriceFloorPercent,
            rules.PriceCeilingPercent);

        return new PriceResult
        {
            PricePerNight = finalPrice,
            TotalPrice = finalPrice * request.TotalNights,
            CurrencyCode = request.CurrencyCode,
            Breakdown = priceBreakdown,
            CalculatedAt = DateTimeOffset.UtcNow,
            ValidUntil = DateTimeOffset.UtcNow.AddMinutes(15)
        };
    }

    private async Task<PriceModifier> GetOccupancyModifierAsync(
        Guid propertyId, DateOnly checkIn, DateOnly checkOut)
    {
        var occupancyRate = await _demandStore.GetOccupancyRateAsync(
            propertyId, checkIn, checkOut);

        var modifier = occupancyRate switch
        {
            < 0.30m => -0.30m,
            < 0.50m => -0.10m + (occupancyRate - 0.30m) * 0.75m,
            < 0.70m => 0.05m + (occupancyRate - 0.50m) * 0.50m,
            < 0.85m => 0.15m + (occupancyRate - 0.70m) * 0.67m,
            < 0.95m => 0.25m + (occupancyRate - 0.85m) * 2.50m,
            _ => 0.50m + (occupancyRate - 0.95m) * 6.00m
        };

        return new PriceModifier
        {
            Name = "Occupancy",
            Factor = modifier,
            Description = $"Occupancy: {occupancyRate:P0}, " +
                          $"Modifier: {modifier:P0}"
        };
    }

    private decimal ApplyConstraints(
        decimal calculatedPrice, decimal baseRate,
        decimal floorPercent, decimal ceilingPercent)
    {
        var floor = baseRate * (1 + floorPercent);
        var ceiling = baseRate * (1 + ceilingPercent);
        return Math.Max(floor, Math.Min(ceiling, calculatedPrice));
    }
}
Price Consistency Challenge: Dynamic pricing creates a consistency challenge: a user sees a price in search results, but by the time they reach checkout, the price may have changed. We solve this with a "price lock" mechanism: when a user views a property, we lock the displayed price for 15 minutes. This is tracked via a PriceLockId stored in the user's session. The booking service verifies the price lock during checkout and uses the locked price rather than recalculating.

Batch Price Computation

During search, we need to compute prices for multiple room types across multiple properties. Rather than calling the pricing engine per room type, we batch the computation. A background process recalculates prices for all active inventory every 5 minutes and stores the results in a Redis hash. The search path reads pre-computed prices from Redis, achieving O(1) lookup per room type.

C#
public class BatchPriceComputationWorker : BackgroundService
{
    private readonly DynamicPricingEngine _pricingEngine;
    private readonly IInventoryPartitioner _partitioner;
    private readonly PriceCacheWriter _priceCache;

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var partitions = await _partitioner
                .GetStalePartitionsAsync(
                    maxAge: TimeSpan.FromMinutes(5));

            var parallelOptions = new ParallelOptions
            {
                MaxDegreeOfParallelism = 32,
                CancellationToken = stoppingToken
            };

            await Parallel.ForEachAsync(partitions, parallelOptions,
                async (partition, ct) =>
                {
                    var records = await GetRecordsForPartitionAsync(
                        partition, ct);

                    var priceTasks = records.Select(async record =>
                    {
                        var price = await _pricingEngine
                            .CalculatePriceAsync(
                                new PricingRequest
                                {
                                    PropertyId = record.PropertyId,
                                    RoomTypeId = record.RoomTypeId,
                                    CheckIn = record.Date,
                                    CheckOut = record.Date.AddDays(1),
                                    TotalNights = 1,
                                    OccupancyRate =
                                        CalculateOccupancyRate(record),
                                    CurrencyCode = record.CurrencyCode
                                }, ct);
                        return (record, price);
                    });

                    var results = await Task.WhenAll(priceTasks);

                    await _priceCache.BulkUpdateAsync(
                        results.Select(r => new PriceCacheEntry
                        {
                            PropertyId = r.record.PropertyId,
                            RoomTypeId = r.record.RoomTypeId,
                            Date = r.record.Date,
                            Price = r.price.PricePerNight,
                            Currency = r.price.CurrencyCode,
                            ComputedAt = r.price.CalculatedAt
                        }), ct);
                });

            await Task.Delay(TimeSpan.FromMinutes(5),
                stoppingToken);
        }
    }
}

9. Booking State Machine

The booking lifecycle follows a well-defined state machine with strict transition rules. Each state transition triggers side effects: sending confirmation emails, updating inventory, notifying the property, processing payments, or syncing to external channels. Understanding this state machine is critical because bugs in state transitions lead to the most damaging customer-facing issues - lost bookings, incorrect charges, or rooms unavailable to the correct guest.

stateDiagram-v2 [*] --> Pending: User initiates booking Pending --> Confirmed: Payment successful Pending --> Cancelled: Payment failed or User cancels Confirmed --> CheckedIn: Guest arrives at property Confirmed --> Cancelled: User cancels within policy Confirmed --> NoShow: Check-in date passes CheckedIn --> CheckedOut: Guest departs CheckedOut --> Refunded: Full refund processed Cancelled --> Refunded: Full refund applicable NoShow --> [*] Refunded --> [*]

State Transition Implementation

C#
public class BookingStateMachine
{
    private static readonly Dictionary<BookingStatus,
        HashSet<BookingStatus>> _allowedTransitions = new()
    {
        [BookingStatus.Pending] = new()
        {
            BookingStatus.Confirmed,
            BookingStatus.Cancelled
        },
        [BookingStatus.Confirmed] = new()
        {
            BookingStatus.CheckedIn,
            BookingStatus.Cancelled,
            BookingStatus.NoShow,
            BookingStatus.ModificationPending
        },
        [BookingStatus.ModificationPending] = new()
        {
            BookingStatus.Confirmed
        },
        [BookingStatus.CheckedIn] = new()
        {
            BookingStatus.CheckedOut
        },
        [BookingStatus.CheckedOut] = new()
        {
            BookingStatus.Refunded,
            BookingStatus.PartiallyRefunded
        },
        [BookingStatus.Cancelled] = new()
        {
            BookingStatus.Refunded,
            BookingStatus.PartiallyRefunded
        },
        [BookingStatus.NoShow] = new()
        {
            BookingStatus.Refunded
        },
    };

    private readonly BookingRepository _bookingRepo;
    private readonly IEventBus _eventBus;

    public async Task TransitionAsync(
        Guid bookingId, BookingStatus targetStatus,
        string reason, Guid? performedBy = null)
    {
        var booking = await _bookingRepo.GetByIdAsync(bookingId)
            ?? throw new BookingNotFoundException(bookingId);

        if (!_allowedTransitions.TryGetValue(
            booking.Status, out var allowed)
            || !allowed.Contains(targetStatus))
        {
            throw new InvalidBookingTransitionException(
                booking.Status, targetStatus, bookingId);
        }

        var previousStatus = booking.Status;
        booking.Status = targetStatus;
        booking.Version++;

        switch (targetStatus)
        {
            case BookingStatus.Confirmed:
                booking.ConfirmedAt = DateTimeOffset.UtcNow;
                await _eventBus.PublishAsync(
                    new BookingConfirmedEvent
                    {
                        BookingId = bookingId,
                        PreviousStatus = previousStatus
                    });
                break;

            case BookingStatus.Cancelled:
                booking.CancelledAt = DateTimeOffset.UtcNow;
                await _eventBus.PublishAsync(
                    new BookingCancelledEvent
                    {
                        BookingId = bookingId,
                        Reason = reason,
                        PreviousStatus = previousStatus
                    });
                break;

            case BookingStatus.CheckedIn:
                booking.CheckedInAt = DateTimeOffset.UtcNow;
                await _eventBus.PublishAsync(
                    new GuestCheckedInEvent
                    {
                        BookingId = bookingId
                    });
                break;

            case BookingStatus.CheckedOut:
                booking.CheckedOutAt = DateTimeOffset.UtcNow;
                await _eventBus.PublishAsync(
                    new GuestCheckedOutEvent
                    {
                        BookingId = bookingId
                    });
                break;
        }

        await _bookingRepo.UpdateAsync(booking);
    }
}

Booking Confirmation Flow

The full confirmation flow from Pending to Confirmed involves multiple steps that must execute atomically from the user's perspective. The orchestrating service coordinates these steps using the Saga pattern with compensating transactions:

  1. Hold Inventory: Place a 10-minute hold on the selected rooms. If this fails, return "sold out" to the user.
  2. Lock Price: Store the quoted price with a 15-minute expiry. This prevents price changes during checkout.
  3. Process Payment: Authorize (but not capture) the payment amount. This places a hold on the guest's card.
  4. Confirm Booking: Create the booking record, transition to Confirmed status, and capture the payment.
  5. Release Hold / Confirm Inventory: Convert the inventory hold to a confirmed reservation.
  6. Notify: Send confirmation email to the guest and notification to the property.
  7. Sync Channels: Publish inventory change to channel manager for OTA sync.

If any step fails after payment authorization, we execute compensating transactions: cancel the booking, release the inventory hold, and void the payment authorization. The entire flow must complete within 30 seconds to avoid user frustration.

C#
public class BookingOrchestrator
{
    private readonly InventoryServiceClient _inventoryClient;
    private readonly PricingServiceClient _pricingClient;
    private readonly PaymentServiceClient _paymentClient;
    private readonly BookingRepository _bookingRepo;
    private readonly IEventBus _eventBus;

    public async Task<BookingResult> CreateBookingAsync(
        CreateBookingRequest request)
    {
        var transactionId = Guid.NewGuid();
        var compensations = new List<Func<Task>>();

        try
        {
            foreach (var room in request.Rooms)
            {
                foreach (var date in EnumerateDates(
                    request.CheckIn, request.CheckOut))
                {
                    var holdResult = await _inventoryClient
                        .TryHoldAsync(new HoldRequest
                        {
                            PropertyId = request.PropertyId,
                            RoomTypeId = room.RoomTypeId,
                            Date = date,
                            Quantity = room.Quantity,
                            TransactionId = transactionId,
                            HoldDuration = TimeSpan.FromMinutes(10)
                        });

                    if (!holdResult.Success)
                        throw new InventoryHoldFailedException(
                            $"{room.RoomTypeId} unavailable on {date}");

                    compensations.Add(() =>
                        _inventoryClient.ReleaseHoldAsync(
                            request.PropertyId, room.RoomTypeId,
                            date, room.Quantity, transactionId));
                }
            }

            var priceLock = await _pricingClient.LockPriceAsync(
                new PriceLockRequest
                {
                    PropertyId = request.PropertyId,
                    Rooms = request.Rooms,
                    CheckIn = request.CheckIn,
                    CheckOut = request.CheckOut,
                    LockDuration = TimeSpan.FromMinutes(15)
                });

            compensations.Add(() =>
                _pricingClient.ReleasePriceLockAsync(
                    priceLock.LockId));

            var authResult = await _paymentClient.AuthorizeAsync(
                new PaymentAuthorizationRequest
                {
                    Amount = priceLock.TotalAmount,
                    Currency = priceLock.CurrencyCode,
                    PaymentMethodId = request.PaymentMethodId,
                    TransactionId = transactionId
                });

            if (!authResult.Authorized)
                throw new PaymentAuthorizationException(
                    authResult.FailureReason);

            compensations.Add(() =>
                _paymentClient.VoidAuthorizationAsync(
                    authResult.AuthorizationId));

            var booking = new Booking
            {
                Id = Guid.NewGuid(),
                ConfirmationCode = GenerateConfirmationCode(),
                PropertyId = request.PropertyId,
                UserId = request.UserId,
                Rooms = request.Rooms.Select(r => new BookedRoom
                {
                    RoomTypeId = r.RoomTypeId,
                    Quantity = r.Quantity,
                    PricePerNight =
                        priceLock.RoomPrices[r.RoomTypeId],
                    Guests = r.Guests
                }).ToList(),
                CheckIn = request.CheckIn,
                CheckOut = request.CheckOut,
                TotalNights = request.CheckOut.DayNumber
                              - request.CheckIn.DayNumber,
                Status = BookingStatus.Confirmed,
                TotalPrice = priceLock.TotalAmount,
                CurrencyCode = priceLock.CurrencyCode,
                CreatedAt = DateTimeOffset.UtcNow,
                ConfirmedAt = DateTimeOffset.UtcNow
            };

            await _bookingRepo.CreateAsync(booking);
            await _paymentClient.CaptureAsync(
                authResult.AuthorizationId);

            foreach (var room in request.Rooms)
            {
                foreach (var date in EnumerateDates(
                    request.CheckIn, request.CheckOut))
                {
                    await _inventoryClient.ConfirmReservationAsync(
                        request.PropertyId, room.RoomTypeId,
                        date, room.Quantity, transactionId);
                }
            }

            await _eventBus.PublishAsync(new BookingCreatedEvent
            {
                BookingId = booking.Id,
                ConfirmationCode = booking.ConfirmationCode
            });

            return BookingResult.Success(booking);
        }
        catch (Exception ex)
        {
            foreach (var compensation in
                compensations.AsEnumerable().Reverse())
            {
                try { await compensation(); }
                catch { /* best-effort compensation */ }
            }

            return BookingResult.Failure(ex.Message);
        }
    }
}

10. Payment Processing

Payment processing in a hotel booking system is more complex than standard e-commerce due to the delayed nature of hotel stays. Unlike buying a product that ships immediately, hotel payments involve authorization at booking time, potential captures at different times (prepaid vs pay-at-hotel), multi-currency conversions, and refund flows that may happen days or weeks after the original charge. PCI DSS compliance is mandatory, and the payment service must be isolated in its own security perimeter.

Payment Flow Types

FlowAuthorizationCaptureRefund
Prepaid (non-refundable)At bookingImmediatelyExceptional only
Prepaid (refundable)At bookingImmediatelyBased on policy
Pay at propertyAt booking (hold)At check-inVia property
Deposit + BalanceDeposit at bookingBalance at check-inPartial based on policy
Pay later (installments)First installmentScheduled capturesRemaining cancelled
C#
public class PaymentService
{
    private readonly IPaymentGateway _gateway;
    private readonly ICurrencyExchangeService _exchangeService;
    private readonly PaymentRepository _paymentRepo;

    public async Task<PaymentResult> ProcessPaymentAsync(
        PaymentRequest request)
    {
        var (amount, exchangeRate) = await _exchangeService
            .ConvertAsync(
                request.Amount, request.CurrencyCode, "USD");

        var paymentRecord = new PaymentRecord
        {
            Id = Guid.NewGuid(),
            BookingId = request.BookingId,
            Amount = amount,
            OriginalAmount = request.Amount,
            OriginalCurrency = request.CurrencyCode,
            ExchangeRate = exchangeRate,
            Gateway = "stripe",
            Status = PaymentStatus.Pending,
            CreatedAt = DateTimeOffset.UtcNow
        };

        try
        {
            PaymentGatewayResult gatewayResult;

            switch (request.FlowType)
            {
                case PaymentFlowType.AuthorizeOnly:
                    gatewayResult = await _gateway.AuthorizeAsync(
                        new GatewayAuthorizationRequest
                        {
                            Amount = amount,
                            Currency = "USD",
                            PaymentToken = request.PaymentToken,
                            IdempotencyKey = paymentRecord.Id.ToString(),
                            Metadata = new Dictionary<string, string>
                            {
                                ["booking_id"] =
                                    request.BookingId.ToString(),
                                ["capture_mode"] = "manual"
                            }
                        });
                    paymentRecord.GatewayAuthorizationId =
                        gatewayResult.AuthorizationId;
                    paymentRecord.Status = PaymentStatus.Authorized;
                    break;

                case PaymentFlowType.AuthorizeAndCapture:
                    gatewayResult = await _gateway.ChargeAsync(
                        new GatewayChargeRequest
                        {
                            Amount = amount,
                            Currency = "USD",
                            PaymentToken = request.PaymentToken,
                            IdempotencyKey = paymentRecord.Id.ToString()
                        });
                    paymentRecord.GatewayTransactionId =
                        gatewayResult.TransactionId;
                    paymentRecord.Status = PaymentStatus.Captured;
                    break;

                case PaymentFlowType.CaptureExisting:
                    gatewayResult = await _gateway.CaptureAsync(
                        request.GatewayAuthorizationId, amount);
                    paymentRecord.Status = PaymentStatus.Captured;
                    break;

                default:
                    throw new NotSupportedException(
                        $"Flow {request.FlowType} not supported");
            }

            await _paymentRepo.CreateAsync(paymentRecord);
            return PaymentResult.Success(paymentRecord);
        }
        catch (PaymentDeclinedException ex)
        {
            paymentRecord.Status = PaymentStatus.Declined;
            paymentRecord.FailureReason = ex.Message;
            await _paymentRepo.CreateAsync(paymentRecord);
            return PaymentResult.Declined(ex.Message);
        }
    }

    public async Task<RefundResult> ProcessRefundAsync(
        RefundRequest request)
    {
        var originalPayment = await _paymentRepo
            .GetByIdAsync(request.OriginalPaymentId);

        var refundAmount = CalculateRefundAmount(
            originalPayment, request.Booking,
            request.CancelledAt);

        if (refundAmount <= 0)
            return RefundResult.NoRefundApplicable();

        var (refundInOriginal, _) = await _exchangeService
            .ConvertAsync(refundAmount, "USD",
                originalPayment.OriginalCurrency);

        var gatewayResult = await _gateway.RefundAsync(
            new GatewayRefundRequest
            {
                TransactionId =
                    originalPayment.GatewayTransactionId,
                Amount = refundAmount,
                Reason = request.Reason,
                IdempotencyKey = Guid.NewGuid().ToString()
            });

        var refundRecord = new RefundRecord
        {
            Id = Guid.NewGuid(),
            BookingId = request.BookingId,
            OriginalPaymentId = originalPayment.Id,
            Amount = refundAmount,
            OriginalAmount = refundInOriginal,
            GatewayRefundId = gatewayResult.RefundId,
            Status = PaymentStatus.Refunded,
            CreatedAt = DateTimeOffset.UtcNow
        };

        await _paymentRepo.CreateRefundAsync(refundRecord);
        return RefundResult.Success(refundRecord);
    }
}
PCI Compliance: The payment service must never store raw card numbers, CVV codes, or magnetic stripe data. All card data is tokenized by the payment gateway during the initial client-side tokenization step. The application only handles payment tokens, which are gateway-specific opaque strings that cannot be used to reconstruct card details. The payment service runs in its own PCI-scoped network segment with additional encryption at rest.

11. Cancellation & Refund Policy

Cancellation policies are a core differentiator in the hotel booking industry. Booking.com alone offers millions of different cancellation policies because each hotel partner configures their own. The system must support flexible rule definitions that consider the cancellation timing relative to check-in, the rate plan selected, special promotional rates, loyalty tier benefits, and channel-specific policies. Getting refund calculations wrong leads to guest disputes, chargebacks, and regulatory issues.

Policy Types

Policy TypeRefund RulesCommon Use Case
Free Cancellation100% refund up to 24-48 hours before check-inStandard flexible rate
Late Free Cancellation100% refund up to 7 days before check-inPeak season bookings
Non-RefundableNo refund under any circumstanceDiscounted prepaid rate
Tiered Refund100% then 50% then 0% based on daysExtended stay policies
First Night OnlyFirst night charged, remaining refundedGroup bookings
Percentage-BasedFixed percentage refund based on timingBoutique hotels
C#
public class CancellationPolicyEngine
{
    public RefundCalculation CalculateRefund(
        Booking booking, DateOnly cancellationDate,
        DateTimeOffset cancellationTimestamp)
    {
        var policy = booking.CancellationPolicy;
        var daysUntilCheckIn = booking.CheckIn.DayNumber
                               - cancellationDate.DayNumber;

        if (policy.Type == CancellationPolicyType.NonRefundable)
        {
            return new RefundCalculation
            {
                RefundAmount =
                    Money.Zero(booking.CurrencyCode),
                CancellationFee = booking.TotalPrice,
                Reason = "Non-refundable rate",
                IsWithinFreeCancellationWindow = false
            };
        }

        if (daysUntilCheckIn >= policy.FreeCancellationDaysBeforeCheckIn)
        {
            return new RefundCalculation
            {
                RefundAmount = booking.TotalPrice,
                CancellationFee =
                    Money.Zero(booking.CurrencyCode),
                Reason = $"Free cancellation: " +
                    $"{daysUntilCheckIn} days before check-in " +
                    $"(window: " +
                    $"{policy.FreeCancellationDaysBeforeCheckIn} days)",
                IsWithinFreeCancellationWindow = true
            };
        }

        var refundPercentage = policy.Type switch
        {
            CancellationPolicyType.Tiered =>
                CalculateTieredRefund(
                    policy.TieredRules, daysUntilCheckIn),
            CancellationPolicyType.FirstNightOnly =>
                CalculateFirstNightRefund(booking),
            CancellationPolicyType.PercentageBased =>
                CalculatePercentageRefund(
                    policy.PercentageRules, daysUntilCheckIn),
            _ => 0m
        };

        var refundAmount =
            booking.TotalPrice * refundPercentage;
        var cancellationFee =
            booking.TotalPrice - refundAmount;

        return new RefundCalculation
        {
            RefundAmount = refundAmount,
            CancellationFee = cancellationFee,
            RefundPercentage = refundPercentage,
            DaysBeforeCheckIn = daysUntilCheckIn,
            Reason = $"Refund {refundPercentage:P0}: " +
                $"cancelled {daysUntilCheckIn} days before check-in",
            IsWithinFreeCancellationWindow = false
        };
    }

    private decimal CalculateTieredRefund(
        List<TieredRule> rules, int daysBeforeCheckIn)
    {
        foreach (var rule in
            rules.OrderByDescending(r => r.DaysThreshold))
        {
            if (daysUntilCheckIn >= rule.DaysThreshold)
                return rule.RefundPercentage;
        }
        return 0m;
    }

    private decimal CalculateFirstNightRefund(Booking booking)
    {
        var nightlyRate =
            booking.TotalPrice / booking.TotalNights;
        var remainingNights = booking.TotalNights - 1;
        return remainingNights > 0
            ? (nightlyRate * remainingNights)
              / booking.TotalPrice
            : 0m;
    }
}
Cancellation Processing Pipeline: When a cancellation is initiated, the system doesn't immediately release the inventory back to availability. Instead, it enters a "cancelled-pending" state for a configurable grace period (typically 1 hour) during which the cancellation can be reversed. After the grace period, a background job releases the inventory, processes the refund through the payment gateway, and notifies the channel manager. This grace period reduces the operational cost of accidental cancellations.

12. Multi-Property Search & Filtering

The search experience for a platform like Booking.com must handle millions of properties across thousands of cities, with sophisticated filtering, sorting, and personalization. The challenge is combining geographic proximity search with date-based availability, price ranges, property attributes, and user preferences - all within a sub-300ms latency budget.

Search Facets and Filters

Filter CategoryExamplesIndex Strategy
GeographicCity, neighborhood, distanceGeo_point field
Date-basedCheck-in/out, length of stayDenormalized availability array
PropertyType, star rating, brandKeyword fields
AmenitiesPool, WiFi, parking, spaKeyword array field
PricingPrice range per nightComputed price field
Guest RatingOverall and category scoresFloat fields
Booking PoliciesFree cancellation, breakfastBoolean fields
Room FeaturesBed type, room size, viewNested room type fields
C#
public class AdvancedSearchService
{
    private readonly IElasticClient _elastic;
    private readonly SearchFilterProcessor _filterProcessor;
    private readonly SearchPersonalizer _personalizer;

    public async Task<SearchResponse> SearchAsync(
        AdvancedSearchRequest request)
    {
        var processedFilters = _filterProcessor
            .Process(request.Filters);

        var searchDescriptor =
            new SearchDescriptor<PropertySearchDocument>()
                .Index("properties")
                .Size(request.PageSize)
                .From((request.Page - 1) * request.PageSize)
                .TrackTotalHits(true)
                .Query(q => q
                    .Bool(b =>
                        b.Filter(
                            f => f.Nested(n => n
                                .Path(p => p.RoomTypes)
                                .Query(nq => nq.Bool(nb =>
                                    nb.Filter(
                                        nf => nf.Range(r =>
                                            r.Field("roomTypes.availableRooms")
                                             .Gte(request.RoomsNeeded)),
                                        nf => nf.Term(t =>
                                            t.Field("roomTypes.dateRangeCovered")
                                             .Value($"{request.CheckIn}_{request.CheckOut}"))
                                    )
                                ))),
                            f => processedFilters.PriceRange != null
                                ? f.Range(r =>
                                    r.NumberRange(nr =>
                                        nr.Field(p => p.MinPrice)
                                         .Gte(processedFilters.PriceRange.Min)
                                         .Lte(processedFilters.PriceRange.Max)))
                                : f.MatchAll(),
                            f => processedFilters.StarRatings?.Any() == true
                                ? f.Terms(t =>
                                    t.Field(p => p.StarRating)
                                     .Terms(processedFilters.StarRatings))
                                : f.MatchAll(),
                            f => processedFilters.MinRating.HasValue
                                ? f.Range(r =>
                                    r.NumberRange(nr =>
                                        nr.Field(p => p.ReviewScore)
                                         .Gte(processedFilters.MinRating)))
                                : f.MatchAll()
                        )
                        .Must(
                            m => m.GeoDistance(g =>
                                g.Field(p => p.Location)
                                 .Distance(request.RadiusKm ?? 10,
                                     DistanceUnit.Kilometers)
                                 .Origin(new GeoLocation(
                                     request.Lat, request.Lng))
                                 .Boost(1.5)),
                            m => !string.IsNullOrEmpty(request.Query)
                                ? m.MultiMatch(mm =>
                                    mm.Fields(f =>
                                        f.Field(p => p.Name, 3.0)
                                         .Field(p => p.City, 2.0)
                                         .Field(p => p.Country))
                                     .Query(request.Query)
                                     .Type(TextQueryType.BestFields))
                                : m.MatchAll()
                        )
                    ))
                .Sort(s => ApplySort(s, request.SortBy,
                    request.Lat, request.Lng));

        var response = await _elastic.SearchAsync<
            PropertySearchDocument>(searchDescriptor);

        var results = response.Documents
            .Select(doc => MapToSearchResult(doc, request))
            .ToList();

        results = _personalizer.Personalize(
            results, request.UserId);

        return new SearchResponse
        {
            TotalResults = response.Total,
            Page = request.Page,
            PageSize = request.PageSize,
            Results = results,
            Facets = ExtractFacets(response.Aggregations),
            AppliedFilters = processedFilters
        };
    }
}
Search Result Caching: We implement a multi-layer caching strategy for search results. The first layer is a CDN-level cache for identical search queries (same city, same dates, same filters) - this handles the "everyone searching Paris for New Year's" scenario and can absorb 30-40% of search traffic. The second layer is an application-level LRU cache with a 60-second TTL keyed by a hash of the search parameters. The third layer is the Elasticsearch index itself, which benefits from OS-level page cache for hot data.

13. Review & Rating System

Reviews and ratings are a critical trust signal in the hotel booking industry. Booking.com displays review scores for over 28 million properties, with guests rating stays on a 10-point scale across six subcategories: Staff, Facilities, Cleanliness, Comfort, Value for Money, and Free WiFi. The review system must prevent fake reviews, handle multi-language content, support moderation workflows, and aggregate scores accurately with proper weighting.

Review Data Model

C#
public class Review
{
    public Guid Id { get; set; }
    public Guid BookingId { get; set; }
    public Guid PropertyId { get; set; }
    public Guid UserId { get; set; }
    public int OverallScore { get; set; }
    public ReviewCategoryScores Scores { get; set; }
    public string Title { get; set; }
    public string Body { get; set; }
    public string Language { get; set; }
    public ReviewStatus Status { get; set; }
    public bool IsVerified { get; set; }
    public string StayType { get; set; }
    public DateOnly StayDate { get; set; }
    public List<ReviewResponse> Responses { get; set; }
    public int HelpfulVotes { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
}

public class ReviewCategoryScores
{
    public int Staff { get; set; }
    public int Facilities { get; set; }
    public int Cleanliness { get; set; }
    public int Comfort { get; set; }
    public int ValueForMoney { get; set; }
    public int FreeWifi { get; set; }
    public int Location { get; set; }
}

public class PropertyReviewSummary
{
    public Guid PropertyId { get; set; }
    public double OverallScore { get; set; }
    public int TotalReviewCount { get; set; }
    public ReviewCategoryScores AverageScores { get; set; }
    public Dictionary<int, int> ScoreDistribution { get; set; }
    public ReviewHighlights Highlights { get; set; }
    public DateTimeOffset LastUpdated { get; set; }
}

Score Aggregation Algorithm

The overall property score is not a simple arithmetic mean. Booking.com uses a Bayesian average that weights recent reviews more heavily, applies minimum review count thresholds, and adjusts for individual reviewer tendencies.

C#
public class ReviewAggregationService
{
    private readonly ReviewRepository _reviewRepo;
    private readonly IRedisCache _cache;

    public async Task<PropertyReviewSummary>
        CalculatePropertyScoreAsync(Guid propertyId)
    {
        var reviews = await _reviewRepo
            .GetPublishedReviewsAsync(propertyId);

        if (reviews.Count == 0)
            return CreateEmptySummary(propertyId);

        // Bayesian average (C=10, m=6.0)
        const int minimumReviews = 10;
        const double globalMean = 6.0;
        const double smoothingFactor = 10.0;

        var rawAverage = reviews.Average(r => r.OverallScore);
        var reviewCount = reviews.Count;
        var bayesianScore =
            (smoothingFactor * globalMean
             + reviewCount * rawAverage)
            / (smoothingFactor + reviewCount);

        // Recency weighting (exponential decay)
        var weightedSum = 0.0;
        var weightTotal = 0.0;
        var now = DateTimeOffset.UtcNow;

        foreach (var review in reviews)
        {
            var ageMonths =
                (now - review.CreatedAt).TotalDays / 30.0;
            var recencyWeight =
                Math.Exp(-0.05 * ageMonths);
            weightedSum +=
                review.OverallScore * recencyWeight;
            weightTotal += recencyWeight;
        }

        var recencyWeightedScore = weightTotal > 0
            ? weightedSum / weightTotal
            : bayesianScore;

        // Combine Bayesian and recency-weighted scores
        var finalScore = 0.6 * recencyWeightedScore
                         + 0.4 * bayesianScore;
        finalScore = Math.Round(finalScore, 1);

        // Calculate category averages
        var categoryScores = new ReviewCategoryScores
        {
            Staff = (int)reviews.Average(
                r => r.Scores.Staff),
            Facilities = (int)reviews.Average(
                r => r.Scores.Facilities),
            Cleanliness = (int)reviews.Average(
                r => r.Scores.Cleanliness),
            Comfort = (int)reviews.Average(
                r => r.Scores.Comfort),
            ValueForMoney = (int)reviews.Average(
                r => r.Scores.ValueForMoney),
            FreeWifi = (int)reviews.Average(
                r => r.Scores.FreeWifi),
            Location = (int)reviews.Average(
                r => r.Scores.Location)
        };

        var distribution = Enumerable.Range(1, 10)
            .ToDictionary(score => score,
                score => reviews.Count(
                    r => r.OverallScore == score));

        var summary = new PropertyReviewSummary
        {
            PropertyId = propertyId,
            OverallScore = finalScore,
            TotalReviewCount = reviewCount,
            AverageScores = categoryScores,
            ScoreDistribution = distribution,
            LastUpdated = DateTimeOffset.UtcNow
        };

        await _cache.SetAsync(
            $"review_summary:{propertyId}",
            summary,
            TimeSpan.FromMinutes(30));

        return summary;
    }
}
Fraud Prevention: Review fraud is a significant problem in the hotel industry. The system implements multiple anti-fraud measures: reviews can only be submitted for verified bookings, users are restricted to one review per stay, IP-based clustering detects review bombing patterns, natural language processing flags suspiciously similar reviews across properties, and a trust score for each reviewer influences how much their reviews contribute to the overall property score.

14. Calendar Synchronization

Calendar synchronization is the mechanism that keeps inventory data consistent across all booking channels. When a room is booked on the hotel's direct website, the inventory must be reflected on Booking.com, Expedia, Agoda, and every other connected channel within seconds. Conversely, when a booking arrives from an OTA, the hotel's direct website and all other channels must be updated. Failure to synchronize properly leads to overbookings - the most damaging operational failure in the hotel industry.

Synchronization Architecture

graph LR Direct["Direct Website Booking"] --> InventorySvc BookingCom["Booking.com Booking"] --> ChannelMgr["Channel Manager"] Expedia["Expedia Booking"] --> ChannelMgr Agoda["Agoda Booking"] --> ChannelMgr PMS["PMS - Opera or Cloudbeds"] --> ChannelMgr InventorySvc --> EventBus["Kafka Event Bus"] EventBus --> ChannelMgr ChannelMgr --> OutBookingCom["Booking.com Push"] ChannelMgr --> OutExpedia["Expedia Push"] ChannelMgr --> OutAgoda["Agoda Push"] ChannelMgr --> OutPMS["PMS Update"] InventorySvc --> InventoryDB[("Inventory Database")]

Channel Manager Protocol

The channel manager communicates with OTAs using industry-standard protocols. Most modern OTAs support either the OpenHotelCollaboration (OHC) API or the OTA/HTNG standard. The channel manager maintains a per-channel rate limiter and handles the varying response times and reliability characteristics of each OTA's API.

C#
public class ChannelManagerService : BackgroundService
{
    private readonly IChannelProvider[] _channelProviders;
    private readonly IEventBus _eventBus;
    private readonly SemaphoreSlim _rateLimiter;

    public ChannelManagerService(
        IChannelProvider[] channelProviders,
        IEventBus eventBus)
    {
        _channelProviders = channelProviders;
        _eventBus = eventBus;
        _rateLimiter = new SemaphoreSlim(1000, 1000);
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        await _eventBus.SubscribeAsync<InventoryChangedEvent>(
            async evt =>
            {
                var syncRequest = new ChannelSyncRequest
                {
                    PropertyId = evt.PropertyId,
                    RoomTypeId = evt.RoomTypeId,
                    Date = evt.Date,
                    NewAvailableCount = evt.NewAvailable,
                    Timestamp = DateTimeOffset.UtcNow
                };

                var tasks = _channelProviders
                    .Where(p => p.IsEnabledFor(evt.PropertyId))
                    .Select(provider =>
                        SyncToProviderAsync(provider, syncRequest));

                await Task.WhenAll(tasks);
            });
    }

    private async Task SyncToProviderAsync(
        IChannelProvider provider,
        ChannelSyncRequest request)
    {
        await _rateLimiter.WaitAsync();
        try
        {
            var retries = 3;
            for (int i = 0; i < retries; i++)
            {
                try
                {
                    await provider.UpdateAvailabilityAsync(
                        request);
                    return;
                }
                catch (ChannelApiException ex)
                    when (ex.IsTransient && i < retries - 1)
                {
                    await Task.Delay(
                        TimeSpan.FromSeconds(Math.Pow(2, i)));
                }
                catch (ChannelApiException ex)
                {
                    await PublishToRetryQueueAsync(
                        provider.Name, request, ex);
                    throw;
                }
            }
        }
        finally
        {
            _rateLimiter.Release();
        }
    }
}

public interface IChannelProvider
{
    string Name { get; }
    bool IsEnabledFor(Guid propertyId);
    Task UpdateAvailabilityAsync(
        ChannelSyncRequest request);
    Task PushRatesAsync(ChannelRatePushRequest request);
    Task PullReservationsAsync(DateTimeOffset since);
}

public class BookingComChannelProvider : IChannelProvider
{
    public string Name => "booking.com";

    public async Task UpdateAvailabilityAsync(
        ChannelSyncRequest request)
    {
        var payload = new BookingComAvailabilityUpdate
        {
            HotelId = MapToBookingComHotelId(
                request.PropertyId),
            RoomTypes = new[]
            {
                new RoomAvailability
                {
                    RoomTypeId = MapToBookingComRoomType(
                        request.RoomTypeId),
                    Dates = new[]
                    {
                        new DateAvailability
                        {
                            Date = request.Date
                                .ToString("yyyy-MM-dd"),
                            Available =
                                request.NewAvailableCount,
                            Status =
                                request.NewAvailableCount > 0
                                    ? "available"
                                    : "sold_out"
                        }
                    }
                }
            }
        };

        await _httpClient.PostAsJsonAsync(
            "https://distribution-xml.booking.com/2.0/" +
            "hotel/availability",
            payload);
    }
}
Sync Conflict Resolution: When two channels book the same room within the sync delay window (the "race condition window"), the system must have a resolution strategy. The standard approach is "first confirmed wins": whichever booking was confirmed first in the system's database takes priority. The losing channel's booking is placed in a "rejected" state, and the system attempts to find an alternative room or property for the displaced guest.

15. Overbooking Strategy

Overbooking - intentionally selling more rooms than physically exist - is a standard revenue management practice in the hotel industry. Hotels typically overbook by 3-5% based on historical no-show and cancellation rates. The goal is to achieve 100% actual occupancy by accounting for the expected percentage of guests who will not arrive. Getting this right requires sophisticated statistical modeling and careful risk management.

Overbooking Calculation Model

C#
public class OverbookingEngine
{
    private readonly IForecastingService _forecast;
    private readonly IOverbookingPolicyStore _policyStore;

    public OverbookingRecommendation
        CalculateOptimalOverbooking(
            Guid propertyId, Guid roomTypeId, DateOnly date)
    {
        var historicalData =
            _forecast.GetHistoricalPatterns(
                propertyId, roomTypeId, date);

        var noShowRate = historicalData.AverageNoShowRate;
        var cancellationRate = historicalData.CancellationRate;
        var lateCancellationRate =
            historicalData.LateCancellationRate;

        var bookingPace = _forecast.GetBookingPace(
            propertyId, date);
        var currentOccupancy =
            bookingPace.CurrentConfirmed
            / (double)bookingPace.TotalRooms;

        var expectedAttrition = noShowRate
            + cancellationRate * (1 - lateCancellationRate);

        var demandFactor = currentOccupancy switch
        {
            < 0.70m => 1.2m,
            < 0.85m => 1.0m,
            < 0.95m => 0.7m,
            _ => 0.4m
        };

        var baseOverbooking = (int)Math.Ceiling(
            bookingPace.TotalRooms
            * expectedAttrition
            * (double)demandFactor);

        var policy = _policyStore.GetPolicy(propertyId);
        var maxOverbooking = (int)Math.Ceiling(
            bookingPace.TotalRooms
            * policy.MaxOverbookingPercent);

        var optimalOverbooking = Math.Min(
            baseOverbooking, maxOverbooking);

        var overbookingRisk = CalculateRisk(
            optimalOverbooking,
            bookingPace.TotalRooms,
            noShowRate, cancellationRate);

        return new OverbookingRecommendation
        {
            PropertyId = propertyId,
            RoomTypeId = roomTypeId,
            Date = date,
            PhysicalRooms = bookingPace.TotalRooms,
            RecommendedOverbooking = optimalOverbooking,
            SellableRooms = bookingPace.TotalRooms
                            + optimalOverbooking,
            ExpectedAttritionRate = expectedAttrition,
            RiskLevel = overbookingRisk,
            Confidence = historicalData.SampleSize > 30
                ? ConfidenceLevel.High
                : ConfidenceLevel.Low
        };
    }

    private OverbookingRisk CalculateRisk(
        int overbookedCount, int totalRooms,
        decimal noShowRate, decimal cancellationRate)
    {
        var expectedShowRate = 1.0m
            - (decimal)noShowRate
            - (decimal)cancellationRate;
        var expectedShows =
            totalRooms * expectedShowRate + overbookedCount;
        var overbookingProbability = expectedShows > totalRooms
            ? (expectedShows - totalRooms) / totalRooms
            : 0;

        if (overbookingProbability > 0.15m)
            return OverbookingRisk.High;
        if (overbookingProbability > 0.05m)
            return OverbookingRisk.Medium;
        return OverbookingRisk.Low;
    }
}

Overbooking Compensation Strategy

When overbooking leads to more guests arriving than rooms available (an "walk" in hotel industry terms), the system must handle the situation gracefully:

SeverityRooms OverCompensationEscalation
Low1-2 roomsWalk to comparable hotel + transport + $50 creditFront desk manager
Medium3-5 roomsWalk + upgrade at partner hotel + $100 creditGeneral manager
High5+ roomsFull walk package + $200 credit + loyalty pointsRegional director
Regulatory Considerations: Some jurisdictions have laws governing overbooking practices. In the EU, the Package Travel Directive requires hotels to provide equivalent or better accommodation when they cannot honor a confirmed booking. The system must track regulatory requirements per country and enforce appropriate compensation minimums automatically.

16. Channel Manager (OTA Integration)

The channel manager is arguably the most operationally complex component of a hotel booking platform. It serves as the bridge between the hotel's inventory system and dozens of external booking channels, each with its own API, rate limits, data formats, and synchronization requirements. A production channel manager must handle bidirectional sync: pushing availability and rate updates outward, and pulling reservations inward from external channels.

Integration Architecture

graph TB subgraph Internal["Internal Systems"] InvSvc["Inventory Service"] RateSvc["Rate Service"] ResSvc["Reservation Service"] end subgraph Core["Channel Manager Core"] Orchestrator["Sync Orchestrator"] Queue["Sync Queue Per-Channel"] DLQ["Dead Letter Queue"] Retry["Retry Handler"] end subgraph Adapters["Channel Adapters"] BCAdapter["Booking.com Adapter"] ExpAdapter["Expedia Adapter"] AgodaAdapter["Agoda Adapter"] end subgraph External["External Channels"] BC["Booking.com API"] EX["Expedia API"] AG["Agoda API"] end InvSvc --> Orchestrator RateSvc --> Orchestrator Orchestrator --> Queue Queue --> BCAdapter Queue --> ExpAdapter Queue --> AgodaAdapter BCAdapter --> BC ExpAdapter --> EX AgodaAdapter --> AG BCAdapter --> DLQ ExpAdapter --> DLQ DLQ --> Retry Retry --> Queue BC --> ResSvc EX --> ResSvc AG --> ResSvc

Multi-Channel Rate Push

C#
public class MultiChannelRatePushService
{
    private readonly ChannelRegistry _channelRegistry;
    private readonly IRateRepository _rateRepo;
    private readonly RateTransformer _transformer;

    public async Task PushRatesToAllChannelsAsync(
        Guid propertyId, DateOnly startDate, DateOnly endDate)
    {
        var rates = await _rateRepo.GetRatesAsync(
            propertyId, startDate, endDate);
        var channels = _channelRegistry
            .GetEnabledChannels(propertyId);

        var pushTasks = channels.Select(async channel =>
        {
            try
            {
                var channelRates = _transformer
                    .TransformForChannel(
                        rates, channel.ChannelType);

                var adjustedRates = ApplyChannelPricing(
                    channelRates, channel.CommissionPercent);

                var finalRates = EnforceRateParity(
                    adjustedRates, channel.ParityRules);

                await channel.Provider.PushRatesAsync(
                    new RatePushRequest
                    {
                        PropertyId =
                            channel.MappedPropertyId,
                        Rates = finalRates,
                        EffectiveDate = startDate,
                        ExpirationDate = endDate,
                        IdempotencyKey =
                            GenerateIdempotencyKey(
                                propertyId, channel.Name,
                                startDate, endDate)
                    });
            }
            catch (Exception ex) when (IsRetryable(ex))
            {
                await PublishToRetryQueue(
                    propertyId, channel.Name, rates, ex);
            }
        });

        await Task.WhenAll(pushTasks);
    }

    private List<ChannelRate> ApplyChannelPricing(
        List<InternalRate> rates,
        decimal commissionPercent)
    {
        return rates.Select(r => new ChannelRate
        {
            RoomTypeCode = r.RoomTypeCode,
            Date = r.Date,
            Rate = r.BaseRate,
            Currency = r.Currency,
            MinStay = r.MinStay,
            MaxStay = r.MaxStay,
            Closed = !r.IsAvailable,
            CloseOnArrival = r.CloseOnArrival
        }).ToList();
    }

    private List<ChannelRate> EnforceRateParity(
        List<ChannelRate> rates, ParityRules rules)
    {
        if (!rules.EnforceParity) return rates;

        return rates.Select(r =>
        {
            if (rules.DirectWebsiteMustBeLowest
                && r.Rate < rules.DirectRate)
            {
                r.Rate = rules.DirectRate;
            }
            return r;
        }).ToList();
    }
}

Inbound Reservation Parsing

When a guest books on an OTA, the channel manager must receive the reservation, map it to internal data structures, create a booking record, and hold inventory - all within seconds.

C#
public class BookingComReservationAdapter
    : IInboundReservationAdapter
{
    public async Task<InternalBookingRequest>
        ParseReservationAsync(
            BookingComReservation otaReservation)
    {
        return new InternalBookingRequest
        {
            ExternalBookingId = otaReservation.BookingId,
            ChannelSource = "booking.com",
            PropertyId = MapToInternalPropertyId(
                otaReservation.HotelId),
            CheckIn = DateOnly.Parse(
                otaReservation.CheckInDate),
            CheckOut = DateOnly.Parse(
                otaReservation.CheckOutDate),
            Rooms = otaReservation.Rooms
                .Select(room => new InternalRoomRequest
                {
                    RoomTypeId = MapToInternalRoomType(
                        room.RoomType),
                    Quantity = room.Quantity,
                    Guests = room.Guests
                        .Select(g => new GuestDetails
                        {
                            FirstName = g.FirstName,
                            LastName = g.LastName,
                            Email = g.Email,
                            Phone = g.Phone,
                            IsPrimary = g.IsLeadGuest
                        }).ToList()
                }).ToList(),
            TotalPrice = new Money
            {
                Amount = otaReservation.TotalAmount,
                CurrencyCode = otaReservation.Currency
            },
            SpecialRequests = otaReservation.Remarks,
            ReceivedAt = DateTimeOffset.UtcNow
        };
    }
}
Rate Parity Compliance: Major OTAs enforce rate parity clauses in their contracts - the room price must be the same on all channels, or the OTA can delist the property. The system must track parity rules per channel and prevent any channel from displaying a lower price than the direct website. This is enforced both at push time (outbound rates) and via monitoring that periodically checks displayed prices across channels.

17. Reliability, Failure Modes & Disaster Recovery

A hotel booking system has different reliability requirements than a video streaming or social media platform. While a brief video buffering delay is annoying, a booking system failure can result in lost revenue, double-bookings, and damaged relationships with both guests and hotel partners. The system must prioritize consistency and correctness for the write path (bookings) while maintaining high availability for the read path (search).

Failure Mode Analysis

Failure ModeImpactDetectionMitigation
Inventory database failureNo new bookings possibleHealth checksMulti-AZ replicas, auto failover
Elasticsearch degradationStale/empty search resultsQuery latency monitoringCached results, degraded mode
Payment gateway outageCannot process paymentsError rate monitoringSecondary gateway fallback
Channel manager sync delayCross-channel overbookingsSync latency monitoringIncreased buffer, manual alerts
Kafka broker failureDelayed event processingConsumer lag monitoringMulti-broker cluster
Redis cache failureCold cache, slower searchCache hit rate monitoringDatabase fallback
Full no-show surgeMassive overbookingsOccupancy delta monitoringDynamic overbooking adjust

Circuit Breaker Pattern

C#
public class ResilientBookingService
{
    private readonly CircuitBreaker _paymentCircuit;
    private readonly CircuitBreaker _inventoryCircuit;
    private readonly RetryPolicy _retryPolicy;
    private readonly IFallbackService _fallback;

    public ResilientBookingService()
    {
        _paymentCircuit = new CircuitBreaker(
            name: "payment-gateway",
            samplingDuration: TimeSpan.FromSeconds(30),
            minimumThroughput: 10,
            failureRatio: 0.5,
            breakDuration: TimeSpan.FromSeconds(60));

        _inventoryCircuit = new CircuitBreaker(
            name: "inventory-service",
            samplingDuration: TimeSpan.FromSeconds(15),
            minimumThroughput: 20,
            failureRatio: 0.3,
            breakDuration: TimeSpan.FromSeconds(30));

        _retryPolicy = Policy
            .Handle<HttpRequestException>()
            .Or<TimeoutRejectedException>()
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: attempt =>
                    TimeSpan.FromSeconds(
                        Math.Pow(2, attempt)));
    }

    public async Task<BookingResult>
        CreateBookingResilientAsync(
            CreateBookingRequest request)
    {
        return await _retryPolicy.ExecuteAsync(async () =>
        {
            if (_inventoryCircuit.State
                == CircuitBreakerState.Open)
            {
                var cachedAvailability = await _fallback
                    .GetCachedAvailabilityAsync(
                        request.PropertyId, request.Rooms);
                if (!cachedAvailability.HasAvailability)
                    return BookingResult.Failed(
                        "No availability");
            }

            if (_paymentCircuit.State
                == CircuitBreakerState.Open)
            {
                return await _fallback
                    .QueueForDeferredPaymentAsync(request);
            }

            return await CreateBookingCoreAsync(request);
        });
    }
}

Disaster Recovery

The system operates across three AWS regions: primary (us-east-1), secondary (eu-west-1), and tertiary (ap-southeast-1). The inventory database uses synchronous replication within the primary region and asynchronous cross-region replication. In the event of a primary region failure:

  1. DNS failover: Route 53 health checks detect primary region failure and route traffic to the secondary region within 60 seconds.
  2. Inventory consistency check: The secondary region's inventory data may be slightly stale (up to 5 seconds of replication lag). The system enters a "conservative mode" that reduces the overbooking buffer and increases hold durations.
  3. In-flight booking recovery: Any bookings that were in progress during the failover are tracked via a distributed saga log. The recovery process replays incomplete sagas and either completes or rolls back each one.
  4. Channel manager recovery: Channel sync resumes from the last acknowledged sequence number, ensuring no availability update is lost.
C#
public class DisasterRecoveryOrchestrator
{
    private readonly IRegionHealthMonitor _healthMonitor;
    private readonly InventoryReplicationMonitor _replicationMonitor;
    private readonly SagaRecoveryService _sagaRecovery;

    public async Task ExecuteFailoverAsync(
        FailoverRequest request)
    {
        var primaryHealth = await _healthMonitor
            .CheckRegionAsync("us-east-1");
        if (primaryHealth.IsHealthy
            && !request.ForcedFailover)
        {
            throw new InvalidOperationException(
                "Primary region is healthy");
        }

        await _healthMonitor.PromoteRegionAsync(
            "eu-west-1");

        var lag = await _replicationMonitor
            .GetReplicationLagAsync("eu-west-1");
        if (lag > TimeSpan.FromSeconds(5))
        {
            await EnterConservativeModeAsync(lag * 2);
        }

        await _sagaRecovery.RecoverIncompleteSagasAsync(
            maxAge: TimeSpan.FromMinutes(5));

        await ResyncAllChannelsAsync();
        await SendFailoverNotificationAsync(request);
    }

    private async Task EnterConservativeModeAsync(
        TimeSpan conservativeDuration)
    {
        await _featureFlags.SetAsync(
            "overbooking.aggressive", false);
        await _featureFlags.SetAsync(
            "inventory.hold_minutes", 15);
        await _featureFlags.SetAsync(
            "pricing.lastMinute.enabled", false);

        _ = Task.Delay(conservativeDuration)
            .ContinueWith(async _ =>
        {
            await _featureFlags.ResetAllAsync(
                new[] {
                    "overbooking.aggressive",
                    "inventory.hold_minutes",
                    "pricing.lastMinute.enabled" });
        });
    }
}
Observability Stack: The system uses a comprehensive observability stack: Prometheus for metrics, Grafana for dashboards, Jaeger for distributed tracing, PagerDuty for alerting, and ELK stack for centralized logging. Key SLIs tracked include: search P99 latency, booking success rate, inventory accuracy (actual vs. displayed availability), channel sync latency, payment success rate, and end-to-end booking funnel conversion.

18. Cost Estimation & Infrastructure Sizing

Running a Booking.com-scale platform involves significant infrastructure costs, but the economics are favorable due to the high-value nature of hotel bookings. A single hotel booking generates $50-$500 in commission revenue for the platform (typically 15-25% of the room rate), so even modest conversion improvements justify substantial infrastructure investment.

Infrastructure Cost Breakdown (Monthly)

ComponentSpecificationMonthly Cost% of Total
Compute (Kubernetes/EKS)500+ pods, m5.2xlarge nodes$450,00028%
PostgreSQL (RDS Multi-AZ)db.r5.4xlarge, 3 instances$120,0007%
Redis (ElastiCache)r5.4xlarge cluster, 6 nodes$95,0006%
Elasticsearch (OpenSearch)20 data nodes, r5.xlarge$180,00011%
Apache Kafka (MSK)6 brokers, kafka.m5.2xlarge$85,0005%
S3 Storage (images, docs)50TB with CloudFront CDN$25,0002%
CloudFront CDN50TB transfer/month$30,0002%
Data Transfer (inter-region)10TB/month$15,0001%
Monitoring (Datadog/Prometheus)Full stack$60,0004%
ML/Pricing compute (GPU)2 p3.2xlarge instances$12,0001%
Search infrastructureCustom ranking service$40,0002%
Multi-region DR (2 regions)Warm standby$350,00022%
PCI-scoped payment segmentIsolated VPC$25,0002%
Load balancers (ALB/NLB)Global distribution$20,0001%
Other (SQS, Lambda, etc.)Various serverless$200,00012%

Total Monthly Cost Summary

Calc
Total monthly infrastructure cost: ~$1,607,000
Annual infrastructure cost:        ~$19,300,000

Revenue per booking:    $50-$500 (average: $150)
Daily bookings:         1,500,000
Monthly bookings:       45,000,000
Monthly gross booking:  $6,750,000,000 (GMV)
Platform commission:    ~15% average
Monthly revenue:        ~$1,012,500,000
Infrastructure as % of revenue: ~0.16%

Cost per booking:       $1,607,000 / 45,000,000 ~ $0.036
Cost per search query:  $1,607,000 / 6,000,000,000 ~ $0.00027

Engineering team cost (500 engineers):
  Average salary + benefits: $200,000/year
  Monthly: 500 x $16,667 = $8,333,333

Total monthly cost (infra + team): ~$9,940,333
Cost Optimization: The single largest cost optimization is reserved instances and savings plans for the 1-year committed compute, which reduces EC2 costs by approximately 40%. For PostgreSQL and Redis, using Graviton2 (ARM) instances provides 20% cost reduction with equivalent performance. The search infrastructure benefits from using Elasticsearch's frozen tier for historical availability data, reducing hot storage requirements by 60%.

19. Interview Q&A Deep Dive

This section covers the most common system design interview questions related to hotel booking systems, along with the depth of answer expected at the Senior/Staff/Principal Engineer level.

Q1: How do you prevent double-bookings in a distributed system?

Double-booking prevention requires a multi-layered approach. At the database level, we use optimistic concurrency control with version numbers on inventory records - the UPDATE WHERE version = @expectedVersion pattern ensures that only one transaction can modify an inventory record at a time. If the version doesn't match, the transaction retries. For additional safety, we use database-level unique constraints: a composite unique index on (property_id, room_type_id, date) with a reserved_count that can never exceed total_count + overbooked_count.

In Redis, we use Lua scripts for atomic check-and-increment operations. The Lua script runs atomically on the Redis server, preventing TOCTOU (time-of-check-time-of-use) bugs. The system also implements an inventory hold mechanism: before a booking is confirmed, a temporary hold is placed on the inventory for 10 minutes. This serializes the booking process for the same room/date combination.

Finally, we implement an asynchronous reconciliation job that runs every 5 minutes, comparing inventory counts in PostgreSQL with actual confirmed bookings. If a discrepancy is detected (reserved_count > actual confirmed bookings for that room/date), the system automatically corrects the inventory and alerts the operations team. This three-layer defense (Redis atomic operations + PostgreSQL optimistic locking + async reconciliation) provides defense-in-depth against double-bookings.

Q2: How would you handle a flash sale where 100,000 users simultaneously try to book rooms?

A flash sale scenario requires different architecture than normal booking flow. First, we implement a virtual waiting room: users join a queue and are given a position number. The queue is managed via Redis sorted sets with timestamps. Users are admitted in batches of 1,000, given a 5-minute window to complete their booking. This prevents the inventory service from being overwhelmed.

During the flash sale, we pre-compute prices and cache them aggressively. The inventory service switches to a "high-contention mode" where the hold duration is reduced to 3 minutes, and the retry count is increased to 5 with exponential backoff. We also implement client-side polling with exponential backoff so users don't all refresh at once.

On the backend, we shard inventory by room type and date, allowing parallel processing of different rooms. The Kafka topic for inventory changes is pre-provisioned with additional partitions (100 instead of the normal 10) to handle the throughput spike. We also implement rate limiting at the API gateway level: users are limited to one booking attempt per minute per room type.

Q3: Design the dynamic pricing engine. How do you calculate prices in real-time for millions of room/date combinations?

The pricing engine uses a two-tier architecture: a near-real-time pre-computation tier and a real-time calculation tier. The pre-computation tier runs as a batch job every 5 minutes, computing prices for all active inventory using the full pricing model (seasonality, occupancy, demand velocity, competitor prices, etc.). These pre-computed prices are stored in Redis hashes keyed by price:{propertyId}:{roomTypeId}:{date}.

When a search query arrives, the system reads pre-computed prices from Redis in O(1) time per room type. For the checkout step, we apply a real-time "price lock" that accounts for any price changes since the search result was displayed. The real-time tier is only invoked for individual room/date combinations during checkout, not for batch search results.

The pricing model itself uses a gradient-boosted decision tree trained on historical booking data. Features include occupancy rate, days until check-in, historical demand for the same date last year, competitor prices, and event calendars. The model is retrained weekly and served via a custom inference service that can evaluate 10,000 prices per second per core.

Q4: How do you keep inventory in sync across 50+ OTA channels?

Channel synchronization uses an event-driven architecture with a dedicated Kafka topic per channel. When inventory changes occur, an event is published to a central inventory-changes topic. The channel manager service consumes from this topic and fans out to channel-specific topics. Each channel has its own worker that processes events at the rate the channel's API can handle.

We implement several reliability mechanisms: per-channel circuit breakers that stop pushing to a channel when its API error rate exceeds 20%, dead-letter queues for failed sync events that are retried with exponential backoff, and idempotency keys to prevent duplicate updates from causing double inventory decrements on the OTA side. A reconciliation job runs every 15 minutes, querying each OTA's API for current availability and comparing it with our inventory records.

Q5: How do you handle the "last room" race condition?

The "last room" race condition occurs when two users simultaneously attempt to book the last available room. The system must ensure exactly one booking succeeds and the other sees a "sold out" message. We solve this using a two-phase approach.

Phase 1 (Redis): The first user to reach the inventory service gets an atomic check-and-hold in Redis via a Lua script. This script checks available_count > 0, decrements the hold count, and returns success - all in a single atomic operation. The second concurrent request fails the atomic check and receives a "sold out" response immediately, without hitting the database.

Phase 2 (PostgreSQL): When the first user completes payment, the hold is converted to a confirmed reservation in PostgreSQL using optimistic locking. If somehow two users reached Phase 2 (e.g., due to a Redis failover), the database's unique constraint on the reservation count provides the final safety net. The user whose transaction fails the version check receives a "sorry, this room was just booked by another guest" message.

Q6: What happens when the pricing engine returns different prices for search vs checkout?

This is an expected and handled scenario in dynamic pricing systems. When a user searches, we display the current price and store a PriceLockId in their session. The price lock stores the quoted price with a 15-minute TTL. During checkout, the system verifies the price lock. If the price has increased but is still within the lock window, the user pays the locked (lower) price. If the price has decreased, the user benefits from the lower price.

If the price lock has expired (user took more than 15 minutes to checkout), the system displays the current price and asks the user to confirm. This is presented as "Price update: the rate for this room has changed from $X to $Y" with a confirmation button. Approximately 5% of users see a price change, and conversion drops by 15% for those sessions.

Q7: How do you handle bookings that span multiple room types or properties?

Multi-room and multi-property bookings are treated as composite transactions. A booking can contain multiple BookedRoom entries, each with a different room type. All rooms must be available for the entire date range for the booking to proceed. The inventory hold is placed atomically for all rooms: either all holds succeed or none do (with compensation for partial holds).

Multi-property bookings are more complex because each property's inventory lives in a potentially different database partition. We implement this using a two-phase commit: first, we attempt holds at all properties. If all succeed, we create separate booking records for each property but link them with a parent CompositeBookingId. If any property fails, we release all holds at the other properties.

Interview Strategy: When asked about hotel booking systems, always start by clarifying the scope (consumer OTA vs. hotel chain direct booking vs. PMS). Then discuss the key architectural trade-offs: consistency vs. availability for inventory, read optimization vs. write optimization via CQRS, and the specific failure modes (overbookings, payment processing failures, channel sync delays) that are unique to the hospitality domain.

Q8: How would you design the partner dashboard for hotel managers?

The partner dashboard is a web application that allows hotel managers to view and manage their property listings, room types, availability calendars, pricing, bookings, and performance metrics. The dashboard reads from a materialized view of the property and booking data that is optimized for the partner's perspective (all their properties, not a single property across all users).

The calendar view is the most complex component: it shows a grid with dates on the x-axis and room types on the y-axis, with each cell showing the available count, price, and number of bookings. Partners can click on any cell to modify pricing, block dates, or adjust availability. All changes are propagated to the inventory service and synced to all channels within seconds.

We implement real-time updates on the dashboard using WebSocket connections that subscribe to booking and inventory change events for the partner's properties. When a new booking arrives from any channel, the calendar view updates in real-time, giving the partner immediate visibility into their occupancy status.

Q9: Explain the data flow when a user searches, books, stays, and leaves a review.

The complete guest journey involves all major system components working together. Search phase: User enters search criteria, the Search Service queries Elasticsearch for matching properties with availability, the Pricing Engine provides real-time prices, and the user sees a filtered, sorted list of results. Selection phase: User clicks a property, the Property Service returns detailed information, room types with pricing, photos, and reviews. A price lock is created for 15 minutes.

Booking phase: User proceeds to checkout, the Booking Orchestrator holds inventory, authorizes payment, creates the booking record, confirms inventory, captures payment, sends confirmation email, and publishes events for channel sync and analytics. Stay phase: At check-in, the status transitions to CheckedIn. At checkout, the status transitions to CheckedOut. If the guest modifies their stay, the ModificationPending state handles the change flow.

Post-stay phase: The guest receives a review invitation email 24 hours after checkout. They submit a review with structured ratings and free-text feedback. The Review Aggregation Service recalculates the property's score, updates the Elasticsearch index, and the property's search ranking adjusts accordingly. The property manager sees the new review on their dashboard and can respond publicly.

Q10: How do you handle internationalization - multiple currencies, languages, and local regulations?

Internationalization (i18n) in a hotel booking system spans three dimensions: pricing (currencies and taxes), content (languages and cultural norms), and compliance (local regulations). For currencies, we display prices in the user's local currency using real-time exchange rates, but the actual transaction is processed in the hotel's base currency. The exchange rate is locked at the time of booking and stored in the payment record.

For taxes, different regions have different tax requirements: US hotels charge occupancy tax (varies by city/county), European hotels charge VAT (varies by country), and some destinations have additional tourism levies. The system maintains a tax rules engine that applies the correct taxes based on the property location and the booking dates. Tax rules are maintained by the finance team and updated whenever regulations change.

For content, property descriptions, reviews, and support content are translated into the user's preferred language. We use a combination of professional translation (for high-value content like property descriptions) and machine translation (for reviews and user-generated content). The Elasticsearch index maintains multi-language analyzers that support full-text search in 30+ languages.

Q11: How would you implement loyalty programs and member-only pricing?

A loyalty program in a hotel booking system adds another layer of complexity to pricing and booking flows. The loyalty service maintains member tiers (e.g., Bronze, Silver, Gold, Platinum) based on qualifying nights or spending. Each tier unlocks different benefits: room upgrades, late checkout, free breakfast, bonus points on bookings, and member-only rates.

The pricing engine integrates with the loyalty service to apply member-specific discounts. When a logged-in user searches for rooms, the pricing engine checks their loyalty tier and applies the appropriate discount. This discount is stored as a separate modifier in the price breakdown, allowing the system to track the cost of loyalty benefits independently.

Points accumulation and redemption follow a separate flow. After checkout, the loyalty service credits points to the member's account based on the booking amount and tier multiplier. Members can redeem points for free nights, room upgrades, or other rewards. Point redemption creates a separate "award booking" flow that bypasses payment processing but still follows the standard inventory and booking lifecycle.

Key Takeaway: A hotel booking system is a complex domain that requires careful attention to inventory consistency, real-time pricing, multi-channel synchronization, and payment processing. The most successful designs prioritize correctness for the write path (zero double-bookings) while optimizing for speed on the read path (sub-300ms search). The event-driven architecture with CDC-based synchronization provides the foundation for both real-time channel management and analytical insights. Success in system design interviews for this domain requires demonstrating understanding of both the technical architecture and the business domain constraints.

Hotel Booking System - Senior+ Guide