Low-Level Design: Event Booking System — Seat Selection, Inventory Lock, and Payment Coordination

Core Entities

Event: event_id, name, venue_id, event_date, doors_open, start_time, status (ON_SALE, SOLD_OUT, CANCELLED, COMPLETED), total_capacity. Venue: venue_id, name, address, seating_map_url, total_seats. Section: section_id, venue_id, event_id, name (Floor A, Section 101), capacity, base_price, available_count. Seat: seat_id, section_id, row, seat_number, status (AVAILABLE, HELD, SOLD, BLOCKED), held_by_session_id, held_until. Order: order_id, user_id, event_id, status (PENDING, CONFIRMED, CANCELLED, REFUNDED), total_amount, created_at, expires_at. OrderItem: item_id, order_id, seat_id, price, fee_amount. Ticket: ticket_id, order_item_id, barcode (unique), is_valid, scanned_at.

Seat Hold and Expiry

The core challenge: seats must be reserved while the user completes payment, but not held forever (prevents inventory lock-up). Two-phase approach: Hold (temporary): when user selects seats, lock them for N minutes (typically 10-15 minutes). Payment: user completes payment within the hold window. Confirm: on successful payment, mark seats as SOLD and create tickets.

class SeatService:
    HOLD_DURATION_MINUTES = 10

    def hold_seats(self, session_id: str, seat_ids: list[int]) -> Order:
        with self.db.transaction():
            # Lock the rows with SELECT FOR UPDATE
            seats = self.repo.lock_seats(seat_ids)
            for seat in seats:
                if seat.status != SeatStatus.AVAILABLE:
                    raise SeatUnavailableError(seat.seat_id)

            holds_expire_at = datetime.utcnow() + timedelta(minutes=self.HOLD_DURATION_MINUTES)
            for seat in seats:
                seat.status = SeatStatus.HELD
                seat.held_by_session_id = session_id
                seat.held_until = holds_expire_at

            order = Order(session_id=session_id,
                          status=OrderStatus.PENDING,
                          expires_at=holds_expire_at)
            self.repo.save_all(seats + [order])
            return order

Hold Expiry and Cleanup

A background job runs every 60 seconds: SELECT seat_id FROM seats WHERE status=’HELD’ AND held_until < NOW(). For each expired hold: UPDATE seats SET status='AVAILABLE', held_by_session_id=NULL, held_until=NULL WHERE seat_id=:id AND status='HELD'. Also cancel the associated pending order. Optimistic check (AND status='HELD'): prevents releasing a seat that was just confirmed by a concurrent payment. After releasing: increment section.available_count so the section map shows updated availability. Redis pub/sub: broadcast the seat availability change so open browser sessions can update their seat maps in real time without polling.

Payment and Ticket Issuance

class CheckoutService:
    def confirm_order(self, order_id: int, payment_token: str) -> list[Ticket]:
        with self.db.transaction():
            order = self.repo.lock_order(order_id)  # SELECT FOR UPDATE
            if order.status != OrderStatus.PENDING:
                raise OrderNotPendingError()
            if datetime.utcnow() > order.expires_at:
                self._cancel_order(order)
                raise OrderExpiredError()

            # Process payment
            charge = self.payment.charge(payment_token, order.total_amount,
                                          idempotency_key=str(order_id))
            if charge.status != "succeeded":
                raise PaymentFailedError(charge.decline_reason)

            # Confirm seats
            seats = self.repo.get_seats_for_order(order_id)
            for seat in seats:
                seat.status = SeatStatus.SOLD
            order.status = OrderStatus.CONFIRMED
            order.payment_id = charge.id

            # Generate tickets with unique barcodes
            tickets = [Ticket(seat_id=s.seat_id,
                              barcode=self._generate_barcode(),
                              is_valid=True)
                       for s in seats]
            self.repo.save_all(seats + [order] + tickets)
            return tickets

High-Concurrency Seat Selection

For popular events (Taylor Swift, playoff tickets): thousands of users simultaneously try to select the same seats. The SELECT FOR UPDATE approach works but creates lock contention. Optimizations: (1) Virtual queue: admit users to the seat selection flow in batches (like flash sale waiting room). Reduces simultaneous seat selection attempts. (2) Section-level inventory: show sections with available_count > 0 on the event map. Only allow entering seat-level selection for sections with availability. Reduces wasted attempts. (3) Auto-assign for General Admission: instead of user-selected seats, system assigns seats automatically (best available algorithm). Eliminates seat selection contention entirely. (4) Read replica for browsing: serve the event map and seat availability from a read replica. Only route to the primary when actually holding a seat.

Asked at: Stripe Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Asked at: Uber Interview Guide

Scroll to Top