Design BookMyShow: The Complete Ticket Booking System Design Guide

Design BookMyShow: The Complete Ticket Booking System Design Guide — A Senior+ Guide | Ayodhyya

Building a scalable, fault-tolerant ticket booking platform — seat locking, payment processing, concurrency control, and real-time inventory at massive scale

Senior+ Guide 50+ min read Ayodhyya

Table of Contents

  1. Introduction — The Ticket Booking Landscape
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope Calculations
  4. Data Model & Storage Schema
  5. API Design
  6. High-Level Architecture
  7. Seat Inventory & Real-Time Availability
  8. Seat Locking & Concurrency Control
  9. Database Design & Sharding
  10. Caching Strategy
  11. Payment Processing & Transactions
  12. Notification System
  13. Search & Discovery
  14. Security & Fraud Prevention
  15. Monitoring & Observability
  16. Multi-Region Design
  17. Cost Estimation
  18. Edge Cases
  19. Interview Q&A
  20. Full C# Implementation
  21. Conclusion

1. Introduction — The Ticket Booking Landscape

BookMyShow is India's largest online ticketing platform, processing over 150 million transactions per month and serving more than 50 million monthly active users. During blockbuster movie releases like RRR or Pathaan, the platform experiences traffic spikes of 200,000+ concurrent users fighting for limited seats in popular theaters. The system must handle peak loads of 15,000+ transactions per second while maintaining sub-second response times for seat selection and under 5-second end-to-end booking completion. This is not a simple e-commerce checkout problem — it is a real-time inventory management system where two users must never be allowed to book the same seat, where payment failures must not leave ghost reservations, and where the entire flow from seat selection to ticket generation must complete atomically.

Unlike standard e-commerce where overselling can be compensated with refunds or back-orders, ticket booking has a strict one-to-one mapping: each seat in a theater belongs to exactly one show at one time. Selling the same seat twice is a catastrophic failure that destroys customer trust and creates operational nightmares. The system must therefore guarantee seat uniqueness through distributed locking, handle flash-sale traffic patterns where millions of users simultaneously attempt to book seats for a single show, process payments reliably across multiple payment gateways, and deliver tickets via email, SMS, and in-app notifications within seconds of booking completion. The architectural challenges compound when you consider that BookMyShow handles movies, plays, concerts, sports events, and more — each with different seating configurations, pricing models, and booking rules.

Interview Context: The ticket booking system design question tests your understanding of distributed locking, real-time inventory management, payment orchestration, idempotency, and handling flash-sale traffic patterns. It is one of the most frequently asked system design questions at companies like Amazon, Google, Flipkart, and Indeed.

Why This Design Is Unique

Most system design interviews focus on either read-heavy or write-heavy systems. A ticket booking platform is a mixed workload that shifts dramatically based on context. During browsing and search, it is read-heavy — millions of users browsing showtimes, movie details, and theater listings. During the booking window, it shifts to write-heavy — thousands of concurrent seat-lock attempts, payment processing, and ticket generation for the same show. During flash sales for popular events, it becomes a hot-spot write system where a single show's seat inventory becomes a contention point for tens of thousands of concurrent requests. This requires a fundamentally different approach than designing a URL shortener, a social media feed, or a chat system.

The key insight that separates a senior engineer's design from a junior one is understanding that the seat inventory is a shared mutable resource that requires careful coordination. You cannot simply decrement a counter in a database — you must handle the full lifecycle of a seat reservation: tentative lock, payment processing, confirmed booking, and timeout-based release. Each stage has failure modes that must be handled gracefully. A user who locks a seat but fails to pay must not block other users indefinitely. A payment that succeeds but whose confirmation notification is lost must not result in a charged user without a ticket. These edge cases are where the real engineering complexity lies, and they are exactly what interviewers are testing.

2. Functional & Non-Functional Requirements

Functional Requirements

#RequirementPriorityDetails
F1Browse movies & eventsMustList movies, shows, trailers, ratings, and reviews by city
F2Search showtimesMustFind available shows by movie, date, language, format, and theater
F3Seat selectionMustInteractive seat map with real-time availability, pricing tiers, and selection
F4Seat lockingMustTemporarily hold selected seats during checkout with timeout
F5Payment processingMustSupport UPI, credit/debit cards, wallets, net banking, and EMI
F6Booking confirmationMustGenerate ticket with QR code, send confirmation via email/SMS
F7Booking historyMustView past and upcoming bookings with ticket details
F8Cancel bookingShouldCancel booking with refund based on cancellation policy
F9Price calculationMustDynamic pricing based on seat type, show time, demand, and day
F10Multiple payment retriesShouldRetry failed payments without re-entering seat selection
F11Watchlist & alertsNiceNotify users when tickets go on sale for bookmarked events
F12Food & beverage pre-orderNiceOrder snacks during checkout for theater pickup

Non-Functional Requirements

#RequirementTargetRationale
NF1Availability99.99%Downtime during blockbuster releases causes massive revenue loss and brand damage
NF2Read latency (p99)< 200msBrowse and search must feel instantaneous for good UX
NF3Write latency (p99)< 500msSeat lock and booking confirmation must be fast to prevent user abandonment
NF4Seat uniqueness100% guaranteedNo double-booking — zero tolerance for selling the same seat twice
NF5ConsistencyStrong for bookingsPayment and booking must be strongly consistent; browsing can be eventually consistent
NF6IdempotencyAll write operationsNetwork retries must not create duplicate bookings or charges
NF7Flash sale handling15K+ TPSPeak load during blockbuster releases and concert on-sale events
NF8Data retention7 yearsFinancial records and booking history for compliance

3. Capacity Estimation & Back-of-Envelope Calculations

Traffic Estimates

MetricValueCalculation
Monthly active users50 millionGiven (BookMyShow scale)
Daily active users10 million50M * 0.2 daily active ratio
Peak concurrent users500,00010M * 0.05 peak ratio
Bookings per day (avg)500,00010M DAU * 0.05 conversion rate
Bookings per second (avg)~6 TPS500K / 86,400
Peak TPS (flash sale)15,000 TPSBlockbuster release peak
Read-to-write ratio100:1For every booking, 100 browse/search requests
Search QPS (peak)300,00015,000 TPS * 20 read multiplier

Storage Estimates

Data TypeSize per RecordRecords per DayDaily Storage
Booking records2 KB500,000~1 GB
Seat inventory updates200 B10,000,000~2 GB
Payment records1 KB500,000~0.5 GB
Search/audit logs500 B50,000,000~25 GB
Total daily~28.5 GB

Bandwidth Estimates

The total bandwidth during peak is approximately 15,000 TPS * 10 KB average response = 150 MB/s inbound for writes. For reads at 300,000 QPS * 5 KB average response = 1.5 GB/s. Combined peak bandwidth is approximately 1.65 GB/s. This requires load balancers and application servers capable of handling multi-gigabit throughput, with CDN serving static assets (images, movie posters, CSS, JS) to offload approximately 70% of read traffic from origin servers.

Critical Insight: The back-of-envelope numbers above represent average daily load. Flash sale events for blockbuster movies or cricket finals can spike traffic by 20-50x within minutes. Your architecture must handle 300,000+ TPS bursts without degrading the experience for non-flash-sale users. This is why auto-scaling groups, connection pooling, and queue-based load leveling are non-negotiable in this design.

4. Data Model & Storage Schema

The data model must capture the hierarchical relationship between cities, venues, screens, shows, seats, and bookings. Each entity has specific access patterns that influence whether it should be stored in a relational database, a document store, or a cache layer.

Core Entities

EntityStorageAccess PatternPartition Key
CityPostgreSQLRead-heavy, rarely changescity_id
Venue / TheaterPostgreSQLRead-heavy, geo-spatial queriescity_id
ScreenPostgreSQLRead-heavy, linked to venuevenue_id
Movie / EventPostgreSQL + ElasticsearchSearch, filtered browsingmovie_id
ShowPostgreSQLHot reads, frequently queriedmovie_id + date
Seat LayoutRedis (JSON) + PostgreSQLReal-time reads during selectionscreen_id
Seat InventoryRedis (hash) + PostgreSQLHot writes, contention pointshow_id
BookingPostgreSQL (primary) + Cassandra (history)Write-heavy during flash salesuser_id + booking_id
PaymentPostgreSQLACID transactions, audit trailbooking_id
Pricing RuleRedis + PostgreSQLReal-time price computationshow_id

PostgreSQL Schema — Core Tables

SQL
CREATE TABLE cities (
    city_id       BIGSERIAL PRIMARY KEY,
    name          VARCHAR(100) NOT NULL,
    state         VARCHAR(100),
    country       VARCHAR(100) DEFAULT 'India',
    is_active     BOOLEAN DEFAULT TRUE,
    created_at    TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE venues (
    venue_id      BIGSERIAL PRIMARY KEY,
    city_id       BIGINT REFERENCES cities(city_id),
    name          VARCHAR(200) NOT NULL,
    address       TEXT,
    latitude      DECIMAL(10, 8),
    longitude     DECIMAL(11, 8),
    venue_type    VARCHAR(50) CHECK (venue_type IN ('MOVIE', 'CONCERT', 'SPORTS', 'PLAY')),
    is_active     BOOLEAN DEFAULT TRUE,
    created_at    TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE screens (
    screen_id     BIGSERIAL PRIMARY KEY,
    venue_id      BIGINT REFERENCES venues(venue_id),
    name          VARCHAR(100) NOT NULL,
    screen_type   VARCHAR(50) CHECK (screen_type IN ('IMAX', '4DX', 'DOLBY_ATMOS', 'STD', 'PREMIUM')),
    total_rows    SMALLINT NOT NULL,
    total_columns SMALLINT NOT NULL,
    total_seats   INT GENERATED ALWAYS AS (total_rows * total_columns) STORED
);

CREATE TABLE movies (
    movie_id      BIGSERIAL PRIMARY KEY,
    title         VARCHAR(300) NOT NULL,
    language      VARCHAR(50) NOT NULL,
    genre         VARCHAR(100),
    duration_min  SMALLINT NOT NULL,
    rating        VARCHAR(10) CHECK (rating IN ('U', 'U/A', 'A', 'U/A 13+')),
    release_date  DATE NOT NULL,
    poster_url    TEXT,
    trailer_url   TEXT,
    synopsis      TEXT,
    is_active     BOOLEAN DEFAULT TRUE
);

CREATE TABLE shows (
    show_id       BIGSERIAL PRIMARY KEY,
    movie_id      BIGINT REFERENCES movies(movie_id),
    screen_id     BIGINT REFERENCES screens(screen_id),
    show_date     DATE NOT NULL,
    show_time     TIME NOT NULL,
    format        VARCHAR(20) DEFAULT '2D',
    language      VARCHAR(50) NOT NULL,
    status        VARCHAR(20) DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'CANCELLED', 'COMPLETED')),
    created_at    TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (screen_id, show_date, show_time)
);

CREATE TABLE seat_layouts (
    seat_id       BIGSERIAL PRIMARY KEY,
    screen_id     BIGINT REFERENCES screens(screen_id),
    row_label     VARCHAR(5) NOT NULL,
    seat_number   SMALLINT NOT NULL,
    seat_type     VARCHAR(30) CHECK (seat_type IN ('RECLINER', 'PREMIUM', 'GOLD', 'SILVER', 'BRONZE', 'STANDARD')),
    base_price    DECIMAL(10, 2) NOT NULL,
    is_recliner   BOOLEAN DEFAULT FALSE,
    is_active     BOOLEAN DEFAULT TRUE,
    UNIQUE (screen_id, row_label, seat_number)
);

CREATE TABLE seat_inventory (
    inventory_id  BIGSERIAL PRIMARY KEY,
    show_id       BIGINT REFERENCES shows(show_id),
    seat_id       BIGINT REFERENCES seat_layouts(seat_id),
    status        VARCHAR(20) DEFAULT 'AVAILABLE'
                    CHECK (status IN ('AVAILABLE', 'LOCKED', 'BOOKED', 'BLOCKED')),
    locked_by     UUID,
    locked_at     TIMESTAMPTZ,
    price         DECIMAL(10, 2) NOT NULL,
    version       INT DEFAULT 1,
    UNIQUE (show_id, seat_id)
);

CREATE TABLE bookings (
    booking_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id           BIGINT NOT NULL,
    show_id           BIGINT REFERENCES shows(show_id),
    booking_status    VARCHAR(20) DEFAULT 'PENDING'
                       CHECK (booking_status IN ('PENDING', 'CONFIRMED', 'CANCELLED', 'REFUNDED', 'EXPIRED')),
    total_amount      DECIMAL(10, 2) NOT NULL,
    convenience_fee   DECIMAL(10, 2) DEFAULT 0,
    discount_amount   DECIMAL(10, 2) DEFAULT 0,
    final_amount      DECIMAL(10, 2) GENERATED ALWAYS AS (total_amount + convenience_fee - discount_amount) STORED,
    booking_type      VARCHAR(20) DEFAULT 'MOVIE',
    created_at        TIMESTAMPTZ DEFAULT NOW(),
    confirmed_at      TIMESTAMPTZ,
    expires_at        TIMESTAMPTZ
);

CREATE TABLE booking_seats (
    id              BIGSERIAL PRIMARY KEY,
    booking_id      UUID REFERENCES bookings(booking_id),
    show_id         BIGINT NOT NULL,
    seat_id         BIGINT NOT NULL,
    row_label       VARCHAR(5) NOT NULL,
    seat_number     SMALLINT NOT NULL,
    seat_type       VARCHAR(30),
    price           DECIMAL(10, 2) NOT NULL
);

CREATE TABLE payments (
    payment_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    booking_id          UUID REFERENCES bookings(booking_id),
    user_id             BIGINT NOT NULL,
    amount              DECIMAL(10, 2) NOT NULL,
    payment_method      VARCHAR(30) CHECK (payment_method IN ('UPI', 'CARD', 'WALLET', 'NET_BANKING', 'EMI')),
    payment_gateway     VARCHAR(50),
    gateway_txn_id      VARCHAR(100),
    status              VARCHAR(20) DEFAULT 'PENDING'
                         CHECK (status IN ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED', 'REFUNDED')),
    idempotency_key     UUID UNIQUE NOT NULL,
    created_at          TIMESTAMPTZ DEFAULT NOW(),
    completed_at        TIMESTAMPTZ
);

Redis Data Structures

Redis
-- Seat inventory hash for a show (real-time status)
HSET show:12345:seats A1 AVAILABLE A2 LOCKED A3 BOOKED B1 AVAILABLE ...

-- Seat lock with TTL (auto-expire after 10 minutes)
SET lock:show:12345:seat:A2 "user-uuid-789" EX 600

-- Show seat count cache
HSET show:12345:meta total_seats 350 available_seats 187 locked_seats 12 booked_seats 151

-- Price cache per show
HSET show:12345:prices RECLINER 450.00 PREMIUM 300.00 GOLD 250.00 STANDARD 150.00

-- User booking cache
ZADD user:789:bookings 1689123456 "booking-uuid-123"

-- Bloom filter for booking idempotency
BF.ADD booking:idempotency "idempotency-key-abc"

5. API Design

All APIs follow RESTful conventions with JSON payloads. Authentication uses JWT tokens with short-lived access tokens (15 minutes) and long-lived refresh tokens (30 days). Every write operation requires an idempotency key in the request header to prevent duplicate processing.

Browse & Search APIs

HTTP
GET /api/v1/cities
GET /api/v1/movies?city_id={id}&date={date}&language={lang}&genre={genre}
GET /api/v1/movies/{movie_id}/details
GET /api/v1/movies/{movie_id}/showtimes?city_id={id}&date={date}
GET /api/v1/venues/{venue_id}/screens/{screen_id}/layout?show_id={id}

Booking APIs

HTTP
POST   /api/v1/bookings/lock-seats
       Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
       Body: {
         "show_id": 12345,
         "seat_ids": ["A1", "A2", "A3"],
         "lock_duration_sec": 600
       }
       Response: {
         "lock_id": "lock-uuid-456",
         "expires_at": "2026-07-10T14:25:00Z",
         "total_amount": 750.00,
         "convenience_fee": 40.00,
         "seats": [...]
       }

POST   /api/v1/bookings/confirm
       Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
       Body: {
         "lock_id": "lock-uuid-456",
         "payment_id": "payment-uuid-789"
       }
       Response: {
         "booking_id": "booking-uuid-012",
         "status": "CONFIRMED",
         "ticket_url": "https://...",
         "qr_code": "base64..."
       }

DELETE /api/v1/bookings/locks/{lock_id}
       Headers: Authorization: Bearer {jwt}

Payment APIs

HTTP
POST   /api/v1/payments/initiate
       Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
       Body: {
         "booking_id": "booking-uuid-012",
         "payment_method": "UPI",
         "vpa": "user@upi"
       }

POST   /api/v1/payments/webhook
       Headers: X-Gateway-Signature: {hmac}
       Body: { "gateway_txn_id": "...", "status": "SUCCESS", ... }

GET    /api/v1/bookings/{booking_id}/payment-status
       Headers: Authorization: Bearer {jwt}

User & Booking History APIs

HTTP
GET    /api/v1/users/me/bookings?status=upcoming&page=1&limit=20
GET    /api/v1/bookings/{booking_id}
POST   /api/v1/bookings/{booking_id}/cancel
       Headers: Idempotency-Key: {uuid}
       Body: { "reason": "change_of_plans" }
Idempotency Pattern: Every mutating endpoint accepts an Idempotency-Key header. The server stores the key with a 24-hour TTL. If a request with the same key arrives, the server returns the cached response without reprocessing. This prevents double bookings from network retries and handles the exact-at-most-once delivery guarantee required for payment processing.

6. High-Level Architecture

The system follows a microservices architecture with clear separation of concerns. The most critical design decision is isolating the seat inventory service as a separate, independently scalable service because it is the primary bottleneck during flash sales.

graph TB subgraph Client Layer WebApp[Web App] MobileApp[Mobile App] end subgraph Edge Layer CDN[CDN - CloudFront] WAF[WAF] APIGW[API Gateway - Kong] RateLimit[Rate Limiter] end subgraph Application Layer MovieService[Movie & Event Service] SearchService[Search Service - ES] ShowService[Show Service] SeatService[Seat Inventory Service] BookingService[Booking Service] PaymentService[Payment Service] NotificationService[Notification Service] UserService[User Service] end subgraph Data Layer PG[(PostgreSQL Cluster)] Redis[(Redis Cluster)] ES[(Elasticsearch)] Cassandra[(Cassandra)] S3[(S3 - Media)] end subgraph Async Layer Kafka[Kafka] DLQ[Dead Letter Queue] end WebApp --> CDN MobileApp --> CDN CDN --> WAF --> APIGW --> RateLimit RateLimit --> MovieService RateLimit --> SearchService RateLimit --> ShowService RateLimit --> SeatService RateLimit --> BookingService RateLimit --> PaymentService RateLimit --> UserService MovieService --> PG MovieService --> S3 SearchService --> ES ShowService --> PG ShowService --> Redis SeatService --> Redis SeatService --> PG BookingService --> PG BookingService --> Redis PaymentService --> PG PaymentService --> Kafka NotificationService --> Kafka BookingService --> Kafka SeatService --> Kafka PaymentService --> Kafka

Service Responsibilities

ServiceResponsibilityScaling StrategyData Store
Movie & Event ServiceMovie metadata, trailers, ratings, cast infoHorizontal, read replicasPostgreSQL + S3
Search ServiceFull-text search, faceted filtering, autocompleteHorizontal, index shardingElasticsearch
Show ServiceShow scheduling, availability, timingHorizontal with cachingPostgreSQL + Redis
Seat Inventory ServiceReal-time seat status, locking, pricingAggressive horizontal, Redis-backedRedis + PostgreSQL
Booking ServiceBooking lifecycle, confirmation, cancellationHorizontal, partitioned by userPostgreSQL + Cassandra
Payment ServicePayment orchestration, gateway integration, refundsHorizontal, idempotentPostgreSQL
Notification ServiceEmail, SMS, push notifications, in-app alertsQueue-consumers, auto-scalingKafka consumers
User ServiceAuth, profiles, preferences, loyalty pointsHorizontalPostgreSQL + Redis

7. Seat Inventory & Real-Time Availability

The seat inventory system is the heart of the ticket booking platform and the most complex component to design correctly. Unlike a simple product inventory that tracks quantity, seat inventory tracks the status of every individual seat for every show. A typical multiplex has 8-12 screens with 200-500 seats each, running 4-6 shows per day. For a chain with 500 venues, that is roughly 500 * 10 * 5 * 350 = 8.75 million seat inventory records active at any given time.

Seat Status State Machine

stateDiagram-v2 [*] --> AVAILABLE : Initial State AVAILABLE --> LOCKED : User initiates lock (TTL=10min) LOCKED --> AVAILABLE : Lock timeout expired LOCKED --> BOOKED : Payment confirmed BOOKED --> CANCELLED : User cancels / refund processed BOOKED --> REFUNDED : Refund processed BLOCKED --> [*] : Admin blocks seat (maintenance) AVAILABLE --> BLOCKED : Admin blocks seat

Redis-First Architecture for Seat Status

During peak traffic, every seat selection request reads the status of 200-500 seats for a screen. With 500 concurrent users per show, that is 250,000 seat-status reads per second for a single show. A PostgreSQL table cannot handle this read throughput with acceptable latency. The solution is a Redis-first architecture where the canonical seat status lives in Redis, and PostgreSQL serves as the durable backup for crash recovery.

The Redis data structure uses a hash map per show: show:{show_id}:seats where each field is a seat identifier (like "A1", "B15") and the value is the current status. Reading all seat statuses for a show requires a single HGETALL call, which returns in under 1ms for screens with up to 500 seats. The hash also includes metadata fields like total_seats, available_count, and last_updated to avoid additional queries for seat count displays.

Seat Layout Pre-loading Strategy

C#
public class SeatInventoryService
{
    private readonly IDatabase _redis;
    private readonly ShowRepository _showRepo;

    public async Task PreloadSeatInventoryAsync(long showId)
    {
        // Load seat layout from PostgreSQL (cached, rarely changes)
        var layout = await _showRepo.GetSeatLayoutAsync(showId);
        var key = $"show:{showId}:seats";

        // Build Redis hash from PostgreSQL layout
        var entries = new HashEntry[layout.Count + 3];
        for (int i = 0; i < layout.Count; i++)
        {
            entries[i] = new HashEntry(
                $"{layout[i].RowLabel}{layout[i].SeatNumber}",
                "AVAILABLE"
            );
        }
        entries[layout.Count] = new HashEntry("total_seats", layout.Count);
        entries[layout.Count + 1] = new HashEntry("available_count", layout.Count);
        entries[layout.Count + 2] = new HashEntry("last_updated",
            DateTimeOffset.UtcNow.ToUnixTimeSeconds());

        await _redis.HashSetAsync(key, entries);
        await _redis.KeyExpireAsync(key, TimeSpan.FromHours(24));
    }
}

Dynamic Pricing Engine

Ticket prices are not static — they vary by seat type, show time (matinee vs. evening vs. night), day of week (weekday vs. weekend vs. holiday), demand level, and special events. The pricing engine computes the final price for each seat at the time of display and locks that price during the booking flow.

Pricing FactorExampleImpact
Seat typeRecliner vs. Standard3x price difference (₹450 vs. ₹150)
Show timeMatinee (12 PM) vs. Night (9 PM)±20% variation
Day of weekFriday evening vs. Tuesday matinee±30% variation
Demand surgeOpening weekend, blockbuster releaseUp to 2x surge pricing
Special formatIMAX, 4DX, Dolby Atmos1.5x - 2.5x premium
HolidayDiwali, Eid, Republic Day+25-50% holiday surcharge
C#
public class PricingEngine
{
    public decimal CalculatePrice(SeatLayout seat, Show show, PricingRules rules)
    {
        decimal basePrice = seat.BasePrice;

        // Time-based multiplier
        decimal timeMultiplier = show.ShowTime.Hour switch
        {
            >= 6 and < 12  => 0.85m,  // Matinee discount
            >= 12 and < 17 => 1.00m,  // Afternoon standard
            >= 17 and >= 21 => 1.20m,  // Evening premium
            _                => 1.10m   // Late night
        };

        // Day-based multiplier
        decimal dayMultiplier = show.ShowDate.DayOfWeek switch
        {
            DayOfWeek.Monday or DayOfWeek.Tuesday or DayOfWeek.Wednesday => 0.85m,
            DayOfWeek.Thursday => 0.95m,
            DayOfWeek.Friday or DayOfWeek.Saturday => 1.15m,
            DayOfWeek.Sunday => 1.10m,
            _ => 1.00m
        };

        // Holiday surge
        decimal holidayMultiplier = rules.IsHoliday(show.ShowDate) ? 1.35m : 1.00m;

        // Demand surge (based on booking velocity)
        decimal demandMultiplier = CalculateDemandSurge(show.ShowId, rules);

        decimal finalPrice = basePrice * timeMultiplier * dayMultiplier
                           * holidayMultiplier * demandMultiplier;

        return Math.Round(finalPrice, 0); // Round to nearest rupee
    }
}

8. Seat Locking & Concurrency Control

Seat locking is the most critical concurrency challenge in the entire system. When a user selects seats and proceeds to checkout, those seats must be temporarily reserved — no other user can book them during the checkout window. This is fundamentally a distributed locking problem with strict requirements: the lock must be atomic, time-bounded (to prevent abandoned locks from permanently holding seats), and must recover gracefully from process crashes.

Why Simple Database Locks Fail

A naive approach would use a database SELECT ... FOR UPDATE to lock seat rows during checkout. This fails under load for three reasons. First, PostgreSQL row-level locks are held for the duration of the transaction, which includes the entire payment processing time (3-10 seconds). Holding row locks for seconds under high concurrency creates lock contention that degrades throughput from thousands of TPS to single digits. Second, if the application server crashes while holding the database lock, the lock is only released when the database detects the dead connection, which can take 30-60 seconds depending on TCP keepalive settings. Third, the lock scope is limited to a single database connection — if the checkout process spans multiple service calls, the lock must be reacquired at each step, creating race conditions.

Redis-Based Distributed Locking with TTL

The production approach uses Redis with the SET key value NX EX ttl command, which sets a key only if it does not exist (NX) with an expiration time (EX) atomically. This provides mutual exclusion with automatic lease expiration. If the application crashes, the lock automatically expires after the TTL, releasing the seats for other users. The typical lock duration is 10 minutes — long enough for the user to complete payment, short enough to prevent seats from being held indefinitely.

C#
public class SeatLockService
{
    private readonly IDatabase _redis;
    private readonly TimeSpan _lockDuration = TimeSpan.FromMinutes(10);

    public async Task<SeatLockResult> LockSeatsAsync(
        long showId, string[] seatIds, Guid userId)
    {
        var lockId = Guid.NewGuid();
        var results = new List<SeatLockResult>();

        // Atomic multi-seat lock using Redis pipeline
        var tasks = seatIds.Select(async seatId =>
        {
            var lockKey = $"lock:show:{showId}:seat:{seatId}";
            var seatKey = $"show:{showId}:seats";

            // Check current status first
            var currentStatus = await _redis.HashGetAsync(seatKey, seatId);
            if (currentStatus == "BOOKED" || currentStatus == "LOCKED")
            {
                return new SeatLockResult
                {
                    SeatId = seatId,
                    Success = false,
                    Reason = $"Seat {seatId} is already {currentStatus}"
                };
            }

            // Attempt atomic lock with TTL
            var acquired = await _redis.StringSetAsync(
                lockKey,
                userId.ToString(),
                _lockDuration,
                When.NotExists
            );

            if (acquired)
            {
                // Update seat status in the inventory hash
                await _redis.HashSetAsync(seatKey, seatId, "LOCKED");
                await _redis.HashIncrementAsync(seatKey, "available_count", -1);
                await _redis.HashIncrementAsync(seatKey, "locked_count", 1);

                return new SeatLockResult
                {
                    SeatId = seatId,
                    Success = true,
                    LockId = lockId,
                    ExpiresAt = DateTimeOffset.UtcNow.Add(_lockDuration)
                };
            }

            return new SeatLockResult
            {
                SeatId = seatId,
                Success = false,
                Reason = "Failed to acquire lock — seat may be locked by another user"
            };
        });

        return new SeatLockResult
        {
            LockId = lockId,
            AllLocked = results.All(r => r.Success),
            Results = results
        };
    }

    public async Task ReleaseSeatsAsync(
        long showId, string[] seatIds, Guid lockId, Guid userId)
    {
        foreach (var seatId in seatIds)
        {
            var lockKey = $"lock:show:{showId}:seat:{seatId}";
            var seatKey = $"show:{showId}:seats";

            // Verify ownership before releasing
            var lockValue = await _redis.StringGetAsync(lockKey);
            if (lockValue == userId.ToString())
            {
                await _redis.KeyDeleteAsync(lockKey);
                await _redis.HashSetAsync(seatKey, seatId, "AVAILABLE");
                await _redis.HashIncrementAsync(seatKey, "available_count", 1);
                await _redis.HashDecrementAsync(seatKey, "locked_count", 1);
            }
        }
    }
}

Lock Expiration Handler

When a lock expires (user did not complete payment in time), the system must restore the seat to AVAILABLE status. This is handled by a background job that polls for expired locks and reconciles the seat inventory.

C#
public class LockExpirationWorker : BackgroundService
{
    private readonly IDatabase _redis;
    private readonly IServiceScopeFactory _scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var expiredLocks = await _redis.SetMembersAsync("locks:expiring");

            foreach (var lockKey in expiredLocks)
            {
                // Parse show_id and seat_id from lock key
                var (showId, seatId) = ParseLockKey(lockKey);

                // Check if lock still exists (may have been confirmed)
                var stillLocked = await _redis.KeyExistsAsync(lockKey);
                if (!stillLocked)
                {
                    // Lock expired — restore seat to AVAILABLE
                    var seatKey = $"show:{showId}:seats";
                    var currentStatus = await _redis.HashGetAsync(seatKey, seatId);

                    if (currentStatus == "LOCKED")
                    {
                        await _redis.HashSetAsync(seatKey, seatId, "AVAILABLE");
                        await _redis.HashIncrementAsync(seatKey, "available_count", 1);
                        await _redis.HashDecrementAsync(seatKey, "locked_count", 1);

                        // Publish event for audit logging
                        await PublishSeatReleasedEvent(showId, seatId, "LOCK_EXPIRED");
                    }
                }
            }

            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}
Double-Booking Prevention: The combination of Redis NX-based locking and status checks in the seat inventory hash provides a two-layer defense against double-booking. The NX flag ensures only one process can acquire the lock for a given seat, while the status check in the hash prevents a race condition where two processes read AVAILABLE simultaneously and both attempt to lock. For extra safety, a Lua script can atomically check-and-set the status and acquire the lock in a single Redis operation.

9. Database Design & Sharding

PostgreSQL is the primary relational database for the booking, payment, and show services. At BookMyShow scale, a single PostgreSQL instance cannot handle the read and write throughput required. The database layer uses a combination of read replicas, connection pooling, table partitioning, and selective sharding.

Sharding Strategy

TableShard KeyShard CountRationale
bookingsuser_id128User-centric queries; all bookings for a user land on same shard
paymentsbooking_id128Co-located with bookings via consistent hashing on related keys
seat_inventoryshow_id256Show-centric access pattern; high write volume per show
showsmovie_id64All shows for a movie on same shard for efficient queries
moviesmovie_id16Relatively small table; fewer shards sufficient
usersuser_id64User profile and auth data partitioned by user

Connection Pooling

PgBouncer sits in front of PostgreSQL to manage connection pooling. Each microservice connects to PgBouncer with a pool of 50-100 connections, which PgBouncer multiplexes across the actual PostgreSQL connections. This prevents the "thundering herd" problem where thousands of concurrent booking requests exhaust PostgreSQL's max_connections limit. The pool mode is set to transaction-level, meaning each database connection is held only for the duration of a single transaction and then returned to the pool.

Table Partitioning for Seat Inventory

SQL
-- Partition seat_inventory by show_id range
CREATE TABLE seat_inventory (
    inventory_id  BIGSERIAL,
    show_id       BIGINT NOT NULL,
    seat_id       BIGINT NOT NULL,
    status        VARCHAR(20) DEFAULT 'AVAILABLE',
    locked_by     UUID,
    locked_at     TIMESTAMPTZ,
    price         DECIMAL(10, 2) NOT NULL,
    version       INT DEFAULT 1,
    PRIMARY KEY (inventory_id, show_id)
) PARTITION BY RANGE (show_id);

-- Create partitions for each shard range
CREATE TABLE seat_inventory_p0 PARTITION OF seat_inventory
    FOR VALUES FROM (0) TO (100000);
CREATE TABLE seat_inventory_p1 PARTITION OF seat_inventory
    FOR VALUES FROM (100000) TO (200000);
CREATE TABLE seat_inventory_p2 PARTITION OF seat_inventory
    FOR VALUES FROM (200000) TO (300000);

-- Index on show_id + seat_id for fast lookups
CREATE INDEX idx_seat_inv_show_seat
    ON seat_inventory (show_id, seat_id);

-- Index on status for cleanup queries
CREATE INDEX idx_seat_inv_status
    ON seat_inventory (show_id, status);

Cassandra for Booking History

While PostgreSQL handles active bookings and recent history, Cassandra stores the complete booking archive (7+ years). The Cassandra schema uses a user-partitioned design optimized for the "get user's past bookings" query pattern, which is the dominant read access for historical data.

CQL
CREATE TABLE booking_history (
    user_id     UUID,
    booking_date DATE,
    booking_id  UUID,
    show_date   TIMESTAMP,
    movie_title TEXT,
    venue_name  TEXT,
    screen_name TEXT,
    seats       LIST<TEXT>,
    total_amount DECIMAL,
    status      TEXT,
    PRIMARY KEY ((user_id), booking_date, booking_id)
) WITH CLUSTERING ORDER BY (booking_date DESC, booking_id ASC)
  AND default_time_to_live = 630720000;  -- 7 years

10. Caching Strategy

The caching strategy for a ticket booking platform is layered and nuanced because different data has different staleness tolerances. Movie metadata and show listings can tolerate a few minutes of staleness. Seat availability must be real-time. Pricing can tolerate seconds of staleness. The design uses a three-tier cache architecture: L1 (in-memory per-instance), L2 (Redis shared), and L3 (PostgreSQL source of truth).

Cache Tiers and TTL Policies

Data TypeL1 (In-Memory)L2 (Redis)Invalidation Strategy
Movie metadata5 min15 minTime-based TTL + event-driven on update
Show listings2 min5 minTime-based TTL + invalidation on show add/cancel
Seat layout30 min1 hourVery rarely changes; manual invalidation
Seat availabilityNone (always read Redis)Real-time (no TTL)Direct Redis updates during lock/book/cancel
Pricing rules5 min10 minTTL + admin panel invalidation
User sessionPer-request15 minJWT validation, sliding window renewal
Search results1 min3 minAggressive caching; background re-indexing
C#
public class CachedShowService : IShowService
{
    private readonly IDatabase _redis;
    private readonly IShowRepository _dbRepo;
    private readonly IMemoryCache _localCache;

    public async Task<ShowDetailsDto> GetShowDetailsAsync(long showId)
    {
        // L1: Check in-memory cache first
        var cacheKey = $"show:details:{showId}";
        if (_localCache.TryGetValue(cacheKey, out ShowDetailsDto cached))
            return cached;

        // L2: Check Redis
        var redisKey = $"show:{showId}:details";
        var redisValue = await _redis.StringGetAsync(redisKey);
        if (!redisValue.IsNullOrEmpty)
        {
            var dto = JsonSerializer.Deserialize<ShowDetailsDto>(redisValue);
            _localCache.Set(cacheKey, dto, TimeSpan.FromMinutes(2));
            return dto;
        }

        // L3: Query PostgreSQL
        var freshData = await _dbRepo.GetShowDetailsAsync(showId);
        if (freshData != null)
        {
            var serialized = JsonSerializer.Serialize(freshData);
            await _redis.StringSetAsync(redisKey, serialized, TimeSpan.FromMinutes(5));
            _localCache.Set(cacheKey, freshData, TimeSpan.FromMinutes(2));
        }

        return freshData;
    }

    public async Task InvalidateShowCacheAsync(long showId)
    {
        _localCache.Remove($"show:details:{showId}");
        await _redis.KeyDeleteAsync($"show:{showId}:details");
        await _redis.KeyDeleteAsync($"show:{showId}:listings");
    }
}
Cache Stampede Prevention: During flash sales, thousands of users simultaneously request seat availability for the same show when the cache expires. This creates a cache stampede that can overwhelm the database. The solution uses probabilistic early expiration (PLEX) where a percentage of requests re-fetch from the database slightly before TTL expires, combined with a distributed lock that ensures only one instance refreshes the cache while others serve stale data and wait.

11. Payment Processing & Transactions

Payment processing in a ticket booking system is uniquely challenging because it must coordinate across two domains: the payment gateway (external system) and the booking service (internal system). The fundamental problem is that there is no way to guarantee that both systems process a transaction atomically. A payment can succeed at the gateway but the confirmation notification can be lost, leaving the user charged but without a ticket. The design must handle this inconsistency through idempotent reconciliation.

Payment Flow State Machine

stateDiagram-v2 [*] --> PaymentInitiated : User clicks Pay PaymentInitiated --> PaymentProcessing : Gateway redirect PaymentProcessing --> PaymentSuccess : Gateway confirms PaymentProcessing --> PaymentFailed : Gateway rejects PaymentFailed --> PaymentInitiated : User retries PaymentSuccess --> BookingConfirmed : Confirmation service BookingConfirmed --> TicketGenerated : QR code + email PaymentSuccess --> PaymentRefunded : User cancels within window

Distributed Transaction Pattern: Saga

The booking confirmation uses a choreography-based Saga pattern where each step publishes an event that triggers the next step. If any step fails, compensating events undo the previous steps. This avoids the need for a two-phase commit across services.

C#
public class BookingSaga
{
    // Step 1: Lock seats (already done before payment)
    // Step 2: Initiate payment
    public async Task HandlePaymentInitiated(PaymentInitiatedEvent evt)
    {
        var payment = await _paymentService.InitiatePaymentAsync(
            evt.BookingId, evt.Amount, evt.PaymentMethod);

        if (payment.Status == PaymentStatus.PROCESSING)
        {
            // Publish event to continue saga
            await _eventBus.PublishAsync(new PaymentProcessingEvent
            {
                BookingId = evt.BookingId,
                PaymentId = payment.PaymentId
            });
        }
    }

    // Step 3: Payment gateway confirms
    public async Task HandlePaymentConfirmed(PaymentConfirmedEvent evt)
    {
        // Confirm booking
        var booking = await _bookingService.ConfirmBookingAsync(evt.BookingId);

        // Publish confirmation event
        await _eventBus.PublishAsync(new BookingConfirmedEvent
        {
            BookingId = evt.BookingId,
            UserId = booking.UserId,
            ShowId = booking.ShowId
        });
    }

    // Step 4: Generate ticket and send notification
    public async Task HandleBookingConfirmed(BookingConfirmedEvent evt)
    {
        var ticket = await _ticketService.GenerateTicketAsync(evt.BookingId);
        await _notificationService.SendBookingConfirmationAsync(
            evt.UserId, ticket);
    }

    // Compensating action: Payment failed — release seats
    public async Task HandlePaymentFailed(PaymentFailedEvent evt)
    {
        // Release locked seats
        await _seatLockService.ReleaseSeatsForBookingAsync(evt.BookingId);

        // Mark booking as expired/failed
        await _bookingService.MarkBookingFailedAsync(evt.BookingId);

        // Notify user
        await _notificationService.SendPaymentFailedAsync(
            evt.UserId, evt.BookingId);
    }
}

Payment Gateway Integration

GatewayPayment MethodsSettlementFailure Handling
RazorpayUPI, Cards, Wallets, Net BankingT+1 dayAuto-retry once, then failover to backup gateway
PayUUPI, Cards, EMI, Pay LaterT+2 daysImmediate failover on timeout
PaytmUPI, Wallet, CardsT+1 dayRetry with exponential backoff

Idempotent Payment Processing

C#
public class PaymentService
{
    private readonly IPaymentGateway _gateway;
    private readonly PaymentRepository _repo;

    public async Task<PaymentResult> ProcessPaymentAsync(
        ProcessPaymentCommand cmd)
    {
        // Check idempotency first
        var existing = await _repo.GetByIdempotencyKeyAsync(cmd.IdempotencyKey);
        if (existing != null)
        {
            return new PaymentResult
            {
                PaymentId = existing.PaymentId,
                Status = existing.Status,
                IsDuplicate = true
            };
        }

        // Create payment record
        var payment = new Payment
        {
            PaymentId = Guid.NewGuid(),
            BookingId = cmd.BookingId,
            Amount = cmd.Amount,
            PaymentMethod = cmd.PaymentMethod,
            IdempotencyKey = cmd.IdempotencyKey,
            Status = PaymentStatus.PROCESSING,
            CreatedAt = DateTime.UtcNow
        };

        await _repo.SaveAsync(payment);

        // Call payment gateway with idempotency key
        var gatewayResult = await _gateway.ChargeAsync(
            payment.PaymentId,
            payment.Amount,
            payment.PaymentMethod,
            cmd.IdempotencyKey.ToString() // Gateway idempotency key
        );

        payment.Status = gatewayResult.IsSuccess
            ? PaymentStatus.SUCCESS
            : PaymentStatus.FAILED;
        payment.GatewayTxnId = gatewayResult.TransactionId;
        payment.CompletedAt = DateTime.UtcNow;

        await _repo.UpdateAsync(payment);

        return new PaymentResult
        {
            PaymentId = payment.PaymentId,
            Status = payment.Status
        };
    }
}
Reconciliation Job: A nightly reconciliation job compares internal payment records with the payment gateway's settlement report. Any mismatches (e.g., a payment marked SUCCESS internally but not in the gateway, or vice versa) are flagged for manual review. This catches edge cases where webhook notifications were lost or duplicate payments were processed due to network issues.

12. Notification System

The notification system handles booking confirmations, payment receipts, show reminders, cancellation confirmations, and promotional messages. During peak booking periods, the system must send millions of notifications within minutes without impacting the booking flow. The design uses an event-driven architecture with Kafka as the message backbone and dedicated consumer services for each notification channel.

Notification Channels and SLAs

ChannelUse CaseLatency SLAProvider
EmailBooking confirmation, tickets (PDF/QR), receipts< 30 secondsAmazon SES
SMSBooking confirmation, OTP, show reminders< 10 secondsMSG91 / Twilio
Push notificationReal-time alerts, show reminders, price drops< 5 secondsFCM + APNs
In-appBooking status updates, wallet balance changes< 1 secondWebSocket / SSE
C#
public class NotificationConsumer : IConsumer<BookingConfirmedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ISmsService _smsService;
    private readonly IPushService _pushService;

    public async Task ConsumeAsync(BookingConfirmedEvent evt)
    {
        var tasks = new List<Task>();

        // Email with ticket PDF attachment
        tasks.Add(Task.Run(async () =>
        {
            var ticketPdf = await GenerateTicketPdfAsync(evt.BookingId);
            await _emailService.SendAsync(new EmailMessage
            {
                To = evt.UserEmail,
                Subject = $"Booking Confirmed - {evt.MovieTitle}",
                TemplateId = "booking_confirmed_v2",
                Attachments = new[] { ticketPdf },
                Variables = new Dictionary<string, string>
                {
                    ["movie_title"] = evt.MovieTitle,
                    ["venue"] = evt.VenueName,
                    ["show_time"] = evt.ShowTime.ToString("dd MMM, hh:mm tt"),
                    ["seats"] = string.Join(", ", evt.Seats),
                    ["amount"] = evt.FinalAmount.ToString("C"),
                    ["booking_id"] = evt.BookingId.ToString()
                }
            });
        }));

        // SMS confirmation
        tasks.Add(Task.Run(async () =>
        {
            await _smsService.SendAsync(new SmsMessage
            {
                To = evt.UserPhone,
                TemplateId = "booking_confirmed",
                Variables = new[] { evt.MovieTitle, evt.ShowTime.ToString("hh:mm tt"),
                    evt.BookingId.ToString()[..8] }
            });
        }));

        // Push notification
        tasks.Add(Task.Run(async () =>
        {
            await _pushService.SendAsync(new PushMessage
            {
                UserId = evt.UserId,
                Title = "Booking Confirmed!",
                Body = $"{evt.MovieTitle} at {evt.VenueName} — {evt.ShowTime:hh:mm tt}",
                Data = new Dictionary<string, string>
                {
                    ["booking_id"] = evt.BookingId.ToString(),
                    ["deep_link"] = $"bookmyshow://booking/{evt.BookingId}"
                }
            });
        }));

        // Send all notifications in parallel
        await Task.WhenAll(tasks);
    }
}

14. Security & Fraud Prevention

BookMyShow faces unique security challenges: ticket scalping bots that buy seats in bulk for resale at inflated prices, credential stuffing attacks on user accounts, payment fraud through stolen cards, and DDoS attacks during blockbuster releases. The security architecture must protect against all of these while maintaining a frictionless experience for legitimate users.

Anti-Scalping Measures

MeasureImplementationEffectiveness
Rate limitingUser-level: 5 bookings/hour, IP-level: 100 req/minBlocks automated bots
CAPTCHAreCAPTCHA v3 risk scoring on booking initiation99.5% bot detection
Max tickets per transactionHard limit of 10 tickets per bookingPrevents bulk buying
Max tickets per user per showLimit 2 bookings per user per showPrevents multi-transaction hoarding
Device fingerprintingTrack device ID, browser fingerprint, behavioral patternsIdentifies coordinated attacks
ML-based fraud scoringReal-time risk scoring using user behavior, device, velocityCatches sophisticated scalpers

Authentication & Authorization

C#
public class BookingAuthorizationFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var userId = GetUserIdFromJwt(context.HttpContext);
        var idempotencyKey = context.HttpContext.Request
            .Headers["Idempotency-Key"].FirstOrDefault();

        // Verify idempotency key is present for write operations
        if (string.IsNullOrEmpty(idempotencyKey))
        {
            context.Result = new BadRequestObjectResult(
                new { error = "Idempotency-Key header is required" });
            return;
        }

        // Rate limit check: max 5 bookings per hour per user
        var bookingCount = await _rateLimiter
            .GetBookingCountAsync(userId, TimeSpan.FromHours(1));
        if (bookingCount >= 5)
        {
            context.Result = new StatusCodeActionResult(429);
            return;
        }

        // Fraud score check
        var fraudScore = await _fraudDetector
            .EvaluateRequestAsync(userId, context.HttpContext);
        if (fraudScore > 0.8)
        {
            await _notificationService.NotifySecurityTeamAsync(
                userId, fraudScore, "High-risk booking attempt");
            // Still allow but add extra verification
            context.HttpContext.Items["requires_captcha"] = true;
        }

        await next();
    }
}

15. Monitoring & Observability

The monitoring stack provides real-time visibility into system health, booking funnel conversion, payment success rates, and seat utilization. During flash sales, the operations team must see live metrics to identify and respond to issues within seconds.

Key Metrics Dashboard

MetricAlert ThresholdDashboard Panel
Booking success rate< 95%Funnel visualization: browse → select → pay → confirm
Payment success rate< 92%Per-gateway success rate, failure reasons
Seat lock-to-booking ratio< 60%Locks acquired vs. confirmed bookings
Seat lock timeout rate> 40%Locks that expired without confirmation
API latency (p99)> 500msPer-endpoint latency percentiles
Redis memory usage> 80%Per-node memory, eviction rate
PostgreSQL connections> 80% poolPgBouncer active/idle/waiting connections
Kafka consumer lag> 10,000 msgsPer-consumer-group lag and processing rate
Active users per show> 5,000Real-time concurrent users per show
Error rate> 1%HTTP 5xx errors by service

Distributed Tracing

C#
// OpenTelemetry instrumentation for booking flow
public class BookingService
{
    private static readonly ActivitySource ActivitySource =
        new("BookingService");

    public async Task<BookingResult> CreateBookingAsync(CreateBookingCommand cmd)
    {
        using var activity = ActivitySource.StartActivity("CreateBooking");
        activity?.SetTag("user_id", cmd.UserId);
        activity?.SetTag("show_id", cmd.ShowId);
        activity?.SetTag("seat_count", cmd.SeatIds.Length);

        // Trace: Lock seats
        using (var lockActivity = ActivitySource.StartActivity("LockSeats"))
        {
            var lockResult = await _seatLockService.LockSeatsAsync(
                cmd.ShowId, cmd.SeatIds, cmd.UserId);
            lockActivity?.SetTag("locks_acquired",
                lockResult.Results.Count(r => r.Success));
        }

        // Trace: Calculate price
        using (var priceActivity = ActivitySource.StartActivity("CalculatePrice"))
        {
            var price = await _pricingEngine.CalculateAsync(
                cmd.ShowId, cmd.SeatIds);
            priceActivity?.SetTag("total_amount", price);
        }

        // Trace: Initiate payment
        using (var payActivity = ActivitySource.StartActivity("InitiatePayment"))
        {
            var payment = await _paymentService.InitiateAsync(cmd.BookingId);
            payActivity?.SetTag("payment_id", payment.PaymentId);
        }

        activity?.SetTag("booking.status", "PENDING");
        return result;
    }
}

16. Multi-Region Design

BookMyShow operates across multiple cities in India and has expanded to the Middle East and Southeast Asia. The multi-region design must handle geo-distributed traffic while ensuring that seat inventory for a specific show is always managed from a single primary region to prevent cross-region double-booking.

Region Architecture

graph LR subgraph India - Primary PG_IN[(PostgreSQL Primary)] Redis_IN[(Redis Primary)] end subgraph Middle East - Replica PG_ME[(PostgreSQL Replica)] Redis_ME[(Redis Replica)] end subgraph SEA - Replica PG_SEA[(PostgreSQL Replica)] Redis_SEA[(Redis Replica)] end PG_IN -->|Async Replication| PG_ME PG_IN -->|Async Replication| PG_SEA Redis_IN -->|CRDT Replication| Redis_ME Redis_IN -->|CRDT Replication| Redis_SEA

Multi-Region Strategy

Data TypeStrategyConsistency
Movie metadataMulti-region read replicasEventual (acceptable)
Show listingsMulti-region read replicasEventual (minutes lag acceptable)
Seat inventoryPrimary-region writes only; reads from regional RedisStrong (single-writer)
BookingsRouted to primary regionStrong (ACID)
PaymentsRouted to primary regionStrong (ACID)
User sessionsGlobal Redis with CRDTEventual (session tolerance)

17. Cost Estimation

ComponentSpecificationMonthly Cost (Est.)
PostgreSQL (RDS Multi-AZ)db.r6g.2xlarge x 3 + read replicas x 6$8,500
Redis (ElastiCache)r6g.xlarge cluster x 6 nodes$4,200
Elasticsearchm6g.xlarge.search x 5 nodes$3,100
Cassandra (Keyspaces)On-demand, ~50M reads + 10M writes/day$2,800
Application servers (ECS Fargate)50+ tasks, 4 vCPU / 8 GB each$6,500
Load balancers (ALB)3 ALBs with WAF$1,200
Kafka (MSK)$3,800
S3 + CloudFront CDN2 TB storage, 50 TB transfer/month$1,500
Email (SES)10M emails/month$1,000
SMS (MSG91)5M SMS/month$2,500
Monitoring (Datadog)Infrastructure + APM + Logs$3,200
Total estimated~$38,300/month
Cost Optimization: Approximately 60% of the infrastructure cost is driven by the seat inventory layer (Redis + PostgreSQL) during peak loads. Auto-scaling with scheduled scaling policies (scale up 30 minutes before blockbuster releases, scale down during off-peak hours) can reduce idle capacity costs by 35-40%. Reserved instances for baseline capacity save an additional 30% compared to on-demand pricing.

18. Edge Cases

Edge CaseImpactMitigation
Payment succeeds but confirmation webhook is lostUser charged but no ticketPeriodic reconciliation: query payment gateway for pending transactions, match with internal bookings, retry confirmation
User locks seats but payment gateway is downSeats locked for 10 min, unavailable to othersCircuit breaker on payment gateway; redirect to backup gateway within 3 seconds; auto-release seats on gateway failure
Concurrent lock attempt for same seatOne user sees success, other sees failureRedis NX flag ensures mutual exclusion; failing user immediately sees "seat no longer available"
Show cancelled after tickets soldUsers have paid for invalid ticketsAutomated refund pipeline triggered by show cancellation event; notification to all affected users
Seat layout change mid-dayInconsistent seat map for evening showsLayout changes only take effect for shows created after the change; existing shows use cached layout snapshot
Flash sale causes 100x traffic spikeSystem overload, slow responseVirtual waiting queue (like Ticketmaster); queue position assigned, users notified when it is their turn
User books ticket then requests chargebackRevenue loss + potential fraudFlag user account; require manual review for chargebacks over ₹500; integrate with chargeback management system
Duplicate idempotency key from different requestsSecond request returns cached response for wrong bookingIdempotency key scoped to user + endpoint; reject if key already used for different parameters

19. Interview Q&A

Q1: How do you prevent double-booking of seats?

Double-booking prevention uses a two-layer approach. The first layer is a Redis-based distributed lock using the SET key value NX EX ttl command, which atomically checks if a seat is available and locks it. The second layer is an optimistic concurrency control on the PostgreSQL seat_inventory table using a version column — when the booking is confirmed, an UPDATE ... WHERE version = expected_version ensures no other transaction has modified the seat in between. If the version check fails, the booking is rejected. This two-layer approach provides both performance (Redis handles the hot path) and correctness (PostgreSQL provides the durable guarantee).

Q2: How do you handle flash sale traffic spikes?

Flash sales require three key mechanisms: (1) A virtual waiting queue that assigns users a queue position when they arrive, preventing all users from hitting the seat inventory simultaneously. Users see a countdown timer and are let into the booking flow in batches of 100. (2) Aggressive caching of movie details, show listings, and seat layouts in Redis and CDN to absorb 90%+ of read traffic without hitting the database. (3) Auto-scaling with scheduled scaling policies that pre-warm additional capacity 30 minutes before a known flash sale event. The combination of load leveling via the queue, read traffic absorption via caching, and capacity pre-warming via auto-scaling prevents system overload.

Q3: What happens if the payment succeeds but the booking confirmation fails?

This is handled by a reconciliation service that runs every 5 minutes. It queries the payment gateway API for transactions in the last 10 minutes that have a SUCCESS status, and cross-references them with internal booking records. If a payment is successful but the booking is still in PENDING status, the reconciliation service retries the booking confirmation. If the booking record does not exist at all (catastrophic failure), it creates the booking record directly from the payment data and triggers ticket generation. This ensures no user is ever charged without receiving their ticket. The reconciliation job also handles the reverse case — bookings marked as confirmed but payments that failed at the gateway — by cancelling the booking and releasing the seats.

Q4: How do you design the seat map for different theater configurations?

Each theater screen has a unique seat layout stored as a matrix in the seat_layouts table. The layout defines row labels (A, B, C...), seat numbers within each row, seat type (recliner, premium, standard), base price, and physical attributes (aisle positions, wheelchair-accessible seats, couple seats). The front-end renders the seat map based on this layout definition, including curved rows, missing seats (aisles), and different seat widths for recliners. The layout is stored as a JSON document in Redis for fast retrieval and is updated only when the theater physically reconfigures the screen — a rare event that does not require real-time consistency.

Q5: How do you handle seat lock timeouts during payment processing?

When a user locks seats, a 10-minute TTL is set on the Redis lock key. A background worker (LockExpirationWorker) runs every 10 seconds, checking for expired locks and restoring seats to AVAILABLE status. During payment processing, if the payment takes longer than expected (e.g., user is slow to enter UPI PIN), the system attempts to extend the lock once at the 8-minute mark. If the lock expires during payment processing, the payment gateway call is allowed to complete (to avoid double-charging), and the post-payment reconciliation service handles the case where the seat was already booked by another user — issuing a refund and notifying the original user.

Q6: How do you design the refund and cancellation flow?

The cancellation flow starts with a user-initiated request through the app or website. The system checks the cancellation policy (defined per show by the theater — typically 24 hours before showtime for full refund, 12 hours for 50% refund, less than 4 hours no refund). If eligible, the booking status is updated to CANCELLED, the seats are released back to AVAILABLE, and a refund is initiated to the original payment method. The refund is processed asynchronously through the payment gateway, with the user seeing "Refund Initiated" status immediately. The actual refund takes 3-5 business days for cards and instant to 24 hours for UPI. A notification is sent at each stage: cancellation confirmed, refund initiated, refund completed.

Q7: How do you handle concurrent users trying to book the last seat?

This is the classic "thundering last seat" problem. When only one seat remains for a popular show, hundreds of users may attempt to lock it simultaneously. The Redis NX-based lock ensures that only one user successfully acquires the lock. The other users immediately receive a "seat no longer available" response without any database query. The lock acquisition is O(1) in Redis, so even 10,000 concurrent attempts for the same seat complete within milliseconds. The front-end handles this gracefully by showing a real-time update that the seat was taken, prompting the user to select alternative seats.

Q8: How do you design the system to handle show cancellations and rescheduling?

When a theater cancels or reschedules a show, the Show Service publishes a ShowCancelled or ShowRescheduled event. The Booking Service consumes this event and: (1) identifies all affected bookings, (2) marks them as CANCELLED, (3) triggers full refunds to all affected users, (4) releases all seats back to the inventory, and (5) sends personalized notifications explaining the situation and next steps. For rescheduled shows, users are given the option to rebook for the new time with a priority lock window (e.g., 24 hours of exclusive access to book the rescheduled show). The entire process is automated but has a manual override for the operations team to handle edge cases.

Q9: How do you ensure idempotency across the booking flow?

Every mutating API call requires an Idempotency-Key header (UUID v4). The server stores the key with its response in a Redis hash with a 24-hour TTL. If a duplicate request arrives with the same key, the cached response is returned without reprocessing. This applies to: seat locking (prevents double-locking), payment initiation (prevents double-charging), and booking confirmation (prevents duplicate bookings). The idempotency key is scoped to the user ID and endpoint — the same key used for a seat lock cannot be reused for a payment call. This prevents replay attacks while allowing safe network retries.

Q10: How do you design the virtual waiting room for flash sales?

The virtual waiting room is implemented using a combination of Redis sorted sets and a token-bucket algorithm. When a flash sale begins, users entering the page are assigned a position in a Redis sorted set based on their arrival timestamp. The front-end polls a status endpoint every 5 seconds that returns: current position in queue, estimated wait time, and total users ahead. The system processes users in batches of 100 — when it is a user's turn, they receive a unique token that grants them access to the booking flow for 10 minutes. The queue is served FIFO to ensure fairness. Users who abandon their position (close the tab) are automatically removed after 2 minutes of inactivity. This prevents server overload while giving every user a fair chance at tickets.

Q11: How do you handle multiple payment methods and partial payments?

The Payment Service supports splitting a single booking payment across multiple payment methods (e.g., ₹200 from wallet + ₹550 from UPI). The total amount is split into line items, each processed as an independent payment transaction with its own idempotency key. All partial payments must succeed for the booking to be confirmed. If any partial payment fails, all successful partial payments are refunded atomically, and the booking is marked as FAILED. The wallet balance deduction is immediate, while UPI/card charges are asynchronous. The system tracks the partial payment state in the database and reconciles any inconsistencies within 30 minutes.

Q12: How would you scale the system to handle 10x current traffic?

Scaling to 10x requires changes across all layers: (1) Database: Increase shard count from 128 to 512 for bookings, add read replicas (from 3 to 10), implement query result caching for common patterns. (2) Redis: Scale from 6-node cluster to 16-node cluster, implement local L1 cache (IMemoryCache) to absorb 30% of Redis reads. (3) Application: Increase Fargate task count with auto-scaling policies, implement connection pooling more aggressively. (4) Async processing: Increase Kafka partition count for booking and payment topics, add more consumer instances. (5) Front-end: Implement client-side caching and optimistic UI updates to reduce round trips. (6) CDN: Extend cache coverage to include API responses for non-personalized endpoints (movie listings, show times).

Q13: How do you handle booking analytics and business intelligence?

Every booking event, payment event, and seat inventory change is published to Kafka topics. A stream processing job (using Apache Flink or Kafka Streams) computes real-time aggregations: bookings per show, revenue per venue, conversion rates, popular seat types, and payment method distribution. These aggregations are written to a ClickHouse OLAP database for business intelligence dashboards. Historical data (beyond 90 days) is archived to S3 and queried via Athena for ad-hoc analysis. The BI team uses tools like Metabase or Looker connected to ClickHouse for daily, weekly, and monthly reporting.

20. Full C# Implementation

Booking Service — Core Domain Model

C#
namespace BookMyShow.Domain.Entities;

public class Booking
{
    public Guid BookingId { get; private set; }
    public long UserId { get; private set; }
    public long ShowId { get; private set; }
    public BookingStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }
    public decimal ConvenienceFee { get; private set; }
    public decimal DiscountAmount { get; private set; }
    public decimal FinalAmount => TotalAmount + ConvenienceFee - DiscountAmount;
    public List<BookingSeat> Seats { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? ConfirmedAt { get; private set; }
    public DateTime ExpiresAt { get; private set; }

    private Booking() { }

    public static Booking Create(long userId, long showId, List<BookingSeat> seats,
        decimal convenienceFee)
    {
        return new Booking
        {
            BookingId = Guid.NewGuid(),
            UserId = userId,
            ShowId = showId,
            Status = BookingStatus.PENDING,
            TotalAmount = seats.Sum(s => s.Price),
            ConvenienceFee = convenienceFee,
            Seats = seats,
            CreatedAt = DateTime.UtcNow,
            ExpiresAt = DateTime.UtcNow.AddMinutes(10)
        };
    }

    public void Confirm()
    {
        if (Status != BookingStatus.PENDING)
            throw new InvalidOperationException(
                $"Cannot confirm booking in {Status} status");
        if (DateTime.UtcNow > ExpiresAt)
            throw new BookingExpiredException(BookingId);

        Status = BookingStatus.CONFIRMED;
        ConfirmedAt = DateTime.UtcNow;
    }

    public void Cancel(string reason)
    {
        if (Status == BookingStatus.CANCELLED || Status == BookingStatus.REFUNDED)
            throw new InvalidOperationException("Booking already cancelled");

        Status = BookingStatus.CANCELLED;
    }
}

public enum BookingStatus
{
    PENDING,
    CONFIRMED,
    CANCELLED,
    REFUNDED,
    EXPIRED
}

public class BookingSeat
{
    public long SeatId { get; set; }
    public string RowLabel { get; set; }
    public int SeatNumber { get; set; }
    public string SeatType { get; set; }
    public decimal Price { get; set; }
}

Booking Service — Application Layer

C#
namespace BookMyShow.Application.Services;

public class BookingApplicationService
{
    private readonly SeatLockService _seatLockService;
    private readonly BookingRepository _bookingRepo;
    private readonly PricingEngine _pricingEngine;
    private readonly IEventBus _eventBus;
    private readonly ILogger<BookingApplicationService> _logger;

    public async Task<LockSeatsResponse> LockSeatsAsync(
        LockSeatsCommand command)
    {
        _logger.LogInformation(
            "User {UserId} attempting to lock {Count} seats for show {ShowId}",
            command.UserId, command.SeatIds.Length, command.ShowId);

        // Validate show is active and in the future
        var show = await _bookingRepo.GetShowAsync(command.ShowId);
        if (show == null || show.Status != ShowStatus.ACTIVE)
            throw new ShowNotFoundException(command.ShowId);

        if (show.ShowDate.Add(show.ShowTime) <= DateTime.UtcNow)
            throw new ShowAlreadyStartedException(command.ShowId);

        // Validate seat count (max 10 per booking)
        if (command.SeatIds.Length > 10)
            throw new ValidationException("Maximum 10 seats per booking");

        // Check user hasn't exceeded booking limit for this show
        var existingBookings = await _bookingRepo
            .GetActiveBookingsForUserShowAsync(command.UserId, command.ShowId);
        if (existingBookings.Count >= 2)
            throw new BookingLimitExceededException(
                "Maximum 2 bookings per user per show");

        // Attempt to lock all seats atomically
        var lockResult = await _seatLockService.LockSeatsAsync(
            command.ShowId, command.SeatIds, command.UserId);

        if (!lockResult.AllLocked)
        {
            // Release any seats that were successfully locked
            var lockedSeats = lockResult.Results
                .Where(r => r.Success)
                .Select(r => r.SeatId)
                .ToArray();

            if (lockedSeats.Any())
            {
                await _seatLockService.ReleaseSeatsAsync(
                    command.ShowId, lockedSeats, lockResult.LockId, command.UserId);
            }

            var failedSeats = lockResult.Results
                .Where(r => !r.Success)
                .Select(r => r.SeatId)
                .ToList();

            throw new SeatUnavailableException(failedSeats);
        }

        // Calculate pricing
        var seats = command.SeatIds.Select(seatId =>
        {
            var (row, num) = ParseSeatId(seatId);
            return new BookingSeat { SeatId = 0, RowLabel = row, SeatNumber = num };
        }).ToList();

        var pricing = await _pricingEngine.CalculateAsync(
            command.ShowId, command.SeatIds);

        // Create pending booking
        var booking = Booking.Create(
            command.UserId,
            command.ShowId,
            seats,
            pricing.ConvenienceFee);

        booking.TotalAmount = pricing.TotalSeatPrice;
        await _bookingRepo.SaveAsync(booking);

        return new LockSeatsResponse
        {
            LockId = lockResult.LockId,
            BookingId = booking.BookingId,
            ExpiresAt = lockResult.Results.First().ExpiresAt,
            TotalAmount = pricing.TotalSeatPrice,
            ConvenienceFee = pricing.ConvenienceFee,
            FinalAmount = booking.FinalAmount,
            Seats = seats
        };
    }

    public async Task<ConfirmBookingResponse> ConfirmBookingAsync(
        ConfirmBookingCommand command)
    {
        var booking = await _bookingRepo.GetByIdAsync(command.BookingId);
        if (booking == null)
            throw new BookingNotFoundException(command.BookingId);

        if (booking.UserId != command.UserId)
            throw new UnauthorizedException("Not your booking");

        // Verify payment is successful
        var payment = await _bookingRepo.GetPaymentAsync(command.PaymentId);
        if (payment == null || payment.Status != PaymentStatus.SUCCESS)
            throw new PaymentNotCompletedException(command.PaymentId);

        if (payment.BookingId != command.BookingId)
            throw new ValidationException("Payment does not match booking");

        // Confirm booking (with idempotency)
        if (booking.Status == BookingStatus.CONFIRMED)
        {
            return new ConfirmBookingResponse
            {
                BookingId = booking.BookingId,
                Status = "CONFIRMED",
                IsDuplicate = true
            };
        }

        booking.Confirm();
        await _bookingRepo.UpdateAsync(booking);

        // Generate ticket
        var ticket = await GenerateTicketAsync(booking);

        // Publish booking confirmed event
        await _eventBus.PublishAsync(new BookingConfirmedEvent
        {
            BookingId = booking.BookingId,
            UserId = booking.UserId,
            ShowId = booking.ShowId,
            MovieTitle = booking.Show.Movie.Title,
            VenueName = booking.Show.Screen.Venue.Name,
            ShowTime = booking.Show.ShowTime,
            Seats = booking.Seats.Select(s => $"{s.RowLabel}{s.SeatNumber}").ToList(),
            FinalAmount = booking.FinalAmount,
            UserEmail = booking.User.Email,
            UserPhone = booking.User.Phone
        });

        return new ConfirmBookingResponse
        {
            BookingId = booking.BookingId,
            Status = "CONFIRMED",
            TicketUrl = ticket.DownloadUrl,
            QrCodeBase64 = ticket.QrCodeImage
        };
    }
}

Seat Lock Service — Atomic Lua Script

C#
namespace BookMyShow.Infrastructure.Services;

public class SeatLockService
{
    private readonly IDatabase _redis;
    private readonly TimeSpan _lockTTL = TimeSpan.FromMinutes(10);

    private const string AtomicLockScript = @"
        local seatKey = KEYS[1]
        local lockKey = KEYS[2]
        local seatId = ARGV[1]
        local userId = ARGV[2]
        local ttl = ARGV[3]

        -- Check current seat status
        local status = redis.call('HGET', seatKey, seatId)
        if status == 'BOOKED' or status == 'LOCKED' then
            return {'FAILED', status}
        end

        -- Try to acquire lock (atomic)
        local acquired = redis.call('SET', lockKey, userId, 'NX', 'EX', ttl)
        if not acquired then
            return {'FAILED', 'LOCKED_BY_OTHER'}
        end

        -- Update seat status
        redis.call('HSET', seatKey, seatId, 'LOCKED')
        redis.call('HINCRBY', seatKey, 'available_count', -1)
        redis.call('HINCRBY', seatKey, 'locked_count', 1)

        return {'SUCCESS', lockKey}
    ";

    public async Task<SeatLockResult> LockSeatAtomicAsync(
        long showId, string seatId, Guid userId)
    {
        var seatKey = $"show:{showId}:seats";
        var lockKey = $"lock:show:{showId}:seat:{seatId}";

        var result = await _redis.ScriptEvaluateAsync(
            AtomicLockScript,
            new RedisKey[] { seatKey, lockKey },
            new RedisValue[] { seatId, userId.ToString(), (int)_lockTTL.TotalSeconds });

        var resultArray = (RedisValue[])result;
        var success = resultArray[0].ToString() == "SUCCESS";

        return new SeatLockResult
        {
            SeatId = seatId,
            Success = success,
            LockId = success ? Guid.NewGuid() : Guid.Empty,
            ExpiresAt = success ? DateTimeOffset.UtcNow.Add(_lockTTL) : DateTimeOffset.MinValue,
            Reason = success ? null : $"Seat unavailable: {resultArray[1]}"
        };
    }
}

Pricing Engine — Full Implementation

C#
namespace BookMyShow.Domain.Services;

public class PricingEngine
{
    private readonly IPricingRuleRepository _ruleRepo;
    private readonly IDemandTracker _demandTracker;
    private readonly IHolidayCalendar _holidayCalendar;

    public async Task<PricingResult> CalculateAsync(
        long showId, string[] seatIds)
    {
        var show = await _ruleRepo.GetShowPricingContextAsync(showId);
        var rules = await _ruleRepo.GetPricingRulesAsync(show.Venue.CityId);
        var demand = await _demandTracker.GetCurrentDemandAsync(showId);

        var seatPrices = new List<SeatPriceResult>();

        foreach (var seatId in seatIds)
        {
            var seatLayout = await _ruleRepo.GetSeatLayoutAsync(
                show.ScreenId, seatId);

            decimal basePrice = seatLayout.BasePrice;

            // Apply multipliers
            decimal timeMultiplier = GetTimeMultiplier(show.ShowTime);
            decimal dayMultiplier = GetDayMultiplier(show.ShowDate);
            decimal holidayMultiplier = _holidayCalendar.IsHoliday(show.ShowDate)
                ? rules.HolidaySurcharge : 1.0m;
            decimal demandMultiplier = GetDemandMultiplier(demand);
            decimal formatMultiplier = GetFormatMultiplier(show.Format);

            decimal finalPrice = Math.Round(
                basePrice * timeMultiplier * dayMultiplier
                * holidayMultiplier * demandMultiplier * formatMultiplier);

            seatPrices.Add(new SeatPriceResult
            {
                SeatId = seatId,
                RowLabel = seatLayout.RowLabel,
                SeatNumber = seatLayout.SeatNumber,
                SeatType = seatLayout.SeatType,
                BasePrice = basePrice,
                FinalPrice = finalPrice
            });
        }

        decimal totalSeatPrice = seatPrices.Sum(s => s.FinalPrice);
        decimal convenienceFee = Math.Round(totalSeatPrice * 0.04m, 0);
        convenienceFee = Math.Max(convenienceFee, 10); // Minimum ₹10

        return new PricingResult
        {
            SeatPrices = seatPrices,
            TotalSeatPrice = totalSeatPrice,
            ConvenienceFee = convenienceFee,
            Currency = "INR"
        };
    }

    private decimal GetTimeMultiplier(TimeSpan showTime)
    {
        return showTime.TotalHours switch
        {
            >= 6 and < 12  => 0.85m,
            >= 12 and < 17 => 1.00m,
            >= 17 and < 21 => 1.20m,
            >= 21           => 1.10m,
            _               => 1.00m
        };
    }

    private decimal GetDayMultiplier(DateTime showDate)
    {
        return showDate.DayOfWeek switch
        {
            DayOfWeek.Monday or DayOfWeek.Tuesday or DayOfWeek.Wednesday => 0.85m,
            DayOfWeek.Thursday => 0.95m,
            DayOfWeek.Friday => 1.10m,
            DayOfWeek.Saturday => 1.20m,
            DayOfWeek.Sunday => 1.10m,
            _ => 1.00m
        };
    }

    private decimal GetDemandMultiplier(DemandLevel demand)
    {
        return demand switch
        {
            DemandLevel.Low => 0.90m,
            DemandLevel.Normal => 1.00m,
            DemandLevel.High => 1.15m,
            DemandLevel.VeryHigh => 1.30m,
            DemandLevel.Surge => 1.50m,
            _ => 1.00m
        };
    }

    private decimal GetFormatMultiplier(string format)
    {
        return format switch
        {
            "IMAX" => 1.80m,
            "4DX" => 2.00m,
            "DOLBY_ATMOS" => 1.60m,
            "3D" => 1.30m,
            "IMAX_3D" => 2.20m,
            _ => 1.00m
        };
    }
}

21. Conclusion

Designing a ticket booking platform like BookMyShow is one of the most comprehensive system design exercises because it touches nearly every distributed systems concept: real-time inventory management, distributed locking, payment orchestration, event-driven architecture, caching strategies, search systems, and fraud prevention. The key architectural insight is that the seat inventory is the most critical shared resource, and every design decision flows from protecting its consistency. Redis-first seat status with Lua-based atomic locking provides the performance needed for flash sales. PostgreSQL with optimistic concurrency control provides the durability needed for financial transactions. Kafka-based event streaming connects the booking, payment, and notification services without tight coupling.

Key Numbers to Remember

MetricValue
Monthly active users50 million
Peak concurrent users (flash sale)500,000+
Peak booking TPS15,000
Seat lock TTL10 minutes
Lock-to-booking conversion target> 70%
Payment success rate target> 95%
Booking confirmation latency (p99)< 500ms
Search results latency (p99)< 200ms
Seat layout Redis read (p99)< 5ms
Availability target99.99% (4.32 min/month)

Production Checklist

  • Implement Redis-based atomic seat locking with Lua scripts and 10-minute TTL
  • Use PostgreSQL with optimistic concurrency control (version column) for booking confirmation
  • Integrate at least 2 payment gateways with automatic failover
  • Implement idempotency keys for all mutating API endpoints
  • Deploy a reconciliation job that runs every 5 minutes to catch lost webhooks
  • Set up a virtual waiting room for flash sales with queue-based load leveling
  • Cache movie metadata and show listings at 3 levels: L1 in-memory, L2 Redis, L3 PostgreSQL
  • Use Kafka for all async communication: booking events, payment events, notifications
  • Implement anti-scalping measures: rate limiting, CAPTCHA, device fingerprinting, ML fraud scoring
  • Monitor the booking funnel conversion rate as the top business metric
  • Use OpenTelemetry for distributed tracing across all services
  • Implement circuit breakers on payment gateway integrations with automatic failover
  • Set up multi-region read replicas with single-writer pattern for seat inventory
  • Archive booking history to Cassandra for 7-year retention
  • Test the entire booking flow under 10x peak load before each blockbuster release

Common Interview Mistakes to Avoid

  • Using a simple database decrement for seat inventory — it does not handle concurrent access or partial failures; you need distributed locking
  • Ignoring the payment-webhook-lost edge case — always design for reconciliation between your system and the payment gateway
  • Making booking confirmation synchronous with payment processing — separate the concerns: lock → pay → confirm via Saga pattern
  • Not discussing idempotency — network retries in distributed systems are guaranteed; without idempotency, you will have double bookings and double charges
  • Forgetting about seat lock timeouts — if a user locks seats but never pays, other users cannot book those seats until the lock expires; the 10-minute TTL and background cleanup worker are essential
  • Not considering flash sale scenarios — a system that works at 500 TPS will crash at 15,000 TPS; you must discuss load leveling, auto-scaling, and caching under extreme load
  • Ignoring the search and discovery layer — in a booking platform, the search experience is as important as the booking flow; discuss Elasticsearch integration
  • Not separating the read path from the write path — reads (browsing, searching) should be served from cached/read-replica paths while writes (booking, payment) go through the primary database

Key Takeaways

A ticket booking system is fundamentally a real-time inventory management system with financial transaction requirements. The architecture decisions you make — from the Redis-first seat locking strategy to the Saga-based payment flow to the event-driven notification pipeline — all flow from two core requirements: seat uniqueness (never double-book) and payment reliability (never charge without confirming). The seat inventory service is the most critical component and must be designed for extreme concurrency: Redis Lua scripts provide atomic check-and-lock, the 10-minute TTL prevents indefinite seat holding, and the background cleanup worker ensures consistency after crashes.

Whether you are designing a ticket booking platform for movies, concerts, sports events, or conferences, the fundamental principles remain the same: protect the seat inventory with distributed locking, orchestrate payments with idempotent Saga patterns, cache aggressively for read-heavy paths, use event-driven architecture for async operations, and always design for the worst-case flash sale scenario. The companies that have mastered these principles — BookMyShow, Ticketmaster, StubHub, and Eventbrite — have built systems that handle millions of concurrent users competing for limited seats, processing billions of dollars in transactions while maintaining sub-second response times and 99.99% availability. Mastering this design gives you a comprehensive understanding of distributed systems that applies to many other domains, from airline reservation systems to ride-sharing platforms to real-time bidding systems.

Ayodhyya — System Design Blog Series

BookMyShow System Design — Senior+ Guide

Sandip Mhaske

I’m a software developer exploring the depths of .NET, AWS, Angular, React, and digital entrepreneurship. Here, I decode complex problems, share insightful solutions, and navigate the evolving landscape of tech and finance.

Post a Comment

Previous Post Next Post