How to Design a Hotel Booking System
Building a Booking.com-Scale Platform - Inventory, Search, Dynamic Pricing, Payments & Channel Management
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 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.
2. Functional & Non-Functional Requirements
Functional Requirements
- Property Search: Users can search for accommodations by location (city, airport, landmark), check-in/check-out dates, number of guests, and room configuration.
- 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.
- Dynamic Pricing: Room prices must be computed dynamically based on demand signals, seasonality, competitor pricing, length of stay, and user segment.
- 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.
- Payment Processing: Support multiple payment methods (credit card, debit card, digital wallets), multi-currency pricing, and secure payment tokenization.
- Booking Management: Users can view, modify, or cancel their bookings. Cancellation must follow configurable refund policies tied to the rate plan.
- Review & Rating System: Verified guests can leave reviews with structured ratings (cleanliness, location, staff, comfort, value, facilities) and free-text comments.
- Property Management: Hotel partners can manage their listings, room types, photos, policies, and availability through a partner dashboard.
- Channel Manager: Inventory changes must propagate to external OTAs (Booking.com, Expedia, Agoda) via standard protocols (OTA/HTNG, OpenBooking).
- Recommendations: The system suggests properties based on user search history, past bookings, and collaborative filtering signals.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Search Latency (P99) | < 300ms | Direct impact on conversion rate |
| Booking Throughput | 10,000 bookings/second peak | Flash sales, holiday peaks |
| Inventory Accuracy | 100% (zero double-bookings) | Trust-critical guarantee |
| Search Availability | 99.99% | Revenue protection |
| Data Consistency | Strong for inventory, eventual for reviews | Different tolerance per domain |
| Price Freshness | < 5 minutes | Competitive parity with OTAs |
| Channel Sync Latency | < 10 seconds | Prevent cross-channel overbookings |
| Global Reach | Multi-region with < 100ms TTFB | Serve users in 200+ countries |
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
| Metric | Value |
|---|---|
| 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
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; }
}
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.
(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
| Service | Responsibility | Data Store | Scaling Strategy |
|---|---|---|---|
| Search Service | Property search, filtering, sorting | Elasticsearch | Horizontal (shard-based) |
| Property Service | CRUD for listings, photos, policies | PostgreSQL + S3 | Read replicas |
| Inventory Service | Room availability tracking, locking | PostgreSQL + Redis | Sharded by property |
| Pricing Engine | Dynamic rate calculation | Redis + Rules Engine | Horizontal, CPU-bound |
| Booking Service | Reservation lifecycle management | PostgreSQL | Horizontal, partitioned |
| Payment Service | Payment processing, refunds | PostgreSQL (PCI scope) | Horizontal, isolated |
| Channel Manager | OTA inventory synchronization | PostgreSQL + Redis | Per-channel workers |
| User Service | Authentication, profiles, loyalty | PostgreSQL + Redis | Read replicas |
| Review Service | Reviews, ratings, moderation | PostgreSQL + Elasticsearch | Read replicas |
| Notification Service | Email, SMS, push notifications | Message Queue | Queue-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.
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.
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);
}
}
7. Date-Based Availability Search
The search experience is the primary funnel for conversions. When a user searches for "Hotels in Paris, Dec 20-25, 2 adults," the system must return a ranked list of available properties with prices in under 300ms. This requires a highly optimized search pipeline that avoids hitting the transactional inventory database.
Search Pipeline Architecture
Elasticsearch Query Design
The Elasticsearch index stores pre-aggregated availability data. For each property, we store the minimum available price and the count of available room types for each date in the search range. The query uses a bool filter to match properties that have at least one room type with availability for every night in the requested date range.
C#
public class PropertySearchService
{
private readonly IElasticClient _elastic;
public async Task<SearchResult> SearchAsync(SearchRequest request)
{
var searchDescriptor = new SearchDescriptor<PropertySearchDocument>()
.Index("properties")
.Size(request.PageSize)
.From(request.Offset)
.Query(q => q
.Bool(b => b
.Filter(
f => f.GeoDistance(g => g
.Field(p => p.Location)
.Distance(request.RadiusKm,
DistanceUnit.Kilometers)
.Origin(new GeoLocation(
request.Lat, request.Lng))),
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(DateRangeString(
request.CheckIn,
request.CheckOut)))
)
)
)
),
f => f.Terms(t => t
.Field(p => p.PropertyType)
.Terms(request.PropertyTypes)),
f => f.Range(r => r
.Field(p => p.StarRating)
.Gte(request.MinStarRating ?? 0))
)
.Must(
m => m.MultiMatch(mm => mm
.Fields(f => f
.Field(p => p.Name, 3.0)
.Field(p => p.City, 2.0)
.Field(p => p.Country, 1.0))
.Query(request.Query)
.Type(TextQueryType.BestFields)
.Fuzziness(Fuzziness.Auto))
)
))
.Sort(s => request.SortBy switch
{
SortOption.Price => s.Field(p => p.MinPrice,
SortOrder.Ascending),
SortOption.Rating => s.Field(p => p.ReviewScore,
SortOrder.Descending),
SortOption.Distance => s.GeoDistance(g => g
.Field(p => p.Location)
.Origin(new GeoLocation(
request.Lat, request.Lng))
.Unit(DistanceUnit.Kilometers)
.Order(SortOrder.Ascending)),
_ => s.Field(p => p.RankingScore,
SortOrder.Descending)
});
var response = await _elastic.SearchAsync<
PropertySearchDocument>(searchDescriptor);
return new SearchResult
{
TotalResults = response.Total,
Properties = response.Documents
.Select(doc => MapToResult(doc, request))
.ToList(),
Facets = ExtractFacets(response.Aggregations)
};
}
}
Search Result Enrichment
The initial Elasticsearch query returns properties with pre-computed minimum prices, but the final displayed price needs to be computed in real-time by the Pricing Engine to reflect the most current demand signals. We batch this enrichment: the top 50 results from the initial query are sent to the Pricing Service in a single batch request, which computes prices in under 20ms using Redis-cached rate rules. The full search response is assembled and returned to the client within the 300ms budget.
Geo-Search and Proximity Ranking
For location-based searches, we use Elasticsearch's geo_point fields with distance-based sorting. Properties within the search radius are ranked by a composite score that combines distance, review score, price competitiveness, and partner priority (paid placement). The geo-search is backed by a quad-tree spatial index in Elasticsearch, which supports sub-millisecond proximity queries even across millions of documents.
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
| Factor | Weight Range | Example |
|---|---|---|
| Base Rate | 100% (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));
}
}
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.
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:
- Hold Inventory: Place a 10-minute hold on the selected rooms. If this fails, return "sold out" to the user.
- Lock Price: Store the quoted price with a 15-minute expiry. This prevents price changes during checkout.
- Process Payment: Authorize (but not capture) the payment amount. This places a hold on the guest's card.
- Confirm Booking: Create the booking record, transition to Confirmed status, and capture the payment.
- Release Hold / Confirm Inventory: Convert the inventory hold to a confirmed reservation.
- Notify: Send confirmation email to the guest and notification to the property.
- 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
| Flow | Authorization | Capture | Refund |
|---|---|---|---|
| Prepaid (non-refundable) | At booking | Immediately | Exceptional only |
| Prepaid (refundable) | At booking | Immediately | Based on policy |
| Pay at property | At booking (hold) | At check-in | Via property |
| Deposit + Balance | Deposit at booking | Balance at check-in | Partial based on policy |
| Pay later (installments) | First installment | Scheduled captures | Remaining 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);
}
}
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 Type | Refund Rules | Common Use Case |
|---|---|---|
| Free Cancellation | 100% refund up to 24-48 hours before check-in | Standard flexible rate |
| Late Free Cancellation | 100% refund up to 7 days before check-in | Peak season bookings |
| Non-Refundable | No refund under any circumstance | Discounted prepaid rate |
| Tiered Refund | 100% then 50% then 0% based on days | Extended stay policies |
| First Night Only | First night charged, remaining refunded | Group bookings |
| Percentage-Based | Fixed percentage refund based on timing | Boutique 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;
}
}
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 Category | Examples | Index Strategy |
|---|---|---|
| Geographic | City, neighborhood, distance | Geo_point field |
| Date-based | Check-in/out, length of stay | Denormalized availability array |
| Property | Type, star rating, brand | Keyword fields |
| Amenities | Pool, WiFi, parking, spa | Keyword array field |
| Pricing | Price range per night | Computed price field |
| Guest Rating | Overall and category scores | Float fields |
| Booking Policies | Free cancellation, breakfast | Boolean fields |
| Room Features | Bed type, room size, view | Nested 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
};
}
}
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;
}
}
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
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);
}
}
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:
| Severity | Rooms Over | Compensation | Escalation |
|---|---|---|---|
| Low | 1-2 rooms | Walk to comparable hotel + transport + $50 credit | Front desk manager |
| Medium | 3-5 rooms | Walk + upgrade at partner hotel + $100 credit | General manager |
| High | 5+ rooms | Full walk package + $200 credit + loyalty points | Regional director |
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
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
};
}
}
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 Mode | Impact | Detection | Mitigation |
|---|---|---|---|
| Inventory database failure | No new bookings possible | Health checks | Multi-AZ replicas, auto failover |
| Elasticsearch degradation | Stale/empty search results | Query latency monitoring | Cached results, degraded mode |
| Payment gateway outage | Cannot process payments | Error rate monitoring | Secondary gateway fallback |
| Channel manager sync delay | Cross-channel overbookings | Sync latency monitoring | Increased buffer, manual alerts |
| Kafka broker failure | Delayed event processing | Consumer lag monitoring | Multi-broker cluster |
| Redis cache failure | Cold cache, slower search | Cache hit rate monitoring | Database fallback |
| Full no-show surge | Massive overbookings | Occupancy delta monitoring | Dynamic 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:
- DNS failover: Route 53 health checks detect primary region failure and route traffic to the secondary region within 60 seconds.
- 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.
- 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.
- 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" });
});
}
}
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)
| Component | Specification | Monthly Cost | % of Total |
|---|---|---|---|
| Compute (Kubernetes/EKS) | 500+ pods, m5.2xlarge nodes | $450,000 | 28% |
| PostgreSQL (RDS Multi-AZ) | db.r5.4xlarge, 3 instances | $120,000 | 7% |
| Redis (ElastiCache) | r5.4xlarge cluster, 6 nodes | $95,000 | 6% |
| Elasticsearch (OpenSearch) | 20 data nodes, r5.xlarge | $180,000 | 11% |
| Apache Kafka (MSK) | 6 brokers, kafka.m5.2xlarge | $85,000 | 5% |
| S3 Storage (images, docs) | 50TB with CloudFront CDN | $25,000 | 2% |
| CloudFront CDN | 50TB transfer/month | $30,000 | 2% |
| Data Transfer (inter-region) | 10TB/month | $15,000 | 1% |
| Monitoring (Datadog/Prometheus) | Full stack | $60,000 | 4% |
| ML/Pricing compute (GPU) | 2 p3.2xlarge instances | $12,000 | 1% |
| Search infrastructure | Custom ranking service | $40,000 | 2% |
| Multi-region DR (2 regions) | Warm standby | $350,000 | 22% |
| PCI-scoped payment segment | Isolated VPC | $25,000 | 2% |
| Load balancers (ALB/NLB) | Global distribution | $20,000 | 1% |
| Other (SQS, Lambda, etc.) | Various serverless | $200,000 | 12% |
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
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.
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.