Travel Booking Platform

A 14-service travel booking platform (flights, hotels, cars, deals, billing) built as a TypeScript monorepo: independently deployable services communicating over Kafka, polyglot storage across MongoDB and MySQL, a React client with i18n, and a Python AI concierge, all orchestrated on Kubernetes.

ROLE
Builder
PERIOD
2025
DOMAIN
Distributed systems

OVERVIEW

A travel booking platform built as a TypeScript monorepo of 14 services, the React/Vite client with i18n among them. An Express API gateway fronts domain services for flights, hotels, cars, booking, billing, and users, with supporting services for notifications, admin analytics, deal aggregation, airport resolution, external adapters, and a Python AI concierge. Services coordinate through Kafka domain events on consumer groups rather than direct calls, persist to MongoDB and MySQL with Redis caching, and share TypeScript event and model types through an internal @kayak/shared package. Everything is containerized per service and deployed with Kubernetes manifests, with GitHub Actions CI/CD and unit, end-to-end, and performance tests.

ARRIVED AS

“A travel booking site spans separate domains (flights, hotels, cars, deals, billing, notifications) that each scale and fail differently. Cramming them into one service couples their deploys and their blast radius. I wanted to push that separation as far as it sensibly goes: a set of small, independently deployable services that coordinate through events rather than direct calls, and to run the whole thing on Kubernetes.”

I built this against the 'Kayak Simulation' group-project brief from my Distributed Systems for Data Engineering course (fall 2025), the same brief behind the team's Kayak (Travel Metasearch); every commit in this repository is mine. It is the largest of my distributed-systems builds: a full travel booking platform decomposed into fourteen services. It covers the real surface of such a product (searching flights, hotels, and cars; managing bookings and billing; aggregating deals; sending notifications; an admin analytics layer; and an AI concierge) with a React client on top. The point of the exercise was less the features and more the architecture: how to split a domain into services that deploy independently and coordinate through events without turning into a distributed monolith.

WHAT I BUILT

  1. 01A TypeScript monorepo of 14 services: an API gateway, domain services for flights, hotels, cars, booking, billing, and users, plus notification, admin/analytics, a deals worker, an airport resolver, external adapters, a Python AI concierge, and a React/Vite client.
  2. 02Event-driven coordination over Kafka: services publish and consume domain events (for example deal.events) on consumer groups, so a booking or a deal propagates asynchronously instead of through synchronous service-to-service calls.
  3. 03Polyglot persistence (MongoDB and MySQL for different services' needs, Redis for caching), and shared TypeScript types via an internal @kayak/shared package so events and models stay consistent across services.
  4. 04Containerized per service with Kubernetes manifests, Nginx, and GitHub Actions for CI, CD, and Pages, with unit, end-to-end, and performance test suites.

WHAT CHANGED

  • Each of the 14 services has its own Dockerfile and can be built, tested, and deployed on its own, so a change to billing never forces a redeploy of flight search.
  • Coordinating through Kafka topics rather than direct calls means a slow or offline service catches up from the log instead of failing its callers.
  • Shared types and a monorepo keep a 14-service system coherent: one place for the event schemas every service agrees on.

ARCHITECTURE

A React/Vite client (with i18n) talks to an Express API gateway that handles routing and auth. Behind it sit the domain services (flights, hotels, cars, booking, billing, user), each an independent Express app with its own data and its own Dockerfile. Cross-service workflows go over Kafka: services publish and subscribe to domain events such as deal.events on named consumer groups, so a deal or a booking fans out asynchronously. Supporting services round it out: a notification service, an admin/analytics service, a background deals worker, an airport resolver, external adapters for third-party APIs, and a Python concierge service for AI travel assistance. Persistence is polyglot (MongoDB and MySQL depending on the service, with Redis for caching), and a shared @kayak/shared TypeScript package gives every service the same event and model types. The whole system is containerized per service and deployed with Kubernetes manifests, behind Nginx, with GitHub Actions running CI, CD, and the Pages build, and unit, end-to-end, and performance tests.

Data flow

click a stage

The React/Vite client calls the Express API gateway, which routes and authenticates the request.

COMPONENT

API gateway

The single entry point: routes client requests to the domain services and handles authentication.

ALSO IN THE BUILD

  • Concierge service (Python)

    An AI travel-assistant service, kept in its own runtime and isolated from the booking path.

  • @kayak/shared

    An internal TypeScript package of shared event and model types, so all services agree on the same schemas.

  • Infra (Kubernetes + Docker + CI/CD)

    Per-service Dockerfiles, Kubernetes manifests, Nginx, and GitHub Actions pipelines for CI, CD, and Pages, with unit/e2e/performance tests.

Decisions, with the cost of each.

A decision without its trade-off is marketing. Each row says what was chosen, why, and what it gave up.

Fourteen services in one monorepo with shared types

Many small services risk drifting apart on event schemas. Keeping them in a monorepo with a shared @kayak/shared types package means the contract between services lives in one place, so independent deployability does not come at the cost of a coherent event model.

INSTEAD OF

A repo per service (schema drift, hard cross-cutting changes); a single service (no independent deploys, the problem this set out to avoid).

Coordinate over Kafka events, not direct calls

Direct service-to-service HTTP couples availability and creates cascading failures. Publishing domain events to Kafka and letting interested services consume them on their own groups means a consumer can be offline and catch up from the log, which is the core resilience argument for going distributed.

INSTEAD OF

Synchronous REST between services (tight coupling); a shared database (re-couples services through their data).

Polyglot persistence per service

Different services have different shapes of data, so each uses the store that fits (MongoDB or MySQL) with Redis for caching, rather than forcing one database to serve every access pattern.

INSTEAD OF

One database for all services (a shared bottleneck and a coupling point).

The part that mattered.

The facts behind the work, and the code that produced them.

independently deployable
14 services
12 TypeScript + 1 Python + React client
event bus
Kafka
domain events, consumer groups
polyglot storage
MongoDB + MySQL
plus Redis caching
orchestrated
Kubernetes
per-service Docker, CI/CD, tests
A service consumes domain events off Kafkatypescript
export class CarDealConsumer {
  constructor(db: mysql.Connection, redis: any) {
    this.kafka = new Kafka({
      clientId: 'cars-svc',
      brokers: [process.env.KAFKA_BROKERS || 'localhost:9092'],
    });
    this.consumer = this.kafka.consumer({ groupId: 'cars-deals-consumer' });
  }

  async start(): Promise<void> {
    await this.consumer.connect();
    await this.consumer.subscribe({ topic: 'deal.events', fromBeginning: false });
    await this.consumer.run({
      eachMessage: async ({ message }) => {
        const event: DealEvent = JSON.parse(message.value?.toString() || '{}');
        if (event.type === 'car') await this.handleCarDeal(event);
      },
    });
  }
}

Each service is a Kafka consumer on its own group. The cars service subscribes to deal.events, filters for the events it cares about by type, and updates its own store, so the deals worker that publishes never needs to know who is listening.

Every service is an independent Express + Kafka apptypescript
import express from 'express';
import { FlightDealConsumer } from './services/kafkaConsumer';
import { /* shared event + model types */ } from '@kayak/shared';

class FlightsService {
  public app: express.Application;
  constructor() {
    this.app = express();
    this.app.use(express.json());
    this.initializeKafkaConsumer();   // subscribe to the event stream
  }
  start() {
    this.app.listen(this.port, () =>
      console.log(`Flights Service listening on port ${this.port}`));
  }
}

Services follow one shape: an Express app for HTTP plus a Kafka consumer for events, importing shared types from @kayak/shared. Because each is self-contained with its own Dockerfile, it builds, tests, and deploys on its own.

LEARNED

  1. Independent deployability is the whole prize of microservices, and a shared-types package in a monorepo is what keeps fourteen of them from drifting into incompatibility.

  2. Events over a log beat direct calls for resilience: a consumer that was offline catches up instead of failing, which is hard to get from synchronous REST.

  3. At fourteen services the operational surface (Docker, Kubernetes, CI/CD) is as much of the project as the code, so building that in from the start mattered.