Skip to main content

Command Palette

Search for a command to run...

Building Dynamic Packaging Systems for OTAs: A Developer's Guide

Published
10 min readView as Markdown

If you've ever wondered how Expedia or Booking.com can instantly combine flights, hotels, and car rentals from hundreds of suppliers into customised packages, you're looking at one of travel tech's most interesting engineering challenges.

Dynamic packaging systems power billions in travel bookings annually. Yet despite their critical role, comprehensive technical resources on building these systems remain surprisingly scarce.

This guide breaks down the architecture, integration patterns, and implementation strategies for building production-grade dynamic packaging systems.

The Engineering Challenge

At its core, dynamic packaging solves a deceptively complex problem: query multiple disparate inventory systems simultaneously, aggregate results, apply business logic, and present coherent package options all within 3 seconds.

Consider the constraints:

Performance Requirements

  • Sub-3-second response times for search queries

  • Handling 1000+ concurrent searches

  • Real-time availability validation across suppliers

  • Minimal latency introduction

Data Complexity

  • Different supplier API schemas and response formats

  • Varying availability formats (cached, semi-real-time, real-time)

  • Time zone handling across suppliers and destinations

  • Currency conversion and pricing normalisation

Business Logic

  • Dynamic pricing rules and margin calculations

  • Time-relationship validation (flight arrival vs. hotel check-in)

  • Inventory allocation across package combinations

  • Cancellation policies and terms aggregation

The system must handle all of this while remaining maintainable, testable, and scalable.

System Architecture Overview

Let's break down a typical dynamic packaging architecture into its core components:

┌─────────────────────────────────────────────────┐
│           Presentation Layer (Frontend)         │
│  React/Vue.js with responsive search interface  │
└──────────────────┬──────────────────────────────┘
                   │ REST/GraphQL API
┌──────────────────▼──────────────────────────────┐
│         Application Layer (Backend API)         │
│   Node.js/Python/Java - Business Logic Layer   │
└──────────────────┬──────────────────────────────┘
                   │
         ┌─────────┼─────────┐
         │         │         │
┌────────▼───┐ ┌──▼──────┐ ┌▼─────────┐
│ Integration│ │ Pricing │ │ Booking  │
│   Service  │ │ Engine  │ │ Service  │
└────────┬───┘ └─────────┘ └──────────┘
         │
    ┌────┼────┬────┬────┬────┐
    ▼    ▼    ▼    ▼    ▼    ▼
  [GDS] [Hotels] [Cars] [Activities] [Suppliers]

Frontend Layer

The user-facing interface should prioritise:

Progressive Loading: Display results as they arrive rather than waiting for all suppliers to respond. Show flight options first, then hotels, then add-ons.

State Management: Use Redux, Vuex, or similar for complex search state. Package configuration involves many interdependent selections.

Caching Strategy: Cache common searches (popular routes, dates) at CDN level to reduce backend load.

Example React hook for package search:

const usePackageSearch = (searchParams) => {
  const [results, setResults] = useState({
    flights: [],
    hotels: [],
    cars: [],
    loading: true
  });

  useEffect(() => {
    const searchPackages = async () => {
      try {
        // Progressive loading pattern
        const stream = await fetch('/api/package-search', {
          method: 'POST',
          body: JSON.stringify(searchParams)
        });

        const reader = stream.body.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const {done, value} = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value);
          const data = JSON.parse(chunk);

          setResults(prev => ({
            ...prev,
            [data.type]: [...prev[data.type], ...data.results]
          }));
        }
      } catch (error) {
        console.error('Search error:', error);
      } finally {
        setResults(prev => ({ ...prev, loading: false }));
      }
    };

    searchPackages();
  }, [searchParams]);

  return results;
};

Application Layer

The orchestration layer handles business logic:

Search Orchestration: Coordinate parallel supplier queries, aggregate results, and apply filtering and sorting.

Price Calculation: Apply markups, promotions, dynamic pricing rules, and currency conversion.

Validation: Ensure component compatibility (flight times align with hotel availability).

Session Management: Track user selections, maintain search context across browsing sessions.

Example Node.js search orchestrator:

class PackageSearchOrchestrator {
  constructor(supplierClients, pricingEngine, cache) {
    this.suppliers = supplierClients;
    this.pricing = pricingEngine;
    this.cache = cache;
  }

  async search(params) {
    const cacheKey = this.generateCacheKey(params);
    const cached = await this.cache.get(cacheKey);

    if (cached && !this.isStale(cached)) {
      return cached;
    }

    // Parallel supplier queries with timeout
    const supplierPromises = Object.entries(this.suppliers).map(
      async ([name, client]) => {
        try {
          return await Promise.race([
            client.search(params),
            this.timeout(3000) // 3s max per supplier
          ]);
        } catch (error) {
          console.error(`Supplier ${name} failed:`, error);
          return { supplier: name, results: [], error: true };
        }
      }
    );

    const results = await Promise.all(supplierPromises);

    // Aggregate and normalize
    const normalized = this.normalizeResults(results);

    // Apply business logic
    const priced = await this.pricing.calculate(normalized, params);

    // Generate package combinations
    const packages = this.combineComponents(priced);

    // Cache results
    await this.cache.set(cacheKey, packages, 300); // 5min TTL

    return packages;
  }

  combineComponents(results) {
    const { flights, hotels, cars } = results;
    const packages = [];

    flights.forEach(flight => {
      hotels.forEach(hotel => {
        // Validate time compatibility
        if (this.isCompatible(flight, hotel)) {
          const pkg = {
            id: this.generatePackageId(flight, hotel),
            flight,
            hotel,
            price: this.calculatePackagePrice(flight, hotel),
            savings: this.calculateSavings(flight, hotel)
          };

          // Optionally add car
          cars.forEach(car => {
            if (this.isCompatible(flight, car)) {
              packages.push({
                ...pkg,
                car,
                price: pkg.price + car.price,
                savings: this.calculateSavings(flight, hotel, car)
              });
            }
          });

          packages.push(pkg);
        }
      });
    });

    return this.rankPackages(packages);
  }

  isCompatible(flight, accommodation) {
    const arrivalTime = new Date(flight.arrival);
    const checkInTime = new Date(accommodation.checkIn);

    // Ensure at least 2 hours between flight arrival and check-in
    return (checkInTime - arrivalTime) >= 2 * 60 * 60 * 1000;
  }
}

Integration Layer

The most complex component, handling diverse supplier integrations:

Adapter Pattern: Create supplier-specific adapters that normalise data to the internal schema.

Circuit Breaker: Prevent cascade failures when suppliers are down.

Rate Limiting: Respect supplier API quotas.

Retry Logic: Handle transient failures with exponential backoff.

Example supplier adapter interface:

from abc import ABC, abstractmethod
from typing import Dict, List
import asyncio
from datetime import datetime

class SupplierAdapter(ABC):
    def __init__(self, config: Dict):
        self.config = config
        self.circuit_breaker = CircuitBreaker(
            failure_threshold=5,
            recovery_timeout=60
        )
        self.rate_limiter = RateLimiter(
            requests_per_second=config.get('rate_limit', 10)
        )

    @abstractmethod
    async def search(self, params: Dict) -> List[Dict]:
        """Search supplier inventory"""
        pass

    @abstractmethod
    def normalize_response(self, raw_response: Dict) -> Dict:
        """Convert supplier format to internal schema"""
        pass

    async def execute_search(self, params: Dict) -> List[Dict]:
        """Orchestrate search with error handling"""
        if not self.circuit_breaker.is_closed():
            raise CircuitOpenError(f"Circuit open for {self.config['name']}")

        await self.rate_limiter.acquire()

        try:
            raw_results = await self.search(params)
            normalized = [
                self.normalize_response(r) for r in raw_results
            ]
            self.circuit_breaker.record_success()
            return normalized
        except Exception as e:
            self.circuit_breaker.record_failure()
            raise SupplierError(f"Search failed: {str(e)}")

class AmadeusAdapter(SupplierAdapter):
    async def search(self, params: Dict) -> List[Dict]:
        """Amadeus-specific search implementation"""
        headers = {
            'Authorization': f"Bearer {await self.get_token()}",
            'Content-Type': 'application/json'
        }

        payload = {
            'originLocationCode': params['origin'],
            'destinationLocationCode': params['destination'],
            'departureDate': params['departure_date'],
            'adults': params.get('adults', 1),
            'currencyCode': params.get('currency', 'USD')
        }

        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{self.config['base_url']}/v2/shopping/flight-offers",
                headers=headers,
                json=payload,
                timeout=aiohttp.ClientTimeout(total=5)
            ) as response:
                if response.status == 200:
                    data = await response.json()
                    return data.get('data', [])
                else:
                    raise SupplierError(
                        f"Amadeus API error: {response.status}"
                    )

    def normalize_response(self, raw: Dict) -> Dict:
        """Convert Amadeus format to internal schema"""
        segments = raw['itineraries'][0]['segments']

        return {
            'type': 'flight',
            'supplier': 'amadeus',
            'id': raw['id'],
            'price': {
                'total': float(raw['price']['total']),
                'currency': raw['price']['currency']
            },
            'departure': {
                'airport': segments[0]['departure']['iataCode'],
                'time': segments[0]['departure']['at']
            },
            'arrival': {
                'airport': segments[-1]['arrival']['iataCode'],
                'time': segments[-1]['arrival']['at']
            },
            'stops': len(segments) - 1,
            'carrier': segments[0]['carrierCode'],
            'duration': self._calculate_duration(segments)
        }

Database Design

Efficient data modelling is critical for performance:

-- Packages table
CREATE TABLE packages (
    id UUID PRIMARY KEY,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP,
    customer_id UUID,
    status VARCHAR(20),
    total_price DECIMAL(10,2),
    currency VARCHAR(3),
    metadata JSONB
);

-- Package components (flexible for any service type)
CREATE TABLE package_components (
    id UUID PRIMARY KEY,
    package_id UUID REFERENCES packages(id),
    component_type VARCHAR(50), -- 'flight', 'hotel', 'car', etc.
    supplier_id VARCHAR(100),
    supplier_name VARCHAR(100),
    booking_reference VARCHAR(100),
    component_data JSONB, -- Flexible storage for component details
    price DECIMAL(10,2),
    status VARCHAR(20)
);

-- Search cache for performance
CREATE TABLE search_cache (
    cache_key VARCHAR(255) PRIMARY KEY,
    results JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP
);

CREATE INDEX idx_search_cache_expiry ON search_cache(expires_at);
CREATE INDEX idx_package_status ON packages(status, created_at);
CREATE INDEX idx_components_package ON package_components(package_id);

Pricing Engine Implementation

Dynamic pricing is where you capture margin:

class DynamicPricingEngine {
  constructor(rules, margins) {
    this.rules = rules;
    this.margins = margins;
  }

  calculatePackagePrice(components, context) {
    // Base price from components
    let basePrice = components.reduce(
      (sum, c) => sum + c.supplierPrice,
      0
    );

    // Apply margins
    let margin = this.calculateMargin(components, context);
    let price = basePrice * (1 + margin);

    // Apply dynamic pricing rules
    price = this.applyDynamicRules(price, components, context);

    // Apply promotions
    price = this.applyPromotions(price, components, context);

    return {
      basePrice,
      margin,
      finalPrice: Math.round(price * 100) / 100,
      savings: this.calculateSavings(components, price)
    };
  }

  calculateMargin(components, context) {
    // Different margins by component type
    let avgMargin = components.reduce((sum, c) => {
      const typeMargin = this.margins[c.type] || 0.10;
      return sum + typeMargin;
    }, 0) / components.length;

    // Adjust for market conditions
    if (context.highDemand) {
      avgMargin *= 1.2;
    }

    if (context.competitionLevel === 'high') {
      avgMargin *= 0.85;
    }

    return Math.min(avgMargin, 0.30); // Cap at 30%
  }

  applyDynamicRules(price, components, context) {
    this.rules.forEach(rule => {
      if (rule.condition(components, context)) {
        price = rule.transform(price);
      }
    });
    return price;
  }

  calculateSavings(components, packagePrice) {
    // Calculate what components would cost separately
    const separateTotal = components.reduce((sum, c) => {
      return sum + (c.retailPrice || c.supplierPrice * 1.15);
    }, 0);

    return Math.max(0, separateTotal - packagePrice);
  }
}

// Example pricing rules
const pricingRules = [
  {
    name: 'early_bird_discount',
    condition: (components, ctx) => {
      const daysUntilTravel = this.getDaysUntil(
        components[0].departureDate
      );
      return daysUntilTravel > 60;
    },
    transform: (price) => price * 0.95 // 5% discount
  },
  {
    name: 'weekend_premium',
    condition: (components, ctx) => {
      return components.some(c => 
        this.isWeekendDeparture(c.departureDate)
      );
    },
    transform: (price) => price * 1.08 // 8% premium
  }
];

Testing Strategy

Comprehensive testing is essential given the complexity:

Unit Tests: Test individual adapters, pricing logic, and validation rules.

Integration Tests: Test supplier communication with mocked responses.

Load Tests: Simulate realistic concurrent user loads.

Chaos Engineering: Test system behaviour when suppliers fail or respond slowly.

Example integration test:

describe('PackageSearchOrchestrator', () => {
  let orchestrator;
  let mockSuppliers;
  let mockPricing;

  beforeEach(() => {
    mockSuppliers = {
      flights: new MockFlightSupplier(),
      hotels: new MockHotelSupplier()
    };
    mockPricing = new MockPricingEngine();
    orchestrator = new PackageSearchOrchestrator(
      mockSuppliers,
      mockPricing
    );
  });

  it('should handle supplier timeout gracefully', async () => {
    mockSuppliers.flights.setDelay(5000); // 5s delay

    const result = await orchestrator.search({
      origin: 'JFK',
      destination: 'LAX',
      date: '2025-06-01'
    });

    // Should return hotel results even if flights timeout
    expect(result.hotels.length).toBeGreaterThan(0);
    expect(result.flights.length).toBe(0);
  });

  it('should create valid package combinations', async () => {
    const packages = await orchestrator.search({
      origin: 'SFO',
      destination: 'NYC',
      date: '2025-07-15'
    });

    packages.forEach(pkg => {
      // Validate time compatibility
      const flightArrival = new Date(pkg.flight.arrival);
      const hotelCheckin = new Date(pkg.hotel.checkIn);

      expect(hotelCheckin.getTime())
        .toBeGreaterThan(flightArrival.getTime());

      // Validate pricing
      expect(pkg.price).toBeGreaterThan(0);
      expect(pkg.savings).toBeGreaterThanOrEqual(0);
    });
  });
});

Performance Optimization

Meeting sub-3-second requirements demands optimisation:

Caching Strategy

  • Redis for search result caching (5-15 minute TTL)

  • CDN caching for popular routes

  • Browser caching for static assets

Database Optimization

  • Proper indexing on frequently queried fields

  • Connection pooling

  • Query optimization for package lookups

Async Processing

  • Queue expensive operations (booking confirmation, email)

  • Background jobs for price updates

  • Webhooks for supplier notifications

Monitoring

  • APM tools (New Relic, Datadog) for bottleneck identification

  • Supplier response time tracking

  • Error rate monitoring by the supplier

Example caching implementation:

import redis
import json
import hashlib

class SearchCache:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.default_ttl = 300  # 5 minutes

    def generate_key(self, search_params):
        # Create deterministic cache key
        normalized = json.dumps(search_params, sort_keys=True)
        return f"search:{hashlib.md5(normalized.encode()).hexdigest()}"

    async def get(self, search_params):
        key = self.generate_key(search_params)
        cached = await self.redis.get(key)

        if cached:
            return json.loads(cached)
        return None

    async def set(self, search_params, results, ttl=None):
        key = self.generate_key(search_params)
        ttl = ttl or self.default_ttl

        await self.redis.setex(
            key,
            ttl,
            json.dumps(results)
        )

Production Considerations

Launching to production requires additional infrastructure:

Scalability

  • Horizontal scaling of application servers

  • Load balancing across instances

  • Auto-scaling based on traffic patterns

Reliability

  • Multi-region deployment for redundancy

  • Database replication and failover

  • Supplier fallback strategies

Security

  • API authentication and rate limiting

  • PCI compliance for payment processing

  • Data encryption at rest and in transit

Observability

  • Centralised logging (ELK stack, Splunk)

  • Distributed tracing (Jaeger, Zipkin)

  • Real-time alerting for critical errors

Common Pitfalls

Avoid these implementation mistakes:

Over-Engineering: Don't build Netflix-scale infrastructure for a day-one MVP. Start simple, scale as needed.

Tight Coupling: Keep supplier logic isolated in adapters. Supplier APIs change frequently.

Ignoring Timeouts: Always set aggressive timeouts. One slow supplier shouldn't block the entire search.

Poor Error Handling: Partial failures are normal. Gracefully degrade service rather than failing completely.

Neglecting Mobile: Over 50% of travel searches happen on mobile. Optimise payload sizes and render times.

Real-World Implementation Examples

Several travel platforms have successfully implemented dynamic packaging with innovative technical approaches and architecture patterns. Studying these implementations provides valuable insights into scaling challenges and solutions.

GraphQL Adoption: More flexible API querying for the frontend, reducing over-fetching.

Event-Driven Architecture: Move from request-response to event streams for better real-time updates.

Machine Learning Integration: Predictive package recommendations, demand forecasting, and dynamic pricing optimisation.

Microservices Evolution: Breaking monoliths into specialised services (search, pricing, booking, inventory).

Conclusion

Building production-grade dynamic packaging systems requires balancing performance, reliability, and business logic complexity. The architecture outlined here provides a foundation, but real-world implementations require continuous iteration based on usage patterns and business requirements.

Key success factors:

  • Robust supplier integration with comprehensive error handling

  • Aggressive performance optimisation and caching

  • Flexible pricing engine supporting complex business rules

  • Comprehensive monitoring and observability

  • Iterative development based on real-world usage

For developers entering travel tech, dynamic packaging offers fascinating technical challenges across distributed systems, real-time data processing, and user experience optimisation. The intersection of these domains makes it an excellent problem space for learning and growth.

More from this blog

Travel Tech

43 posts