Design a Travel Booking System: The Complete Guide
Building Expedia, Booking.com, and MakeMyTrip at scale — flights, hotels, cars, pricing engines, and multi-currency payments
Table of Contents
- Introduction — The Travel Booking Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model & Storage Schema
- High-Level Architecture
- API Design
- Search & Filtering Engine
- Flight, Hotel & Car Inventory
- Dynamic Pricing Engine
- Booking Flow & State Machine
- Payment & Refunds
- Itinerary Management
- Review & Rating System
- Travel Alerts & Notifications
- Multi-Currency & Localization
- Loyalty Program
- Partner API Integration
- Cancellation & Rebooking
- Fraud Detection
- Monitoring & Observability
- Cost Estimation
- Testing Strategy
- Interview Q&A
1. Introduction — The Travel Booking Landscape
The global online travel market is projected to exceed $1.1 trillion by 2027. Platforms like Booking.com serve over 28 million accommodation listings across 220 countries. Expedia processes more than 200 million room nights per year. MakeMyTrip, one of India's largest travel portals, handles millions of flight and hotel bookings daily. At first glance a travel booking system looks straightforward — search for flights or hotels, compare prices, and click "Book Now." Beneath the surface, however, it is one of the most intricate distributed systems in modern software engineering.
A production travel platform must aggregate inventory from dozens of Global Distribution Systems (GDS) such as Amadeus, Sabre, and Travelport, each with its own XML or JSON API and wildly different data shapes. It must run a dynamic pricing engine that recalculates fares based on demand signals, competitor prices, seasonality, and user behaviour in real time. It must enforce seat or room allocation with strict concurrency control so that two users never book the same seat on the same flight. It must support dozens of currencies, local payment methods, and regulatory compliance requirements such as PSD2 in Europe and GST in India. It must also handle the entire lifecycle of a booking — from search through payment confirmation, ticket issuance, itinerary changes, cancellations, refunds, and rebookings.
This guide walks you through every layer of the system. We will cover functional and non-functional requirements, back-of-the-envelope capacity estimation, a detailed relational and NoSQL data model, the high-level microservice architecture, API design for search and booking, the search and filtering pipeline with Elasticsearch, inventory management for flights, hotels, and cars, a dynamic pricing engine with surge and discount logic, a booking state machine with saga-based distributed transactions, payment processing with PCI-DSS considerations, itinerary management, review and rating systems, real-time travel alerts, multi-currency and localization, loyalty programs, partner API integration, cancellation and rebooking workflows, fraud detection, monitoring and observability, cost estimation, and testing strategies. The article concludes with a comprehensive interview Q&A section.
Why This Problem Is Hard
The fundamental difficulty is the combination of real-time inventory from external suppliers, price volatility, and the requirement for strong consistency in booking. Unlike an e-commerce system where you can oversell and backorder, selling a seat that does not exist is an unrecoverable error. You must hold inventory atomically — lock a seat for a customer while they enter payment details, then either confirm or release within a strict timeout. This hold-and-confirm pattern, combined with dozens of GDS integrations each with different latency profiles and error semantics, creates a system that is far more complex than a typical CRUD application.
Additionally, the read-to-write ratio is extreme. For every booking, there are thousands of searches. This means the read path must be highly optimised with caching, CDN-delivered search results, and denormalised data, while the write path must be strongly consistent with distributed locking and idempotency guarantees.
Consider the scale of data involved: a single airline partner might expose 500,000 flight segments daily across 10,000 routes. A hotel aggregator might index 5 million room-nights across 300,000 properties. When you multiply these across multiple GDS partners, each with their own response format, rate limits, and availability semantics, the aggregation challenge becomes clear. You need a robust adapter layer that normalises all of this into a unified internal model while gracefully handling partner-specific quirks and outages.
The competitive landscape adds another dimension of complexity. Users expect real-time prices, instant confirmation, and a seamless cross-device experience. They compare your prices against Google Flights, Skyscanner, and direct airline websites within seconds. Your search must be blazingly fast, your prices must be competitive, and your booking flow must be frictionless. Every millisecond of latency in search results translates directly to lost conversions.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | Flight search and booking | Must | Multi-city, round-trip, one-way with filters |
| F2 | Hotel search and booking | Must | Date range, location, star rating, amenities |
| F3 | Car rental search and booking | Must | Pickup/dropoff location, vehicle type, insurance |
| F4 | Dynamic pricing | Must | Real-time fare updates based on demand and inventory |
| F5 | Payment processing | Must | Credit cards, digital wallets, bank transfers, EMI |
| F6 | Booking management | Must | View, cancel, modify, reprint e-tickets |
| F7 | User accounts and profiles | Must | Registration, login, saved travellers, preferences |
| F8 | Multi-currency support | Should | Display and pay in local currency |
| F9 | Review and rating | Should | Users rate hotels, flights, and cars |
| F10 | Loyalty program | Should | Points accrual, redemption, tier status |
| F11 | Travel alerts | Should | Flight delay, gate change, weather disruptions |
| F12 | Cancellation and refund | Must | Full, partial, non-refundable fare rules |
| F13 | Rebooking | Should | Date or route changes with fare difference |
| F14 | Partner API | Should | White-label API for OTAs and affiliates |
| F15 | Fraud detection | Must | Block stolen cards, velocity checks, device fingerprinting |
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Search latency (P95) | < 500ms | Users expect near-instant search results |
| Booking latency (P95) | < 3 seconds | End-to-end from confirm to confirmation page |
| Availability | 99.99% | Downtime during peak travel seasons costs millions |
| Data consistency | Strong for bookings | Overselling a seat or room is unacceptable |
| Read:Write ratio | 1000:1 | Searches vastly outnumber bookings |
| Throughput | 50,000 QPS (search), 500 QPS (booking) | Peak season demand |
| Data retention | 7 years | Regulatory and tax compliance |
| Security | PCI-DSS Level 1 | Payment card handling |
The non-functional requirements shape every architectural decision. The 500ms search latency target demands that we cache aggressively and precompute popular routes. The 99.99% availability target requires multi-AZ deployment with automated failover. The strong consistency requirement for bookings means we cannot use eventual consistency for inventory allocation — we need distributed locks or serializable transactions. The 1000:1 read-write ratio informs our strategy of separating the read path (search) from the write path (booking) with different consistency and scaling profiles.
3. Capacity Estimation & Back-of-Envelope
Traffic Estimates
Assume 10 million daily active users. Each user performs an average of 5 searches per session with a session duration of 20 minutes. This yields 50 million searches per day or roughly 600 searches per second. With a peak factor of 10x during holiday seasons, peak search QPS reaches 6,000. Assuming a 1% conversion rate from search to booking, we get 500,000 bookings per day or about 6 bookings per second, peaking at 60. The revenue per booking averages $450, giving us a daily gross merchandise value of $225 million and an annual GMV exceeding $82 billion.
Storage Estimates
| Entity | Size per Record | Daily Volume | Annual Storage |
|---|---|---|---|
| Search logs | 2 KB | 50 million | ~36 TB/year |
| Bookings | 5 KB | 500K | ~9 GB/year |
| User profiles | 1 KB | Steady state | ~4 GB (10M users) |
| Hotel listings | 4 KB | Steady state | ~112 GB (28M listings) |
| Reviews | 1.5 KB | 50K | ~27 GB/year |
| Payment transactions | 3 KB | 500K | ~5.5 GB/year |
Bandwidth Estimates
Search results averaging 50 KB each at 6,000 QPS produce approximately 300 MB/s of outbound data. Search result pages should be cached at the CDN edge, reducing origin bandwidth to roughly 10% or 30 MB/s. Inbound traffic from GDS partners averages 50 MB/s when polling for availability updates. WebSocket connections for real-time alerts consume roughly 5 MB/s for 100,000 concurrent users with heartbeat messages every 30 seconds.
Cache Estimates
Hot search results for popular routes (e.g., New York to London) and destinations (e.g., Paris hotels in December) can be cached for 60 seconds, covering approximately 80% of search traffic with roughly 50 GB of Redis cache. Hotel static content (images, descriptions) can be CDN-cached for 24 hours, consuming approximately 2 TB of CDN storage. User session data and price locks require another 10 GB of Redis with TTL-based eviction.
public class CapacityCalculator
{
public static void Estimate()
{
var dailyActiveUsers = 10_000_000L;
var searchesPerSession = 5;
var dailySearches = dailyActiveUsers * searchesPerSession; // 50M
var searchQps = dailySearches / 86_400; // ~579
var peakSearchQps = searchQps * 10; // ~5,790
var conversionRate = 0.01;
var dailyBookings = dailySearches * conversionRate; // 500K
var bookingQps = dailyBookings / 86_400; // ~5.8
var peakBookingQps = bookingQps * 10; // ~58
var avgBookingValue = 450m;
var dailyGMV = dailyBookings * avgBookingValue; // $225M
var annualGMV = dailyGMV * 365; // ~$82B
Console.WriteLine($"Search QPS: {searchQps}, Peak: {peakSearchQps}");
Console.WriteLine($"Booking QPS: {bookingQps:F1}, Peak: {peakBookingQps:F1}");
Console.WriteLine($"Daily GMV: ${dailyGMV:N0}, Annual: ${annualGMV:N0}");
// Storage
var searchLogStoragePerYear = dailySearches * 2048L * 365; // bytes
Console.WriteLine($"Search logs/year: {searchLogStoragePerYear / (1024*1024*1024*1024.0):F1} TB");
}
}
4. Data Model & Storage Schema
The data model must handle structured relational data (users, bookings, payments), semi-structured inventory data from multiple suppliers, and high-volume search analytics. We use PostgreSQL as the primary transactional store, Elasticsearch for search indexing, Redis for caching and session holds, and Apache Kafka for event streaming. The data model is designed around the concept of a Booking containing one or more BookingLegs, where each leg represents a single flight segment, hotel stay, or car rental period.
Entity Relationship Overview
Core Tables
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(200) NOT NULL,
phone VARCHAR(20),
country_code VARCHAR(3),
preferred_currency VARCHAR(3) DEFAULT 'USD',
preferred_language VARCHAR(5) DEFAULT 'en',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
is_verified BOOLEAN DEFAULT FALSE,
is_blocked BOOLEAN DEFAULT FALSE
);
CREATE TABLE bookings (
booking_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(user_id),
booking_ref VARCHAR(12) UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
payment_status VARCHAR(20) DEFAULT 'UNPAID',
booked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB
);
CREATE TABLE booking_legs (
leg_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(booking_id),
leg_type VARCHAR(10) NOT NULL,
supplier_id VARCHAR(50),
supplier_confirmation VARCHAR(50),
origin_code VARCHAR(10),
destination_code VARCHAR(10),
departure_time TIMESTAMPTZ,
arrival_time TIMESTAMPTZ,
passenger_count INT DEFAULT 1,
fare_class VARCHAR(20),
base_fare DECIMAL(12,2),
taxes DECIMAL(12,2),
fees DECIMAL(12,2),
total_fare DECIMAL(12,2),
status VARCHAR(20) DEFAULT 'CONFIRMED',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE payments (
payment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(booking_id),
amount DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL,
method VARCHAR(30) NOT NULL,
gateway VARCHAR(30) NOT NULL,
gateway_txn_id VARCHAR(100),
status VARCHAR(20) DEFAULT 'PENDING',
card_last_four VARCHAR(4),
card_brand VARCHAR(20),
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
refund_amount DECIMAL(12,2) DEFAULT 0,
metadata JSONB
);
CREATE TABLE inventory_locks (
lock_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_type VARCHAR(20) NOT NULL,
inventory_id VARCHAR(100) NOT NULL,
user_id UUID REFERENCES users(user_id),
locked_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
session_id VARCHAR(100),
UNIQUE(inventory_type, inventory_id)
);
CREATE TABLE reviews (
review_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(user_id),
entity_type VARCHAR(20) NOT NULL,
entity_id UUID NOT NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
title VARCHAR(200),
body TEXT,
is_verified_booking BOOLEAN DEFAULT FALSE,
helpful_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE loyalty_accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(user_id) UNIQUE,
points_balance BIGINT DEFAULT 0,
tier VARCHAR(20) DEFAULT 'BRONZE',
lifetime_points BIGINT DEFAULT 0,
points_expiry_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE price_locks (
lock_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id VARCHAR(100) NOT NULL,
inventory_type VARCHAR(20) NOT NULL,
inventory_id VARCHAR(100) NOT NULL,
locked_price DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) NOT NULL,
locked_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
is_used BOOLEAN DEFAULT FALSE
);
NoSQL Collections for Search Analytics
Search queries, clickstream events, and pricing history are written to Kafka and consumed into ClickHouse for analytical queries. Elasticsearch stores denormalised flight, hotel, and car inventory for full-text search with faceted filtering. Redis stores price locks, session data, and frequently-accessed search result caches. The separation of transactional (PostgreSQL) and analytical (ClickHouse) data stores allows each to be optimised independently — PostgreSQL for ACID transactions with B-tree indexes, and ClickHouse for columnar analytical queries with aggressive compression.
| Store | Purpose | Retention | Consistency |
|---|---|---|---|
| PostgreSQL | Users, bookings, payments, reviews | 7 years | Strong (ACID) |
| Elasticsearch | Inventory search, autocomplete | Real-time sync | Eventual |
| Redis | Price locks, search cache, sessions | TTL-based | Eventual |
| ClickHouse | Search analytics, pricing history | 3 years | Eventual |
| Kafka | Event streaming, CDC from PostgreSQL | 30 days | At-least-once |
Database Sharding Strategy
PostgreSQL is sharded by user_id using consistent hashing. This ensures that all bookings for a single user land on the same shard, enabling efficient joins and transactional consistency within a user's scope. The bookings table uses a composite index on (user_id, created_at DESC) for fast retrieval of recent bookings. Inventory data is partitioned by region (Americas, Europe, Asia-Pacific) to keep queries local and reduce cross-region latency. Elasticsearch indices are sharded by origin airport code to distribute search load evenly across nodes.
5. High-Level Architecture
The system is decomposed into domain-aligned microservices: Search Service, Inventory Service, Pricing Service, Booking Service, Payment Service, User Service, Notification Service, and Partner Gateway. Each service owns its own data store and communicates through synchronous REST/gRPC for queries and asynchronous Kafka events for commands and state changes. An API Gateway handles authentication, rate limiting, and request routing. A Service Mesh (Istio or Linkerd) provides mTLS, circuit breaking, and observability.
Service Responsibilities
| Service | Responsibility | Data Store | Communication |
|---|---|---|---|
| Search Service | Full-text search, filtering, autocomplete, geo-search | Elasticsearch + Redis cache | Synchronous (gRPC) |
| Inventory Service | Aggregate inventory from GDS partners, cache availability | PostgreSQL + Redis | Async (Kafka) + Sync (gRPC) |
| Pricing Service | Dynamic pricing, surge calculation, discount application | Redis + ClickHouse | Async (Kafka events) |
| Booking Service | Booking creation, state machine, saga orchestration | PostgreSQL | Saga + Kafka events |
| Payment Service | Payment capture, refunds, reconciliation | PostgreSQL | Synchronous (REST to gateway) |
| User Service | Authentication, profiles, preferences, loyalty | PostgreSQL | Synchronous (gRPC) |
| Notification Service | Email, SMS, push, in-app notifications | Event-driven (Kafka) | Async (Kafka consumers) |
| Partner Gateway | Adapter layer for GDS/airline/hotel APIs | PostgreSQL (config) | Async (Kafka producers) |
Event-Driven Communication
Cross-service communication uses an event-driven architecture with Apache Kafka as the central message bus. Key event streams include: booking.events (state changes in the booking lifecycle), payment.events (payment confirmations and failures), inventory.events (availability updates from GDS partners), pricing.events (price change notifications), and notification.events (alerts to be dispatched). Each event is stamped with a unique ID, timestamp, and version number to support schema evolution. Consumers implement idempotent processing with idempotency keys to handle at-least-once delivery guarantees.
The event schema is managed through a Schema Registry (Confluent or Apicurio) with backward and forward compatibility modes. This ensures that producers can evolve event schemas without breaking existing consumers. For example, when we add a new field to the BookingConfirmedEvent (such as a baggage allowance), older consumers simply ignore the unknown field.
6. API Design
Search API
// POST /api/v1/search/flights
[ApiController]
[Route("api/v1/search")]
public class SearchController : ControllerBase
{
private readonly ISearchService _searchService;
[HttpPost("flights")]
public async Task<ActionResult<FlightSearchResponse>> SearchFlights(
[FromBody] FlightSearchRequest request)
{
var results = await _searchService.SearchFlightsAsync(
origin: request.OriginCode,
destination: request.DestinationCode,
departureDate: request.DepartureDate,
returnDate: request.ReturnDate,
passengers: request.PassengerCounts,
cabinClass: request.CabinClass,
currency: request.Currency ?? "USD",
maxStops: request.MaxStops,
sortBy: request.SortBy ?? "price",
page: request.Page,
pageSize: Math.Min(request.PageSize, 50));
return Ok(results);
}
[HttpPost("hotels")]
public async Task<ActionResult<HotelSearchResponse>> SearchHotels(
[FromBody] HotelSearchRequest request)
{
var results = await _searchService.SearchHotelsAsync(
cityCode: request.CityCode,
checkIn: request.CheckInDate,
checkOut: request.CheckOutDate,
rooms: request.RoomConfigs,
starRatings: request.StarRatings,
priceRange: request.PriceRange,
amenities: request.Amenities,
sortBy: request.SortBy ?? "price",
page: request.Page);
return Ok(results);
}
[HttpPost("cars")]
public async Task<ActionResult<CarSearchResponse>> SearchCars(
[FromBody] CarSearchRequest request)
{
var results = await _searchService.SearchCarsAsync(
pickupLocation: request.PickupLocationCode,
dropoffLocation: request.DropoffLocationCode ?? request.PickupLocationCode,
pickupTime: request.PickupDateTime,
dropoffTime: request.DropoffDateTime,
vehicleType: request.VehicleType,
transmission: request.Transmission,
supplierFilter: request.PreferredSuppliers);
return Ok(results);
}
}
Booking API
// POST /api/v1/bookings
[ApiController]
[Route("api/v1/bookings")]
[Authorize]
public class BookingController : ControllerBase
{
private readonly IBookingService _bookingService;
[HttpPost]
public async Task<ActionResult<BookingResponse>> CreateBooking(
[FromBody] CreateBookingRequest request)
{
var booking = await _bookingService.CreateBookingAsync(
userId: GetUserId(),
sessionId: request.SessionId,
priceLockId: request.PriceLockId,
legs: request.Legs,
passengers: request.Passengers,
contactInfo: request.ContactInfo,
specialRequests: request.SpecialRequests);
return CreatedAtAction(
nameof(GetBooking),
new { bookingId = booking.BookingId },
booking);
}
[HttpGet("{bookingId}")]
public async Task<ActionResult<BookingDetailResponse>> GetBooking(
Guid bookingId)
{
var booking = await _bookingService.GetBookingAsync(bookingId, GetUserId());
return Ok(booking);
}
[HttpPost("{bookingId}/cancel")]
public async Task<ActionResult<CancelResponse>> CancelBooking(
Guid bookingId, [FromBody] CancelRequest request)
{
var result = await _bookingService.CancelBookingAsync(
bookingId, GetUserId(), request.Reason);
return Ok(result);
}
[HttpPost("{bookingId}/rebook")]
public async Task<ActionResult<RebookResponse>> RebookBooking(
Guid bookingId, [FromBody] RebookRequest request)
{
var result = await _bookingService.RebookAsync(
bookingId, GetUserId(), request.NewDates, request.NewLegs);
return Ok(result);
}
}
Price Lock API
// POST /api/v1/price-locks
[ApiController]
[Route("api/v1/price-locks")]
public class PriceLockController : ControllerBase
{
private readonly IPricingService _pricingService;
[HttpPost]
public async Task<ActionResult<PriceLockResponse>> LockPrice(
[FromBody] PriceLockRequest request)
{
var lockResult = await _pricingService.LockPriceAsync(
sessionId: request.SessionId,
inventoryType: request.InventoryType,
inventoryId: request.InventoryId,
ttlSeconds: 600);
return Ok(lockResult);
}
[HttpGet("{lockId}")]
public async Task<ActionResult<PriceLockStatus>> GetLockStatus(Guid lockId)
{
var status = await _pricingService.GetLockStatusAsync(lockId);
return Ok(status);
}
}
API Endpoints Summary
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/search/flights | Search flights | Optional |
| POST | /api/v1/search/hotels | Search hotels | Optional |
| POST | /api/v1/search/cars | Search rental cars | Optional |
| POST | /api/v1/price-locks | Lock a price for 10 min | Required |
| POST | /api/v1/bookings | Create a booking | Required |
| GET | /api/v1/bookings/{id} | Get booking details | Required |
| POST | /api/v1/bookings/{id}/cancel | Cancel a booking | Required |
| POST | /api/v1/bookings/{id}/rebook | Rebook with new dates | Required |
| POST | /api/v1/payments | Process payment | Required |
| POST | /api/v1/payments/{id}/refund | Initiate refund | Required |
| GET | /api/v1/users/{id}/loyalty | Get loyalty balance | Required |
| POST | /api/v1/reviews | Submit a review | Required |
All API endpoints are versioned under /api/v1/ to allow backward-compatible evolution. Responses follow a consistent envelope format with data, errors, and meta fields. Pagination uses cursor-based pagination for search results (returning a next_cursor token) and offset-based pagination for list endpoints. Rate limits are applied per API key with separate quotas for authenticated and unauthenticated requests.
7. Search & Filtering Engine
The search engine is the most traffic-intensive component. It must deliver sub-second results across millions of inventory items while supporting complex filters (price range, stops, airline, departure time, duration, cabin class, hotel amenities, car type). We use Elasticsearch as the primary search index with Redis caching for hot queries and a write-behind pipeline that synchronises inventory changes from PostgreSQL and GDS feeds into the index.
Elasticsearch Index Schema
public class FlightIndexDocument
{
public string Id { get; set; }
public string OriginCode { get; set; }
public string OriginCity { get; set; }
public string OriginCountry { get; set; }
public string DestinationCode { get; set; }
public string DestinationCity { get; set; }
public string DestinationCountry { get; set; }
public DateTime DepartureTime { get; set; }
public DateTime ArrivalTime { get; set; }
public int DurationMinutes { get; set; }
public int StopCount { get; set; }
public List<string> StopAirports { get; set; }
public string AirlineCode { get; set; }
public string AirlineName { get; set; }
public string AircraftType { get; set; }
public decimal BasePrice { get; set; }
public string Currency { get; set; }
public Dictionary<string, decimal> PriceByCurrency { get; set; }
public int AvailableSeats { get; set; }
public string CabinClass { get; set; }
public bool IsRefundable { get; set; }
public bool IncludesMeals { get; set; }
public int BaggageAllowanceKg { get; set; }
public GeoPoint OriginLocation { get; set; }
public GeoPoint DestinationLocation { get; set; }
public DateTime LastUpdated { get; set; }
}
public class HotelIndexDocument
{
public string Id { get; set; }
public string HotelName { get; set; }
public string CityCode { get; set; }
public string CityName { get; set; }
public string CountryCode { get; set; }
public GeoPoint Location { get; set; }
public int StarRating { get; set; }
public decimal PricePerNight { get; set; }
public string Currency { get; set; }
public Dictionary<string, decimal> PriceByDate { get; set; }
public List<string> Amenities { get; set; }
public decimal ReviewScore { get; set; }
public int ReviewCount { get; set; }
public List<string> ImageUrls { get; set; }
public bool FreeCancellation { get; set; }
public bool BreakfastIncluded { get; set; }
public int AvailableRooms { get; set; }
public DateTime LastUpdated { get; set; }
}
Search Caching Strategy
Search results are cached in Redis with a composite cache key derived from origin, destination, dates, cabin class, and currency. Hot routes like JFK-LAX or DEL-BOM are precomputed and cached. Cache TTL is 60 seconds for price data (to keep fares reasonably current) and 10 minutes for static hotel data. A stale-while-revalidate pattern ensures the UI shows results immediately even if the cache is expired, with a background refresh updating the cached copy.
public class CachedSearchService : ISearchService
{
private readonly ISearchService _inner;
private readonly IDistributedCache _cache;
private readonly ILogger<CachedSearchService> _logger;
public async Task<FlightSearchResult> SearchFlightsAsync(FlightSearchRequest req)
{
var cacheKey = BuildCacheKey("flights", req);
var cached = await _cache.GetAsync<FlightSearchResult>(cacheKey);
if (cached != null)
{
if (!IsStale(cached, TimeSpan.FromSeconds(60)))
return cached.Result;
// Stale-while-revalidate: return stale, refresh in background
_ = Task.Run(async () =>
{
var fresh = await _inner.SearchFlightsAsync(req);
await _cache.SetAsync(cacheKey, new CacheEntry(fresh, TimeSpan.FromSeconds(60)));
});
return cached.Result;
}
var result = await _inner.SearchFlightsAsync(req);
await _cache.SetAsync(cacheKey, new CacheEntry(result, TimeSpan.FromSeconds(60)));
return result;
}
private string BuildCacheKey(string type, FlightSearchRequest req)
{
var parts = new[] {
type, req.OriginCode, req.DestinationCode,
req.DepartureDate.ToString("yyyyMMdd"),
req.ReturnDate?.ToString("yyyyMMdd") ?? "OW",
req.CabinClass?.ToString() ?? "ECONOMY",
req.Currency ?? "USD"
};
return $"search:{string.Join(":", parts)}";
}
private bool IsStale(CacheEntry entry, TimeSpan threshold)
{
return DateTimeOffset.UtcNow - entry.CachedAt > threshold;
}
}
Search Performance Optimisations
| Technique | Impact | Implementation |
|---|---|---|
| Redis result caching | 80% cache hit rate | 60s TTL, stale-while-revalidate |
| CDN edge caching | 60% origin offload | Cache-Control headers on search pages |
| Elasticsearch sharding | Parallel search | 3 shards per index, 1 replica |
| Prefilter by price range | 70% candidate reduction | Numeric range query before text search |
| Completion suggester | < 10ms autocomplete | Prefix-based city/airport suggestions |
| Geo-distance filtering | Fast proximity search | Geo_point field with distance sort |
Search Result Ranking
Default sorting is by "best value" which combines price, duration, number of stops, and airline rating into a composite score. Users can override this with explicit sort options: cheapest first, fastest first, earliest departure, or best rated. The ranking algorithm assigns weights: price (40%), duration (25%), stops (20%), and airline rating (15%). These weights are tuned via A/B testing to maximise click-through rate and booking conversion.
8. Flight, Hotel & Car Inventory
Inventory management is the beating heart of a travel platform. Unlike e-commerce where you have full control over your stock, travel inventory lives outside your system — in airline reservation systems, hotel property management systems, and car rental company databases. You must poll, cache, and synchronise this external inventory while respecting rate limits and data freshness requirements.
Flight Inventory Architecture
Inventory Adapter Pattern
public interface IGdsAdapter
{
string ProviderName { get; }
Task<List<FlightAvailability>> SearchFlightsAsync(FlightSearchQuery query);
Task<List<HotelAvailability>> SearchHotelsAsync(HotelSearchQuery query);
Task<List<CarAvailability>> SearchCarsAsync(CarSearchQuery query);
Task<InventoryHoldResult> HoldInventoryAsync(InventoryHoldRequest request);
Task<bool> ReleaseHoldAsync(string holdLocator);
Task<BookingConfirmation> ConfirmBookingAsync(BookingConfirmRequest request);
}
public class AmadeusAdapter : IGdsAdapter
{
private readonly HttpClient _httpClient;
private readonly AmadeusConfig _config;
public string ProviderName => "Amadeus";
public async Task<List<FlightAvailability>> SearchFlightsAsync(FlightSearchQuery query)
{
var request = new AmadeusAvailabilityRequest
{
Origin = query.OriginCode,
Destination = query.DestinationCode,
DepartureDate = query.DepartureDate.ToString("yyyy-MM-dd"),
Adults = query.AdultCount,
Children = query.ChildCount,
TravelClass = MapCabinClass(query.CabinClass),
NonStopOnly = query.MaxStops == 0
};
var response = await _httpClient.PostAsync(
"/v1/shopping/flight-offers",
SerializeToJson(request));
response.EnsureSuccessStatusCode();
var amadeusResult = await DeserializeAsync<AmadeusShoppingResponse>(response);
return amadeusResult.Data.Select(MapToAvailability).ToList();
}
private FlightAvailability MapToAvailability(AmadeusOffer offer)
{
return new FlightAvailability
{
Provider = ProviderName,
ExternalId = offer.Id,
AirlineCode = offer.ValidatingAirlineCode,
OriginCode = offer.Itineraries.First().Segments.First().Departure.IataCode,
DestinationCode = offer.Itineraries.First().Segments.Last().Arrival.IataCode,
DepartureTime = offer.Itineraries.First().Segments.First().Departure.At,
ArrivalTime = offer.Itineraries.First().Segments.Last().Arrival.At,
StopCount = offer.Itineraries.First().Segments.Count - 1,
BasePrice = decimal.Parse(offer.Price.Base),
Currency = offer.Price.Currency,
SeatsAvailable = offer.SeatsAvailable ?? 9,
FareClass = offer.PricingOptions?.FareType?.FirstOrDefault() ?? "PUBLISHED"
};
}
}
Hotel Inventory from OTAs and Direct Connects
Hotel inventory is aggregated from multiple sources including the Hotelbeds API, Booking.com affiliate API, Expedia Rapid API, and direct chain APIs (Marriott, Hilton). Each supplier provides room availability, rate plans, cancellation policies, and static content. The Inventory Service maintains a unified hotel catalogue by matching properties across suppliers using a combination of hotel name fuzzy matching, geographic coordinates, and star rating.
public class HotelInventoryAggregator
{
private readonly IEnumerable<IHotelSupplier> _suppliers;
private readonly IHotelMatcher _matcher;
public async Task<List<UnifiedHotelOffer>> AggregateAsync(HotelSearchQuery query)
{
var tasks = _suppliers.Select(s => s.SearchAsync(query));
var results = await Task.WhenAll(tasks);
var allOffers = results.SelectMany(r => r).ToList();
var grouped = allOffers.GroupBy(o => _matcher.GetHotelKey(o));
return grouped.Select(g => new UnifiedHotelOffer
{
HotelId = g.Key,
HotelName = g.First().HotelName,
StarRating = g.First().StarRating,
Location = g.First().Location,
BestPrice = g.Min(o => o.PricePerNight),
BestCurrency = g.First(o => o.PricePerNight == g.Min(x => x.PricePerNight)).Currency,
AvailableSuppliers = g.Select(o => o.Supplier).Distinct().ToList(),
RoomOptions = g.SelectMany(o => o.RoomOptions).OrderBy(r => r.Price).ToList(),
FreeCancellation = g.Any(o => o.FreeCancellation),
BreakfastIncluded = g.Any(o => o.BreakfastIncluded),
ReviewScore = g.Average(o => o.ReviewScore)
}).OrderBy(h => h.BestPrice).ToList();
}
}
Car Rental Inventory
Car rental inventory is sourced from suppliers like Hertz, Avis, Enterprise, and aggregators like CarTrawler. The data model is simpler than flights or hotels — search by pickup/dropoff location and dates, return a list of vehicle categories with daily rates, insurance options, and mileage policies. The Inventory Service must handle one-way rentals where pickup and dropoff locations differ, and must account for local taxes and surcharges that vary by jurisdiction.
Inventory Hold Mechanism
When a user selects an itinerary and proceeds to checkout, the system must hold the inventory atomically. This means reserving the specific seat or room so no other user can book it while the first user enters payment details. The hold has a strict TTL — typically 10 minutes for flights and 15 minutes for hotels — after which the inventory is automatically released. This is implemented using Redis SETNX with TTL and backed by a PostgreSQL row-level lock with a scheduled cleanup job.
public class InventoryHoldService
{
private readonly IDistributedLockManager _lockManager;
private readonly TimeSpan _flightHoldTtl = TimeSpan.FromMinutes(10);
private readonly TimeSpan _hotelHoldTtl = TimeSpan.FromMinutes(15);
public async Task<HoldResult> HoldInventoryAsync(HoldRequest request)
{
var lockKey = $"hold:{request.InventoryType}:{request.InventoryId}";
var acquired = await _lockManager.TryAcquireAsync(
lockKey,
request.SessionId,
request.InventoryType == "FLIGHT" ? _flightHoldTtl : _hotelHoldTtl);
if (!acquired)
{
return new HoldResult
{
Success = false,
Error = "Inventory no longer available. Please search again."
};
}
// Persist hold in database for durability (survives Redis restart)
await SaveHoldRecordAsync(new InventoryHold
{
LockKey = lockKey,
InventoryType = request.InventoryType,
InventoryId = request.InventoryId,
SessionId = request.SessionId,
UserId = request.UserId,
ExpiresAt = DateTimeOffset.UtcNow + (request.InventoryType == "FLIGHT" ? _flightHoldTtl : _hotelHoldTtl)
});
return new HoldResult
{
Success = true,
HoldId = Guid.NewGuid(),
ExpiresAt = DateTimeOffset.UtcNow + _flightHoldTtl
};
}
}
9. Dynamic Pricing Engine
Travel pricing is inherently dynamic. Airlines change fares hundreds of times per day based on demand curves, remaining seat count, competitor pricing, day-of-week patterns, and historical booking data. Hotels adjust rates based on occupancy, local events, seasonality, and booking window. The pricing engine must calculate the real-time fare at the moment of search and maintain a price lock through checkout to protect both the customer and the business.
Pricing Pipeline
C# Pricing Service Implementation
public class DynamicPricingEngine
{
private readonly IDemandAnalyzer _demandAnalyzer;
private readonly IPromotionEngine _promotionEngine;
private readonly ICurrencyService _currencyService;
private readonly IPricingConfig _config;
public async Task<PriceQuote> CalculatePriceAsync(PricingRequest request)
{
var baseFare = request.BaseFare;
baseFare = ApplyFareRules(baseFare, request.FareRules);
var taxes = await CalculateTaxesAsync(
request.OriginCode, request.DestinationCode, request.CabinClass);
var demandMultiplier = await _demandAnalyzer.GetDemandMultiplierAsync(
request.OriginCode, request.DestinationCode,
request.DepartureDate, request.CabinClass);
var surgeAdjustedFare = baseFare * demandMultiplier;
var loyaltyDiscount = CalculateLoyaltyDiscount(request.UserTier, surgeAdjustedFare);
var promoDiscount = await _promotionEngine.CalculateDiscountAsync(
request.UserId, surgeAdjustedFare, request);
var margin = surgeAdjustedFare * _config.GetMarginPercent(request.OriginCountry);
var subtotal = surgeAdjustedFare - loyaltyDiscount - promoDiscount;
var totalPrice = subtotal + taxes + margin;
return new PriceQuote
{
BaseFare = baseFare,
Taxes = taxes,
SurgeMultiplier = demandMultiplier,
SurgeAdjustedFare = surgeAdjustedFare,
LoyaltyDiscount = loyaltyDiscount,
PromoDiscount = promoDiscount,
PlatformMargin = margin,
TotalPrice = Math.Round(totalPrice, 2),
Currency = request.Currency,
ValidUntil = DateTimeOffset.UtcNow.AddMinutes(10),
PriceLockId = await LockPriceAsync(totalPrice, request)
};
}
private decimal ApplyFareRules(decimal baseFare, List<FareRule> rules)
{
foreach (var rule in rules)
{
switch (rule.Type)
{
case "NON_REFUNDABLE":
baseFare *= 0.85m;
break;
case "ADVANCE_PURCHASE":
if (rule.DaysInAdvance >= 21)
baseFare *= 0.90m;
else if (rule.DaysInAdvance >= 7)
baseFare *= 0.95m;
break;
case "SATURDAY_NIGHT_STAY":
if (rule.HasSaturdayNight)
baseFare *= 0.92m;
break;
}
}
return baseFare;
}
private decimal CalculateLoyaltyDiscount(string tier, decimal fare)
{
return tier switch
{
"PLATINUM" => fare * 0.10m,
"GOLD" => fare * 0.07m,
"SILVER" => fare * 0.05m,
"BRONZE" => fare * 0.02m,
_ => 0
};
}
}
Demand Multiplier Calculation
The demand multiplier is a real-time signal derived from multiple data sources: historical booking velocity for the same route and date, current search-to-book conversion ratio, remaining inventory percentage, competitor fare levels scraped periodically, and upcoming events at the destination (conferences, holidays, festivals). During peak demand — Christmas flights, summer holidays in Europe, Diwali travel in India — the multiplier can reach 2.0x to 3.5x. During off-peak periods, it drops to 0.85x to attract price-sensitive travellers.
| Factor | Weight | Data Source | Refresh Interval |
|---|---|---|---|
| Booking velocity | 30% | Kafka event stream | Real-time |
| Remaining inventory % | 25% | GDS availability | 5 minutes |
| Search-to-book ratio | 20% | ClickHouse analytics | 15 minutes |
| Competitor pricing | 15% | Price scraper service | 30 minutes |
| Seasonal baseline | 10% | Historical data (ClickHouse) | Daily recalculation |
10. Booking Flow & State Machine
The booking flow is a multi-step process with strong consistency requirements. Each booking transitions through a well-defined state machine, and the entire flow is implemented as a distributed saga to handle partial failures gracefully across the Inventory, Pricing, Booking, and Payment services.
Booking State Machine
Saga Orchestrator Implementation
public class BookingSagaOrchestrator
{
private readonly IInventoryService _inventoryService;
private readonly IPricingService _pricingService;
private readonly IPaymentService _paymentService;
private readonly IBookingRepository _bookingRepo;
private readonly IEventBus _eventBus;
public async Task<BookingResult> ExecuteBookingAsync(BookingCommand command)
{
var sagaId = Guid.NewGuid();
var booking = new Booking
{
BookingId = Guid.NewGuid(),
UserId = command.UserId,
Status = BookingStatus.DRAFT,
SagaId = sagaId
};
try
{
// Step 1: Validate and lock price
var priceLock = await _pricingService.LockPriceAsync(new PriceLockRequest
{
SessionId = command.SessionId,
InventoryItems = command.Legs.Select(l => l.InventoryId).ToList(),
Amount = command.ExpectedTotal,
Currency = command.Currency
});
booking.Status = BookingStatus.PRICE_LOCKED;
booking.PriceLockId = priceLock.LockId;
await _bookingRepo.SaveAsync(booking);
// Step 2: Process payment
var payment = await _paymentService.ChargeAsync(new PaymentRequest
{
UserId = command.UserId,
Amount = command.ExpectedTotal,
Currency = command.Currency,
PaymentMethodId = command.PaymentMethodId,
IdempotencyKey = $"booking-{booking.BookingId}"
});
if (payment.Status != PaymentStatus.SUCCESS)
{
booking.Status = BookingStatus.PAYMENT_FAILED;
await _bookingRepo.SaveAsync(booking);
await _pricingService.ReleaseLockAsync(priceLock.LockId);
return new BookingResult { Success = false, Error = "Payment failed" };
}
booking.Status = BookingStatus.PAYMENT_PROCESSED;
booking.PaymentId = payment.PaymentId;
await _bookingRepo.SaveAsync(booking);
// Step 3: Confirm inventory with GDS
var confirmations = new List<LegConfirmation>();
foreach (var leg in command.Legs)
{
var confirmation = await _inventoryService.ConfirmAsync(new ConfirmRequest
{
InventoryId = leg.InventoryId,
Passengers = leg.Passengers,
ContactInfo = command.ContactInfo,
PriceLockId = priceLock.LockId
});
confirmations.Add(confirmation);
}
booking.Status = BookingStatus.INVENTORY_CONFIRMED;
booking.Legs = confirmations.Select(c => new BookingLeg
{
LegId = Guid.NewGuid(),
SupplierConfirmation = c.ConfirmationNumber,
Status = LegStatus.CONFIRMED
}).ToList();
await _bookingRepo.SaveAsync(booking);
// Step 4: Final confirmation
booking.Status = BookingStatus.CONFIRMED;
booking.BookingRef = GenerateBookingRef();
booking.BookedAt = DateTimeOffset.UtcNow;
await _bookingRepo.SaveAsync(booking);
// Step 5: Emit events
await _eventBus.PublishAsync(new BookingConfirmedEvent
{
BookingId = booking.BookingId,
UserId = booking.UserId,
BookingRef = booking.BookingRef
});
return new BookingResult { Success = true, Booking = booking };
}
catch (Exception ex)
{
await CompensateAsync(booking, command);
throw;
}
}
private async Task CompensateAsync(Booking booking, BookingCommand command)
{
if (booking.Status == BookingStatus.PAYMENT_PROCESSED ||
booking.Status == BookingStatus.INVENTORY_CONFIRMED)
{
if (booking.PaymentId.HasValue)
{
await _paymentService.RefundAsync(new RefundRequest
{
PaymentId = booking.PaymentId.Value,
Amount = command.ExpectedTotal,
Reason = "Booking saga compensation"
});
}
}
if (booking.PriceLockId.HasValue)
{
await _pricingService.ReleaseLockAsync(booking.PriceLockId.Value);
}
booking.Status = BookingStatus.FAILED;
await _bookingRepo.SaveAsync(booking);
}
private string GenerateBookingRef()
{
return $"TRV{DateTimeOffset.UtcNow:yyyyMMdd}{Guid.NewGuid().ToString("N")[..6].ToUpper()}";
}
}
Booking Flow Sequence
11. Payment & Refunds
Payment processing in travel is uniquely challenging because of the high transaction values, multi-currency requirements, PCI-DSS compliance mandates, and the need for split payments (airline fare vs. taxes vs. service fee). The Payment Service must integrate with multiple gateways — Stripe for cards, Razorpay for UPI in India, PayPal for digital wallets, Adyen for European payment methods — and handle 3D Secure authentication, tokenised card storage, and multi-capture flows where inventory confirmation and payment capture happen asynchronously.
Payment Flow
Payment Service Implementation
public class PaymentService : IPaymentService
{
private readonly IEnumerable<IPaymentGateway> _gateways;
private readonly IFraudDetector _fraudDetector;
private readonly IPaymentRepository _repo;
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
var fraudScore = await _fraudDetector.EvaluateAsync(request);
if (fraudScore.IsHighRisk)
{
await LogFraudEventAsync(request, fraudScore);
return new PaymentResult
{
Status = PaymentStatus.REJECTED,
Reason = "Payment flagged for review"
};
}
var gateway = SelectGateway(request.PaymentMethod, request.Country);
PaymentResult result = null;
var attempts = 0;
var maxAttempts = 2;
while (attempts < maxAttempts)
{
try
{
result = await gateway.ChargeAsync(new GatewayChargeRequest
{
Amount = request.Amount,
Currency = request.Currency,
CustomerId = request.UserId.ToString(),
PaymentToken = request.PaymentToken,
IdempotencyKey = request.IdempotencyKey,
Metadata = new Dictionary<string, string>
{
["booking_id"] = request.BookingId.ToString(),
["user_email"] = request.UserEmail
}
});
break;
}
catch (GatewayException ex) when (ex.IsTransient)
{
attempts++;
if (attempts >= maxAttempts)
gateway = GetFailoverGateway(request.PaymentMethod);
}
}
await _repo.SaveTransactionAsync(new PaymentTransaction
{
PaymentId = Guid.NewGuid(),
BookingId = request.BookingId,
Amount = request.Amount,
Currency = request.Currency,
Gateway = gateway.Name,
GatewayTransactionId = result.GatewayTxnId,
Status = result.Status,
CardLastFour = request.CardLastFour,
CardBrand = request.CardBrand,
ProcessedAt = DateTimeOffset.UtcNow
});
return result;
}
public async Task<RefundResult> ProcessRefundAsync(RefundRequest request)
{
var original = await _repo.GetTransactionAsync(request.PaymentId);
var gateway = GetGatewayByName(original.Gateway);
var refundAmount = request.Amount ?? original.Amount;
var cancellationFee = CalculateCancellationFee(original.BookingId, refundAmount);
var result = await gateway.RefundAsync(new GatewayRefundRequest
{
OriginalTransactionId = original.GatewayTransactionId,
Amount = refundAmount - cancellationFee,
Reason = request.Reason,
IdempotencyKey = $"refund-{request.PaymentId}-{DateTimeOffset.UtcNow.Ticks}"
});
await _repo.SaveRefundAsync(new RefundTransaction
{
RefundId = Guid.NewGuid(),
OriginalPaymentId = request.PaymentId,
RefundAmount = refundAmount - cancellationFee,
CancellationFee = cancellationFee,
Status = result.Status,
GatewayRefundId = result.GatewayRefundId,
ProcessedAt = DateTimeOffset.UtcNow
});
return new RefundResult
{
Success = result.Status == RefundStatus.SUCCESS,
RefundAmount = refundAmount - cancellationFee,
CancellationFee = cancellationFee,
EstimatedRefundDate = DateTimeOffset.UtcNow.AddDays(5)
};
}
private decimal CalculateCancellationFee(Guid bookingId, decimal totalAmount)
{
var hoursUntilDeparture = GetHoursUntilDeparture(bookingId);
return hoursUntilDeparture switch
{
> 72 => totalAmount * 0.10m,
> 24 => totalAmount * 0.25m,
> 6 => totalAmount * 0.50m,
_ => totalAmount * 0.80m
};
}
}
PCI-DSS Compliance Measures
| Requirement | Implementation | Status |
|---|---|---|
| Card data never touches our servers | Stripe.js / Adyen Drop-in for tokenisation | Compliant |
| Encrypted storage of tokens | AWS KMS envelope encryption | Compliant |
| Network segmentation | VPC isolation, WAF rules on payment endpoints | Compliant |
| Access logging | All payment API calls logged to immutable audit trail | Compliant |
| Annual PCI audit | SAQ-A for card-not-present environment | Compliant |
12. Itinerary Management
Once a booking is confirmed, the Itinerary Service generates a comprehensive travel itinerary that aggregates all booking legs, travel documents, local information, and real-time status updates. The itinerary serves as the single source of truth for the traveller and is rendered as a PDF, displayed in the mobile app, and sent via email. It must be available offline for travellers who lose connectivity during their trip.
Itinerary Data Model
public class TravelItinerary
{
public Guid ItineraryId { get; set; }
public Guid BookingId { get; set; }
public string BookingRef { get; set; }
public Guid UserId { get; set; }
public List<ItineraryDay> Days { get; set; }
public List<TravellerInfo> Travellers { get; set; }
public EmergencyContact EmergencyContact { get; set; }
public List<TravelDocument> Documents { get; set; }
public List<ItineraryAlert> ActiveAlerts { get; set; }
public ItineraryStatus Status { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? TripStartDate { get; set; }
public DateTimeOffset? TripEndDate { get; set; }
}
public class ItineraryDay
{
public DateTime Date { get; set; }
public List<ItineraryEvent> Events { get; set; }
}
public class ItineraryEvent
{
public Guid EventId { get; set; }
public EventType Type { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public DateTimeOffset StartTime { get; set; }
public DateTimeOffset? EndTime { get; set; }
public Location Location { get; set; }
public string ConfirmationNumber { get; set; }
public string MapUrl { get; set; }
public List<ItineraryDocument> Attachments { get; set; }
public EventStatus Status { get; set; }
}
public class ItineraryAlert
{
public Guid AlertId { get; set; }
public AlertSeverity Severity { get; set; }
public string Title { get; set; }
public string Message { get; set; }
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ResolvedAt { get; set; }
public List<ActionOption> SuggestedActions { get; set; }
}
public class TravelDocument
{
public DocumentType Type { get; set; }
public string FileName { get; set; }
public string StorageUrl { get; set; }
public DateTimeOffset GeneratedAt { get; set; }
}
Itinerary Features
- Day-by-day view: Groups all events (flights, hotel check-ins, car pickups) by calendar day with local time zones. Users can toggle between their home timezone and local timezone for the destination.
- Offline access: The mobile app caches the itinerary for offline access during travel when connectivity may be limited. A background sync job updates cached data when connectivity resumes.
- Real-time updates: Push notifications for gate changes, delays, and check-in reminders via WebSocket connections. The notification service listens to Kafka events from the Inventory Service and pushes updates to connected clients.
- Document attachment: E-tickets, hotel vouchers, car rental agreements, visa copies, and insurance documents stored as PDFs in S3 with pre-signed URLs for secure access.
- Share with companions: Generate a shareable link so travel companions can view the same itinerary without an account. Share links are time-limited and can be revoked.
- Calendar integration: One-tap export to Google Calendar, Apple Calendar, or Outlook via .ics file generation.
13. Review & Rating System
The review and rating system builds trust and drives conversion. It must support verified bookings (only users who actually stayed at a hotel or flew on a flight can leave reviews), prevent duplicate reviews, aggregate ratings across multiple dimensions, and surface the most helpful reviews via a ranking algorithm. The system also handles review moderation to filter out offensive content and detect fake reviews using NLP analysis.
Multi-Dimensional Rating
| Entity | Rating Dimensions | Scale |
|---|---|---|
| Hotel | Cleanliness, Comfort, Location, Staff, Value, Amenities | 1-5 stars |
| Flight | Seat comfort, Service, Punctuality, Food, Entertainment | 1-5 stars |
| Car Rental | Vehicle condition, Pick-up speed, Customer service, Value | 1-5 stars |
public class ReviewService : IReviewService
{
private readonly IReviewRepository _repo;
private readonly IBookingRepository _bookingRepo;
private readonly IReviewRanker _ranker;
public async Task<ReviewResult> SubmitReviewAsync(ReviewSubmission submission)
{
var booking = await _bookingRepo.FindBookingAsync(
submission.UserId, submission.EntityType, submission.EntityId);
if (booking == null || booking.Status != BookingStatus.CONFIRMED)
{
return new ReviewResult
{
Success = false,
Error = "Only verified bookings can be reviewed"
};
}
var existing = await _repo.FindByUserAndEntityAsync(
submission.UserId, submission.EntityType, submission.EntityId);
if (existing != null)
{
return new ReviewResult
{
Success = false,
Error = "You have already reviewed this booking"
};
}
if (!IsReviewEligible(booking))
{
return new ReviewResult
{
Success = false,
Error = "Reviews can be submitted 24 hours after completion"
};
}
var review = new Review
{
ReviewId = Guid.NewGuid(),
UserId = submission.UserId,
EntityType = submission.EntityType,
EntityId = submission.EntityId,
Rating = submission.Rating,
DimensionRatings = submission.DimensionRatings,
Title = submission.Title,
Body = submission.Body,
IsVerifiedBooking = true,
CreatedAt = DateTimeOffset.UtcNow
};
await _repo.SaveAsync(review);
await RecalculateAggregateRatingAsync(submission.EntityType, submission.EntityId);
return new ReviewResult { Success = true, ReviewId = review.ReviewId };
}
private bool IsReviewEligible(Booking booking)
{
var lastLegEnd = booking.Legs.Max(l => l.ArrivalTime);
return DateTimeOffset.UtcNow >= lastLegEnd.AddHours(24);
}
}
Review Ranking Algorithm
Reviews are ranked using a composite score that considers recency (more recent reviews rank higher), helpfulness (upvotes from other users), verified booking status (verified reviews are boosted), rating extremity (very positive and very negative reviews tend to be more informative), and reviewer credibility (users with a history of helpful reviews get a boost). The ranking algorithm is periodically retrained using A/B tests to optimise for the metric that correlates most strongly with booking conversion.
14. Travel Alerts & Notifications
Real-time travel alerts keep passengers informed about disruptions, gate changes, weather events, and visa requirement updates. The Notification Service consumes events from Kafka topics and fans out to multiple delivery channels: email (via Amazon SES), SMS (via Twilio), push notifications (via Firebase Cloud Messaging), and in-app real-time messages (via WebSockets).
Alert Types and Routing
| Alert Type | Channels | Priority | Source |
|---|---|---|---|
| Flight delay | Push, SMS, Email | High | Airline GDS feed |
| Gate change | Push, SMS | High | Airport systems |
| Flight cancellation | Push, SMS, Email | Critical | Airline GDS feed |
| Booking confirmation | Normal | Booking Service | |
| Check-in reminder | Push, Email | Normal | Scheduled job |
| Hotel check-in time | Push | Low | Scheduled job |
| Weather warning | Push, Email | Medium | Weather API |
| Visa requirement change | Medium | Immigration API | |
| Price drop alert | Push, Email | Low | Pricing Service |
| Review reminder | Push, Email | Low | Scheduled job |
public class NotificationService
{
private readonly IEmailProvider _email;
private readonly ISmsProvider _sms;
private readonly IPushProvider _push;
private readonly IWebSocketHub _wsHub;
private readonly INotificationPreferenceRepository _prefs;
public async Task SendAlertAsync(TravelAlert alert)
{
var preferences = await _prefs.GetForUserAsync(alert.UserId);
var tasks = new List<Task>();
if (preferences.PushEnabled && alert.Priority >= Priority.Normal)
{
tasks.Add(_push.SendAsync(new PushMessage
{
UserId = alert.UserId,
Title = alert.Title,
Body = alert.Message,
Data = alert.Metadata,
ChannelId = "travel-alerts",
Priority = alert.Priority == Priority.Critical ? "high" : "normal"
}));
}
if (preferences.SmsEnabled && alert.Priority >= Priority.High)
{
tasks.Add(_sms.SendAsync(new SmsMessage
{
PhoneNumber = preferences.PhoneNumber,
Body = $"{alert.Title}: {alert.Message}",
SenderId = "TRVLBOOK"
}));
}
if (alert.RequiresEmail || preferences.EmailEnabled)
{
tasks.Add(_email.SendAsync(new EmailMessage
{
To = preferences.EmailAddress,
Subject = alert.Title,
TemplateId = alert.EmailTemplateId,
TemplateData = alert.TemplateData
}));
}
if (preferences.InAppEnabled)
{
await _wsHub.SendToUserAsync(alert.UserId, new WsMessage
{
Type = "TRAVEL_ALERT",
Payload = alert
});
}
await Task.WhenAll(tasks);
}
}
Alert Delivery SLAs
| Priority | Target Delivery Time | Channel Fallback |
|---|---|---|
| Critical (cancellation) | < 30 seconds | Push fails -> SMS -> Email |
| High (delay, gate change) | < 1 minute | Push fails -> SMS |
| Medium (weather, visa) | < 5 minutes | Push fails -> Email |
| Low (reminders, promos) | < 30 minutes | Email only |
15. Multi-Currency & Localization
A global travel platform must support dozens of currencies and localise content for different markets. Currency conversion must use real-time exchange rates with a small markup to cover forex risk. Prices displayed during search must be in the user's preferred currency, and the final payment must be in the currency supported by the user's payment method. Localization extends beyond currency — date formats, number formatting, language, and regulatory text (terms and conditions, visa warnings) must all be adapted to the user's locale.
Currency Architecture
public class CurrencyService : ICurrencyService
{
private readonly IExchangeRateProvider _rateProvider;
private readonly IDistributedCache _cache;
private const decimal MarkupPercent = 0.015m;
public async Task<decimal> ConvertAsync(
decimal amount, string fromCurrency, string toCurrency)
{
if (fromCurrency == toCurrency) return amount;
var rate = await GetExchangeRateAsync(fromCurrency, toCurrency);
var converted = amount * rate;
var withMarkup = converted * (1 + MarkupPercent);
return Math.Round(withMarkup, GetDecimalPlaces(toCurrency));
}
public async Task<Dictionary<string, decimal>> GetMultiCurrencyPricesAsync(
decimal baseAmount, string baseCurrency, List<string> targetCurrencies)
{
var tasks = targetCurrencies
.Where(c => c != baseCurrency)
.Select(async c => new { Currency = c, Amount = await ConvertAsync(baseAmount, baseCurrency, c) });
var results = await Task.WhenAll(tasks);
return results.ToDictionary(r => r.Currency, r => r.Amount);
}
private async Task<decimal> GetExchangeRateAsync(string from, string to)
{
var cacheKey = $"fx:{from}:{to}";
var cached = await _cache.GetAsync<decimal?>(cacheKey);
if (cached.HasValue) return cached.Value;
var rate = await _rateProvider.GetRateAsync(from, to);
await _cache.SetAsync(cacheKey, rate, TimeSpan.FromMinutes(15));
return rate;
}
private int GetDecimalPlaces(string currency) => currency switch
{
"JPY" or "KRW" or "VND" => 0,
"BHD" or "KWD" or "OMR" => 3,
_ => 2
};
}
Supported Currencies and Regions
| Region | Primary Currencies | Payment Methods | Regulatory Notes |
|---|---|---|---|
| North America | USD, CAD | Cards, PayPal, Apple Pay | PCI-DSS, SOC 2 |
| Europe | EUR, GBP, CHF | Cards, iDEAL, Bancontact | PSD2 SCA required |
| India | INR | UPI, Cards, Net Banking, EMI | RBI guidelines, GST |
| Southeast Asia | SGD, MYR, THB, IDR | Cards, GrabPay, GCash | Data residency |
| Middle East | AED, SAR, QAR | Cards, Mada, STC Pay | VAT compliance |
| Japan | JPY | Cards, Konbini, PayPay | Japanese language required |
| LATAM | BRL, MXN, ARS | Cards, Boleto, OXXO | Installment plans popular |
16. Loyalty Program
A loyalty program drives repeat bookings and increases customer lifetime value. Points are earned per dollar spent and can be redeemed for discounts, upgrades, or free bookings. The program has tier levels (Bronze, Silver, Gold, Platinum) that unlock benefits like priority support, lounge access, and bonus points multipliers. Points expire after 24 months of account inactivity to encourage regular engagement.
Loyalty Points Engine
public class LoyaltyService
{
private readonly ILoyaltyRepository _repo;
private readonly IPricingService _pricing;
public async Task<PointsResult> EarnPointsAsync(EarnPointsCommand command)
{
var account = await _repo.GetAccountAsync(command.UserId);
var basePoints = CalculateBasePoints(command.Amount, command.BookingType);
var multiplier = GetTierMultiplier(account.Tier);
var bonusPoints = await CalculateBonusPointsAsync(command);
var totalEarned = (long)((basePoints * multiplier) + bonusPoints);
account.PointsBalance += totalEarned;
account.LifetimePoints += totalEarned;
account.Tier = CalculateNewTier(account.LifetimePoints);
await _repo.UpdateAsync(account);
await _repo.AddTransactionAsync(new LoyaltyTransaction
{
AccountId = account.AccountId,
Type = LoyaltyTransactionType.EARN,
Points = totalEarned,
BookingId = command.BookingId,
Description = $"Points earned on {command.BookingType} booking"
});
return new PointsResult
{
BasePoints = basePoints,
TierMultiplier = multiplier,
BonusPoints = bonusPoints,
TotalEarned = totalEarned,
NewBalance = account.PointsBalance,
NewTier = account.Tier
};
}
public async Task<RedemptionResult> RedeemPointsAsync(RedeemPointsCommand command)
{
var account = await _repo.GetAccountAsync(command.UserId);
if (account.PointsBalance < command.PointsToRedeem)
return new RedemptionResult { Success = false, Error = "Insufficient points" };
var redemptionValue = command.PointsToRedeem * 0.01m;
var bookingAmount = await _pricing.GetBookingAmountAsync(command.BookingId);
if (redemptionValue > bookingAmount * 0.50m)
{
redemptionValue = bookingAmount * 0.50m;
command.PointsToRedeem = (long)(redemptionValue / 0.01m);
}
account.PointsBalance -= command.PointsToRedeem;
await _repo.UpdateAsync(account);
return new RedemptionResult
{
Success = true,
PointsRedeemed = command.PointsToRedeemed,
MonetaryValue = redemptionValue,
RemainingBalance = account.PointsBalance
};
}
private long CalculateBasePoints(decimal amount, string bookingType)
{
var pointsPerDollar = bookingType switch
{
"FLIGHT" => 5,
"HOTEL" => 8,
"CAR" => 3,
"PACKAGE" => 10,
_ => 5
};
return (long)(amount * pointsPerDollar);
}
private decimal GetTierMultiplier(string tier) => tier switch
{
"PLATINUM" => 2.0m,
"GOLD" => 1.5m,
"SILVER" => 1.2m,
"BRONZE" => 1.0m,
_ => 1.0m
};
private string CalculateNewTier(long lifetimePoints) => lifetimePoints switch
{
>= 100_000 => "PLATINUM",
>= 50_000 => "GOLD",
>= 10_000 => "SILVER",
_ => "BRONZE"
};
}
Loyalty Tier Benefits
| Tier | Lifetime Points | Points Multiplier | Benefits |
|---|---|---|---|
| Bronze | 0 - 9,999 | 1.0x | Basic earning, standard support |
| Silver | 10,000 - 49,999 | 1.2x | Priority check-in, 5% hotel discount |
| Gold | 50,000 - 99,999 | 1.5x | Lounge access, free seat selection, 10% discount |
| Platinum | 100,000+ | 2.0x | Free upgrade, dedicated support, 15% discount, early access to deals |
17. Partner API Integration
The Partner API enables white-label integration for online travel agencies (OTAs), corporate travel managers, and affiliate marketers. Partners authenticate via OAuth 2.0 client credentials, search and book inventory through our platform, and receive webhook notifications for booking status changes. Rate limiting and usage-based billing ensure fair resource allocation across partners.
Partner API Implementation
public class PartnerApiController : ControllerBase
{
private readonly IPartnerService _partnerService;
private readonly IRateLimiter _rateLimiter;
[HttpPost("search")]
[ServiceFilter(typeof(PartnerAuthFilter))]
public async Task<ActionResult<PartnerSearchResponse>> PartnerSearch(
[FromBody] PartnerSearchRequest request)
{
var partner = HttpContext.Items["Partner"] as Partner;
var rateLimitResult = await _rateLimiter.CheckAsync(
partner.PartnerId, "search", partner.SearchQuota);
if (!rateLimitResult.Allowed)
{
return StatusCode(429, new { error = "Rate limit exceeded",
retry_after = rateLimitResult.RetryAfterSeconds });
}
var results = await _partnerService.SearchAsync(request, partner);
var markedUp = ApplyPartnerMarkup(results, partner.CommissionPercent);
return Ok(new PartnerSearchResponse
{
Results = markedUp,
Quota = new QuotaInfo
{
Remaining = rateLimitResult.Remaining,
ResetsAt = rateLimitResult.ResetsAt
}
});
}
[HttpPost("book")]
[ServiceFilter(typeof(PartnerAuthFilter))]
public async Task<ActionResult<PartnerBookingResponse>> PartnerBook(
[FromBody] PartnerBookingRequest request)
{
var partner = HttpContext.Items["Partner"] as Partner;
var result = await _partnerService.BookAsync(request, partner);
await SendWebhookAsync(partner.WebhookUrl, new PartnerWebhookPayload
{
EventType = "BOOKING_CONFIRMED",
BookingRef = result.BookingRef,
Timestamp = DateTimeOffset.UtcNow
});
return Ok(result);
}
private List<SearchResult> ApplyPartnerMarkup(List<SearchResult> results, decimal commission)
{
foreach (var result in results)
result.DisplayPrice = result.BasePrice * (1 + commission / 100);
return results;
}
}
Partner Tiers and Rate Limits
| Partner Tier | Monthly Fee | Search QPS | Booking QPS | Commission |
|---|---|---|---|---|
| Starter | Free | 10 | 2 | 5% |
| Professional | $500/month | 100 | 20 | 3% |
| Enterprise | Custom | 1000 | 100 | Negotiated |
Webhook Integration
Partners receive real-time notifications via webhooks for booking confirmations, cancellations, refunds, and travel alerts. Each webhook payload is signed with HMAC-SHA256 using a shared secret, allowing partners to verify authenticity. Failed webhook deliveries are retried with exponential backoff (3 attempts over 1 hour) and logged for debugging. Partners can register up to 3 webhook URLs for redundancy.
18. Cancellation & Rebooking
Cancellation is a complex business process governed by fare rules, timing, and refund policies. Each fare class has specific cancellation terms — some fares are fully refundable, some carry cancellation fees, and some are entirely non-refundable. The cancellation flow must coordinate across the Booking Service, Payment Service, and Inventory Service to release inventory back to the GDS and process refunds.
Cancellation Flow
Rebooking Implementation
public class RebookingService
{
private readonly IBookingRepository _bookingRepo;
private readonly IInventoryService _inventoryService;
private readonly IPricingService _pricingService;
private readonly IPaymentService _paymentService;
public async Task<RebookingResult> RebookAsync(RebookRequest request)
{
var originalBooking = await _bookingRepo.GetAsync(request.BookingId);
if (originalBooking.UserId != request.UserId)
throw new UnauthorizedException("Not your booking");
if (originalBooking.Status != BookingStatus.CONFIRMED)
throw new InvalidOperationException("Only confirmed bookings can be rebooked");
var newAvailability = await _inventoryService.SearchAsync(new SearchRequest
{
OriginCode = originalBooking.Legs.First().OriginCode,
DestinationCode = originalBooking.Legs.First().DestinationCode,
DepartureDate = request.NewDepartureDate,
PassengerCount = originalBooking.Legs.Sum(l => l.PassengerCount)
});
if (!newAvailability.Any())
return new RebookingResult { Success = false, Error = "No availability for requested dates" };
var bestOption = newAvailability.First();
var originalTotal = originalBooking.Legs.Sum(l => l.TotalFare);
var fareDifference = bestOption.TotalPrice - originalTotal;
var rebookingFee = CalculateRebookingFee(originalBooking);
if (fareDifference > 0)
{
var totalDue = fareDifference + rebookingFee;
var payment = await _paymentService.ChargeAsync(new PaymentRequest
{
UserId = request.UserId,
Amount = totalDue,
Currency = originalBooking.Currency,
PaymentMethodId = request.PaymentMethodId,
IdempotencyKey = $"rebook-{request.BookingId}-{DateTimeOffset.UtcNow.Ticks}"
});
if (payment.Status != PaymentStatus.SUCCESS)
return new RebookingResult { Success = false, Error = "Payment for fare difference failed" };
}
else if (fareDifference < 0)
{
var refundAmount = Math.Abs(fareDifference) - rebookingFee;
if (refundAmount > 0)
{
await _paymentService.RefundAsync(new RefundRequest
{
PaymentId = originalBooking.PaymentId.Value,
Amount = refundAmount,
Reason = "Rebooking fare difference refund"
});
}
}
foreach (var leg in originalBooking.Legs)
await _inventoryService.CancelAsync(leg.SupplierConfirmation);
var newConfirmation = await _inventoryService.ConfirmAsync(new ConfirmRequest
{
InventoryId = bestOption.InventoryId,
Passengers = originalBooking.Legs.First().Passengers
});
originalBooking.Legs = new List<BookingLeg>
{
new BookingLeg
{
LegId = Guid.NewGuid(),
SupplierConfirmation = newConfirmation.ConfirmationNumber,
DepartureTime = bestOption.DepartureTime,
ArrivalTime = bestOption.ArrivalTime,
TotalFare = bestOption.TotalPrice,
Status = LegStatus.CONFIRMED
}
};
originalBooking.TotalAmount = bestOption.TotalPrice;
originalBooking.Status = BookingStatus.CONFIRMED;
await _bookingRepo.UpdateAsync(originalBooking);
return new RebookingResult
{
Success = true,
FareDifference = fareDifference,
RebookingFee = rebookingFee,
NewBookingRef = originalBooking.BookingRef
};
}
private decimal CalculateRebookingFee(Booking booking)
{
var hoursUntilDeparture = booking.Legs.Min(l =>
(l.DepartureTime - DateTimeOffset.UtcNow).TotalHours);
return hoursUntilDeparture switch
{
> 72 => 25.00m,
> 24 => 50.00m,
> 6 => 100.00m,
_ => 0
};
}
}
19. Fraud Detection
Travel booking fraud is a significant threat — stolen credit cards, account takeover, and loyalty point theft cost the industry billions annually. The Fraud Detection Service runs in real-time as part of the payment flow, analysing transaction patterns, device fingerprints, IP geolocation, and user behaviour to assign a risk score to every booking attempt. High-risk transactions are flagged for manual review or blocked outright.
Fraud Signals and Scoring
public class FraudDetector : IFraudDetector
{
private readonly IDeviceFingerprintService _deviceService;
private readonly IVelocityChecker _velocityChecker;
private readonly IBinLookupService _binLookup;
private readonly IMLModelRunner _mlRunner;
public async Task<FraudAssessment> EvaluateAsync(PaymentRequest request)
{
var signals = new FraudSignals();
var device = await _deviceService.GetFingerprintAsync(request.DeviceFingerprint);
signals.IsNewDevice = device.FirstSeenDaysAgo < 3;
signals.IsKnownFraudDevice = device.IsFlagged;
signals.DeviceReputationScore = device.ReputationScore;
signals.PaymentsLastHour = await _velocityChecker.CountAsync(
request.UserId, "payment", TimeSpan.FromHours(1));
signals.PaymentsLastDay = await _velocityChecker.CountAsync(
request.UserId, "payment", TimeSpan.FromDays(1));
signals.SameCardMultipleAccounts = await _velocityChecker.CheckCardSpreadAsync(
request.CardLastFour, maxAccounts: 3, window: TimeSpan.FromDays(7));
var bin = await _binLookup.LookupAsync(request.CardBIN);
signals.CardCountry = bin.IssuingCountry;
signals.IpCountry = request.IpCountry;
signals.CountryMismatch = bin.IssuingCountry != request.IpCountry;
signals.LastMinuteBooking = request.BookingDate - request.DepartureDate <
TimeSpan.FromHours(24);
signals.HighValueBooking = request.Amount > 2000;
signals.OneWayInternational = request.IsOneWay && request.IsInternational;
var features = BuildFeatureVector(signals);
var mlScore = await _mlRunner.PredictFraudAsync(features);
var riskScore = CalculateCompositeRiskScore(signals, mlScore);
return new FraudAssessment
{
RiskScore = riskScore,
IsHighRisk = riskScore > 0.7,
RequiresManualReview = riskScore > 0.5 && riskScore <= 0.7,
Signals = signals,
Recommendation = riskScore switch
{
> 0.8 => FraudDecision.BLOCK,
> 0.5 => FraudDecision.REVIEW,
_ => FraudDecision.APPROVE
}
};
}
private decimal CalculateCompositeRiskScore(FraudSignals signals, float mlScore)
{
var score = mlScore * 0.4m;
if (signals.IsKnownFraudDevice) score += 0.3m;
if (signals.PaymentsLastHour > 3) score += 0.15m;
if (signals.SameCardMultipleAccounts) score += 0.2m;
if (signals.CountryMismatch) score += 0.1m;
if (signals.HighValueBooking && signals.IsNewDevice) score += 0.1m;
if (signals.LastMinuteBooking && signals.OneWayInternational) score += 0.1m;
return Math.Min(score, 1.0m);
}
}
Fraud Prevention Measures
| Measure | Implementation | Impact |
|---|---|---|
| Device fingerprinting | FingerprintJS with server-side validation | Detects account takeover |
| Velocity limits | Redis counters per user/card/IP | Prevents card testing |
| 3D Secure authentication | Stripe 3DS2 / Adyen 3DS2 | Shifts liability to issuer |
| Address verification (AVS) | Gateway AVS check | Validates billing address |
| CVC verification | Gateway CVC check | Validates card in hand |
| ML fraud model | XGBoost trained on historical fraud | 85% fraud detection rate |
| Manual review queue | Snowflake dashboard + ops team | Catches edge cases |
| Account lockout | 3 failed attempts = 24hr lock | Prevents brute force |
The ML fraud model is retrained weekly using labelled data from the manual review queue. Features include transaction velocity, device reputation, geographic distance between IP and card BIN country, time of day patterns, booking value relative to user history, and session behaviour signals (mouse movements, typing speed). The model achieves a precision of 92% and recall of 85%, with a false positive rate below 2% — critical for avoiding legitimate bookings being blocked.
20. Monitoring & Observability
A travel platform requires comprehensive observability across search latency, booking success rates, GDS partner health, payment gateway status, and business metrics like conversion rate and revenue per search. We use a three-pillar observability approach: metrics (Prometheus + Grafana), logs (ELK Stack), and traces (Jaeger). Automated alerting ensures the on-call team is notified within minutes of any degradation.
Key Metrics Dashboard
| Metric | Target | Alert Threshold | Dashboard |
|---|---|---|---|
| Search latency (P95) | < 500ms | > 800ms for 5 min | Grafana - Search |
| Booking success rate | > 95% | < 90% for 10 min | Grafana - Bookings |
| Payment success rate | > 98% | < 95% for 5 min | Grafana - Payments |
| GDS API availability | > 99.9% | < 99% for 10 min | Grafana - Partners |
| Inventory hold expiry rate | < 30% | > 50% for 1 hour | Grafana - Inventory |
| Error rate (5xx) | < 0.1% | > 0.5% for 5 min | Grafana - Errors |
| Search-to-book conversion | > 1.5% | < 1.0% daily | Grafana - Business |
| Revenue per search | > $0.50 | < $0.30 daily | Grafana - Revenue |
| Fraud detection rate | > 85% | < 70% weekly | Grafana - Security |
| Average booking value | $450 | Drop > 20% week-over-week | Grafana - Business |
Distributed Tracing Implementation
[ApiController]
[Route("api/v1/search")]
public class SearchController : ControllerBase
{
private readonly ISearchService _searchService;
private readonly ITracer _tracer;
[HttpPost("flights")]
public async Task<ActionResult<FlightSearchResponse>> SearchFlights(
[FromBody] FlightSearchRequest request)
{
using var span = _tracer.BuildSpan("search.flights")
.WithTag("origin", request.OriginCode)
.WithTag("destination", request.DestinationCode)
.WithTag("cabin_class", request.CabinClass?.ToString() ?? "ECONOMY")
.Start();
try
{
var results = await _searchService.SearchFlightsAsync(request);
span.Log("results.count", results.Count);
Metrics.SearchQueries.WithLabels(
request.OriginCode, request.DestinationCode, "flight"
).Inc();
Metrics.SearchResultsCount.Observe(results.Count);
return Ok(results);
}
catch (Exception ex)
{
span.SetTag("error", true);
span.Log("error.message", ex.Message);
Metrics.SearchErrors.Inc();
throw;
}
}
}
Alerting Rules
groups:
- name: travel-platform
rules:
- alert: HighSearchLatency
expr: histogram_quantile(0.95, search_latency_seconds_bucket) > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "Search P95 latency exceeds 800ms"
- alert: BookingSuccessRateLow
expr: rate(bookings_successful_total[5m]) / rate(bookings_attempted_total[5m]) < 0.90
for: 10m
labels:
severity: critical
annotations:
summary: "Booking success rate dropped below 90%"
- alert: PaymentGatewayDown
expr: up{job="payment-gateway"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Payment gateway health check failing"
- alert: InventoryHoldSpike
expr: rate(inventory_hold_expired_total[5m]) > 50
for: 10m
labels:
severity: warning
annotations:
summary: "High inventory hold expiry rate"
21. Cost Estimation
Running a production travel booking platform at scale involves significant infrastructure costs. Below is an estimated monthly cost breakdown for a system handling 10 million daily active users with 50 million searches per day.
Monthly Infrastructure Cost
| Component | Specification | Quantity | Monthly Cost |
|---|---|---|---|
| Application servers (ECS/EKS) | c5.2xlarge (8 vCPU, 16 GB) | 20 | $5,600 |
| PostgreSQL (RDS Multi-AZ) | db.r5.2xlarge (8 vCPU, 64 GB) | 3 (1 primary + 2 replicas) | $4,800 |
| Elasticsearch | r5.large.xsearch (2 vCPU, 16 GB) | 9 (3 shards x 3 nodes) | $3,600 |
| Redis (ElastiCache) | r5.xlarge (4 vCPU, 26 GB) | 6 (cluster mode) | $2,400 |
| Kafka (MSK) | kafka.m5.large | 6 brokers | $2,160 |
| ClickHouse (analytics) | Storage optimised r5.2xlarge | 3 | $3,600 |
| CDN (CloudFront) | 20 TB/month transfer | 1 | $1,700 |
| Load balancers (ALB) | Application load balancer | 2 | $400 |
| S3 storage | 5 TB (itinerary PDFs, images) | 1 | $115 |
| CloudWatch / Monitoring | Logs + metrics | 1 | $800 |
| KMS / Secrets | Encryption keys | 1 | $50 |
| DNS (Route 53) | Hosted zone + queries | 1 | $50 |
| Total Infrastructure | $25,275 |
Third-Party Service Costs
| Service | Usage | Monthly Cost |
|---|---|---|
| GDS access fees (Amadeus/Sabre) | 50M queries/month | $50,000 - $150,000 |
| Payment gateway fees | 2% of $225M GMV | $4,500,000 |
| Email (Amazon SES) | 5M emails/month | $1,000 |
| SMS (Twilio) | 1M SMS/month | $70,000 |
| Push notifications (FCM) | 10M push/month | $0 (free tier) |
| Exchange rate API | 1M lookups/month | $200 |
| Total Third-Party | $4,621,200 |
Cost Optimisation Strategies
- Reserved instances: Purchase 1-year reserved instances for stable workloads (PostgreSQL, Redis, Kafka) to save 30-40% over on-demand pricing.
- Spot instances for search: The Search Service is stateless and can run on spot instances for 60-70% savings, with auto-scaling to handle spot interruptions.
- Cache optimisation: Increasing the Redis hit rate from 80% to 90% reduces Elasticsearch queries by 50%, potentially removing 3 nodes from the cluster.
- Query batching: Batch multiple GDS queries into single API calls where the GDS supports it, reducing per-query costs by 40%.
- Log retention tiers: Hot logs in ELK for 7 days, warm in S3 for 90 days, cold in Glacier for 7 years (regulatory requirement).
22. Testing Strategy
A travel platform must be tested rigorously across unit, integration, contract, and load testing dimensions. The unique challenge is that GDS partners have sandbox environments with limited availability and unpredictable responses. We build comprehensive mock servers that simulate GDS behaviour, including edge cases like overselling, timeout responses, and partial itinerary failures.
Testing Pyramid
| Test Type | Scope | Tool | Coverage Target |
|---|---|---|---|
| Unit tests | Pricing engine, fraud scoring, fare rules | xUnit + Moq | 90% line coverage |
| Integration tests | DB queries, Redis, Kafka events | Testcontainers | All repository methods |
| Contract tests | GDS adapter responses | Pact | All adapter interfaces |
| E2E tests | Full search-to-booking flow | Playwright + API | Critical user journeys |
| Load tests | Search and booking endpoints | k6 / Gatling | 2x peak capacity |
| Chaos tests | GDS failure, payment timeout | Chaos Monkey / Litmus | All failure modes |
Example Integration Test
public class BookingIntegrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _db;
private readonly RedisContainer _redis;
public async Task InitializeAsync()
{
_db = new PostgreSqlBuilder()
.WithImage("postgres:16")
.WithDatabase("travel_test")
.Build();
await _db.StartAsync();
_redis = new RedisBuilder()
.WithImage("redis:7")
.Build();
await _redis.StartAsync();
}
[Fact]
public async Task FullBookingFlow_ShouldCreateConfirmedBooking()
{
var user = await CreateTestUserAsync();
var searchResult = await SearchFlightsAsync("JFK", "LAX", DateTime.Now.AddDays(14));
var selectedFlight = searchResult.Results.First();
var priceLock = await LockPriceAsync(selectedFlight.Id, user.Id);
var booking = await CreateBookingAsync(new CreateBookingRequest
{
SessionId = user.SessionId,
PriceLockId = priceLock.Id,
Legs = new[] { new LegRequest { InventoryId = selectedFlight.Id } },
Passengers = new[] { new PassengerRequest { Name = "Test User" } },
ContactInfo = new ContactInfo { Email = "test@example.com" }
});
var payment = await ProcessPaymentAsync(booking.BookingId, new PaymentRequest
{
Amount = priceLock.Amount,
Currency = "USD",
PaymentMethodId = CreateTestPaymentMethod()
});
booking.Should().NotBeNull();
booking.Status.Should().Be(BookingStatus.CONFIRMED);
booking.BookingRef.Should().StartWith("TRV");
payment.Status.Should().Be(PaymentStatus.SUCCESS);
var savedBooking = await GetBookingFromDbAsync(booking.BookingId);
savedBooking.Should().NotBeNull();
savedBooking.Legs.Should().HaveCount(1);
savedBooking.Legs.First().SupplierConfirmation.Should().NotBeNullOrEmpty();
}
[Theory]
[InlineData("JFK", "LAX", true)]
[InlineData("DEL", "BOM", true)]
[InlineData("LHR", "CDG", true)]
public async Task SearchFlights_ShouldReturnResults(string origin, string dest, bool expected)
{
var results = await SearchFlightsAsync(origin, dest, DateTime.Now.AddDays(7));
results.Results.Any().Should().Be(expected);
}
public async Task DisposeAsync()
{
await _db.DisposeAsync();
await _redis.DisposeAsync();
}
}
Load Testing Configuration
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 1000 },
{ duration: '5m', target: 5000 },
{ duration: '3m', target: 10000 },
{ duration: '2m', target: 1000 },
],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const payload = JSON.stringify({
originCode: ['JFK', 'LAX', 'LHR', 'DEL', 'SIN'][Math.floor(Math.random() * 5)],
destinationCode: ['LAX', 'JFK', 'CDG', 'BOM', 'NRT'][Math.floor(Math.random() * 5)],
departureDate: new Date(Date.now() + 14 * 86400000).toISOString().split('T')[0],
cabinClass: 'ECONOMY',
passengers: { adults: 1 }
});
const res = http.post('https://api.example.com/api/v1/search/flights', payload, {
headers: { 'Content-Type': 'application/json' },
});
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has results': (r) => JSON.parse(r.body).results.length > 0,
});
sleep(1);
}
23. Interview Q&A
Q1: How do you prevent overselling when two users book the same seat simultaneously?
Answer: We use a pessimistic locking strategy at the GDS level combined with a Redis-based distributed lock in our application layer. When a user selects a flight, we acquire a Redis lock with a 10-minute TTL using SETNX. If two users try to lock the same seat simultaneously, only one succeeds because Redis SETNX is atomic. We also request an inventory hold from the GDS, which provides a secondary guarantee. For hotels, we use optimistic concurrency control with version numbers — the booking UPDATE statement includes a WHERE clause checking the current version, and if it fails, we retry with a fresh read. The combination of application-level locks and GDS-level holds provides a two-layer defence against overselling.
Q2: How do you handle GDS API downtime or slow responses?
Answer: We implement a circuit breaker pattern (Polly in .NET) for each GDS adapter. If a GDS starts returning errors or responses exceed 5 seconds, the circuit opens for 60 seconds, during which searches are served from cached results or from other available GDS providers. We maintain at least 2 GDS connections (Amadeus + Sabre) and can degrade to cached results if both fail. The Inventory Service publishes a GDS health event to Kafka every 30 seconds, which the Search Service uses to exclude unhealthy providers from queries. During partial outages, search results are clearly marked with "prices may vary" banners to manage user expectations.
Q3: How would you design the dynamic pricing engine?
Answer: The pricing engine runs as a separate microservice that subscribes to booking events via Kafka. It maintains a demand model per route and date, updated in real-time as bookings occur. The model uses a weighted combination of five signals: historical booking velocity (30%), remaining inventory percentage (25%), search-to-book conversion ratio (20%), competitor prices (15%), and seasonal baseline (10%). The final multiplier ranges from 0.85x to 3.5x. Price locks are stored in Redis with TTL, and the pricing snapshot is persisted in ClickHouse for audit trail. The entire calculation completes in under 50ms because all inputs are pre-aggregated in Redis. We A/B test different weight configurations to optimise for revenue while maintaining competitive pricing.
Q4: How do you handle partial booking failures — for example, the first flight leg confirms but the second leg fails?
Answer: We implement the booking flow as a saga with compensating transactions. If the second leg fails after the first leg is confirmed, the compensating transaction cancels the first leg and refunds the payment. We also support partial bookings where the user can choose to keep the confirmed leg and get a refund for the failed leg. The saga state machine tracks each step, and the Booking Service can resume from any step after recovery. Failed compensations are logged and retried by a background job, ensuring eventual consistency. In practice, multi-leg failures are rare (less than 0.1% of bookings) because we validate all legs before initiating any confirmation.
Q5: How do you handle currency conversion and avoid arbitrage?
Answer: All internal prices are stored and computed in USD (the base currency). Conversion to display currencies happens at the API layer using exchange rates fetched from a reliable provider (e.g., Open Exchange Rates) and cached for 15 minutes in Redis. We apply a 1.5% forex markup to cover exchange rate fluctuation between display and capture. To prevent arbitrage, the payment amount is always charged in the card's native currency at the current rate with the markup — users cannot exploit stale exchange rates. If a user searches in EUR but pays with a USD card, the charge is in USD at the current rate. We also implement geo-velocity checks to detect users using VPNs to exploit regional pricing differences.
Q6: How would you scale the search service to handle 10,000 QPS during peak season?
Answer: The search service is stateless and horizontally scalable behind a load balancer. We target 20 instances of c5.2xlarge handling 500 QPS each. Elasticsearch is sharded across 9 nodes (3 shards, 1 replica each) with routing based on origin airport code to ensure related documents land on the same shard. Redis cluster with 6 nodes caches 80% of queries with a 60-second TTL. The CDN handles another 60% of static search page requests. We also implement search result precomputation for the top 500 origin-destination pairs, refreshing every 5 minutes, which absorbs about 30% of search traffic without hitting Elasticsearch at all. Auto-scaling policies add instances when CPU exceeds 70% or when queue depth exceeds 100.
Q7: How do you ensure payment idempotency when a user double-clicks the "Pay Now" button?
Answer: Every payment request includes a client-generated idempotency key derived from the booking ID and a timestamp. The Payment Service stores this key in Redis with a 24-hour TTL. If a duplicate request arrives with the same key, the stored response is returned without re-processing the payment. The Stripe/Adyen gateway also receives the same idempotency key, providing end-to-end protection. The booking state machine prevents any action on a booking that is already in a terminal state — if the booking is already CONFIRMED, a second payment attempt is rejected before reaching the payment gateway. The UI also disables the Pay button after the first click and shows a processing spinner.
Q8: How do you handle hotel overbooking (when the hotel runs out of rooms after our system confirms)?
Answer: Hotel overbooking is a real-world problem that our system must handle gracefully. When the hotel reports that a confirmed room is unavailable, the Hotel Operations team is notified immediately via PagerDuty. Our system automatically searches for an equivalent or better alternative at the same property or a nearby hotel. The rebooking covers any price difference if the alternative is more expensive. We maintain a "resort fund" — a pool of pre-negotiated emergency rates with nearby hotels — that can be activated within minutes. The guest receives a push notification, email, and SMS with the new accommodation details and a complimentary upgrade or discount as goodwill. Our SLA guarantees resolution within 2 hours of the overbooking being reported.
Q9: How do you implement feature flags for gradual rollout of new pricing algorithms?
Answer: We use LaunchDarkly (or an open-source alternative like Unleash) for feature flag management. When rolling out a new pricing algorithm, we create a flag like "pricing-engine-v2" and gradually increase the traffic percentage: 1% canary, then 10%, then 50%, then 100% over one week. During canary, we monitor key metrics: booking conversion rate, revenue per search, and customer complaints. If any metric degrades by more than 2%, we automatically roll back. The feature flag is evaluated at the Pricing Service level, and the chosen algorithm version is embedded in the price quote for audit trail purposes. We also run shadow mode testing where the new algorithm calculates prices in parallel without serving them, comparing results against the production algorithm.
Q10: Design a system to handle 1 million concurrent users during a flash sale.
Answer: For flash sales (e.g., Black Friday hotel deals), we implement several strategies: (1) A virtual waiting room (queue-it or custom) that holds users in a FIFO queue before they reach the search service, preventing thundering herd. (2) Pre-computed inventory snapshots loaded into Redis 5 minutes before the sale starts, so the search path never touches the database or GDS during the peak. (3) Static asset preloading via CDN with cache-busting at sale start time. (4) Rate limiting per user to 1 search per 5 seconds during the sale to reduce load. (5) Auto-scaling the search cluster to 50 instances (10x normal) 30 minutes before the sale. (6) A dedicated booking pipeline with reserved capacity that operates independently from the normal booking flow. (7) Real-time monitoring with a war room dashboard showing queue depth, search QPS, booking QPS, and inventory depletion rates.
Q11: How do you test GDS integrations without hitting production systems?
Answer: We build comprehensive mock servers that simulate GDS behaviour. Each mock implements the exact API contract of the real GDS (Amadeus, Sabre) including XML/JSON response formats, error codes, and latency profiles. The mocks are configured with test scenarios: normal availability, oversold flights, timeout responses, partial failures, and malformed data. We use Pact for contract testing to verify that our adapters correctly parse all expected response shapes. In CI/CD, integration tests run against mocks with chaos injection (random delays, error responses) to verify circuit breaker and retry logic. Pre-production environments connect to GDS sandbox environments for end-to-end validation before production deployment.
Q12: How would you design the review ranking algorithm to surface the most helpful reviews?
Answer: The ranking algorithm combines multiple signals into a composite score: recency (exponential decay with 6-month half-life), helpfulness (upvotes from other users, weighted by the voter's own credibility), verified booking status (2x boost for verified reviews), content quality (NLP analysis for detail length, specificity, and sentiment consistency with rating), and reviewer authority (based on number of helpful reviews and account age). The algorithm is periodically retrained using implicit feedback — we track which reviews users read before clicking "Book Now" and optimise the ranking to maximise booking conversion. We also implement diversity controls to ensure the top reviews represent different perspectives (not all 5-star or all 1-star).