Low-Level Design: Event Management System — Venue Booking, Ticketing, and Attendee Management

Core Entities

Venue: venue_id, name, address, capacity, type (ARENA, CONFERENCE_CENTER, THEATER, OUTDOOR), facilities (JSON: {parking, catering, av_equipment}), timezone, is_active. Event: event_id, organizer_id, venue_id, title, description, category (CONCERT, CONFERENCE, SPORTS, FESTIVAL), status (DRAFT, PUBLISHED, ON_SALE, SOLD_OUT, CANCELLED, COMPLETED), start_time, end_time, cover_image_url, age_restriction. TicketTier: tier_id, event_id, name (GENERAL, VIP, EARLY_BIRD, BACKSTAGE), price, total_quantity, sold_quantity, available_quantity, sale_start, sale_end, max_per_order. Order: order_id, event_id, buyer_id, status (PENDING, CONFIRMED, CANCELLED, REFUNDED), total_amount, payment_id, created_at, expires_at. OrderItem: item_id, order_id, tier_id, quantity, unit_price, subtotal. Ticket: ticket_id, order_id, tier_id, attendee_name, attendee_email, qr_code (unique token), status (VALID, USED, CANCELLED, TRANSFERRED), issued_at. VenueBooking: booking_id, venue_id, event_id, start_time, end_time, setup_hours_before, teardown_hours_after, status, deposit_paid.

Ticket Purchase with Inventory Control

class TicketingService:
    ORDER_TTL_MINUTES = 15

    def reserve_tickets(self, event_id: int, buyer_id: int,
                        selections: list[dict]) -> Order:
        with self.db.transaction():
            order = Order(
                event_id=event_id, buyer_id=buyer_id,
                status=OrderStatus.PENDING,
                expires_at=datetime.utcnow() + timedelta(minutes=self.ORDER_TTL_MINUTES)
            )
            items = []
            for sel in selections:
                tier = self.db.query(
                    'SELECT * FROM ticket_tiers WHERE tier_id=:t FOR UPDATE',
                    t=sel['tier_id']
                )
                if tier.available_quantity  tier.max_per_order:
                    raise MaxPerOrderError(tier.max_per_order)
                if not (tier.sale_start <= datetime.utcnow() <= tier.sale_end):
                    raise SaleNotActiveError()

                tier.available_quantity -= sel['quantity']
                tier.sold_quantity += sel['quantity']
                self.db.save(tier)

                items.append(OrderItem(
                    tier_id=tier.tier_id,
                    quantity=sel['quantity'],
                    unit_price=tier.price,
                    subtotal=tier.price * sel['quantity']
                ))

            order.total_amount = sum(i.subtotal for i in items)
            self.db.save(order)
            for item in items:
                item.order_id = order.order_id
                self.db.save(item)

            # Set expiry timer in Redis
            self.redis.setex(f'order_expiry:{order.order_id}',
                             self.ORDER_TTL_MINUTES * 60, '1')
            return order

    def release_expired_orders(self):
        # Scheduled job every minute — release unpaid expired reservations
        expired = self.db.query(
            'SELECT * FROM orders WHERE status='PENDING' AND expires_at < NOW()'
        )
        for order in expired:
            for item in order.items:
                self.db.execute(
                    'UPDATE ticket_tiers SET available_quantity=available_quantity+:q, ' +
                    'sold_quantity=sold_quantity-:q WHERE tier_id=:t',
                    q=item.quantity, t=item.tier_id
                )
            order.status = OrderStatus.CANCELLED
            self.db.save(order)

Venue Booking and Conflict Detection

A venue cannot host two events that overlap in time (including setup and teardown). Booking conflict check: SELECT COUNT(*) FROM venue_bookings WHERE venue_id = :v AND status NOT IN (‘CANCELLED’) AND start_time :new_start. Where new_start = event.start_time – setup_hours and new_end = event.end_time + teardown_hours. If count > 0: conflict exists. Transaction lock: SELECT … FOR UPDATE on venue_bookings for the venue to prevent concurrent bookings from both passing the check. Booking deposit: venues typically require a deposit to confirm a booking. Store deposit_paid amount and payment_id. On deposit payment: status transitions from REQUESTED to CONFIRMED. Catering and AV add-ons: stored as VenueBookingAddOn (addon_id, booking_id, type, description, cost). Included in the venue invoice.

Check-In and Ticket Validation

QR code: each ticket has a unique UUID token stored as a QR code. At the venue: scanner app reads QR code, sends token to the validation API. Validation API: SELECT * FROM tickets WHERE qr_code = :token. Check status = VALID. If valid: update status = USED, record check_in_time. Return ADMIT. If status = USED: return ALREADY_ADMITTED (duplicate scan). If status = CANCELLED: return INVALID. Response time: < 200ms including database round trip. Caching: cache valid ticket tokens in Redis (TTL = event duration). Most validations hit Redis without a DB query. On ticket use: invalidate the Redis cache key and update the DB asynchronously. Offline mode: the scanner app downloads all valid tokens for the event before doors open. Validates locally if network is unavailable. Syncs used tickets when connectivity returns. Transfer: a ticket holder can transfer to another person. CREATE a new Ticket record for the transferee, mark the original as CANCELLED, email the QR code. Limit transfers to once per ticket to prevent scalping chains.

Event Discovery and Search

Search: full-text Elasticsearch index on event title, description, and venue name. Filter by: category, date range, location (geosearch by venue coordinates), price range, availability (has_available_tickets). Sort: relevance (default), date, price. Geosearch: Elasticsearch geo_distance query on venue location. Find events within X km of the user’s location. Personalization: if user is logged in, boost events in categories they have attended before. Trending: events with the most ticket sales in the last 24 hours — maintained as a Redis sorted set, updated on each order confirmation. Homepage: top 10 from the trending set, filtered to events in the user’s city. Waitlist: when an event sells out, users can join a waitlist (tier-specific). On cancellation, notify the next waitlisted user with a 30-minute window to purchase the released ticket.


{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you prevent overselling tickets for a high-demand event?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use pessimistic locking on the inventory row: SELECT available_quantity FROM ticket_inventory WHERE event_id = ? AND tier_id = ? FOR UPDATE. Within the same transaction: check available_quantity >= requested. If yes, decrement and insert an Order record. Commit. The FOR UPDATE lock prevents concurrent transactions from reading or decrementing the same row simultaneously. For extremely high concurrency (ticket drops for major events), pre-shard inventory into multiple rows (e.g., 10 rows of 100 tickets each) and route each buyer to a random shard — this reduces lock contention by 10x.”}},{“@type”:”Question”,”name”:”How do you handle ticket reservation expiry (the 15-minute hold problem)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”When a user initiates checkout, create a Reservation record with status=HELD and expires_at = now + 15 minutes. Decrement available_quantity at reservation time (not at payment). A background job (or database TTL job) runs every minute: SELECT * FROM reservations WHERE status=HELD AND expires_at < now FOR UPDATE SKIP LOCKED. For each expired reservation: set status=EXPIRED and increment available_quantity back. SKIP LOCKED allows multiple worker instances to process different expired reservations in parallel without blocking each other. On payment success: transition reservation to CONFIRMED.”}},{“@type”:”Question”,”name”:”How does venue conflict detection work with setup and teardown buffers?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Each event has start_time, end_time, setup_buffer_minutes, and teardown_buffer_minutes. The "occupied window" for venue conflict is: effective_start = start_time – setup_buffer, effective_end = end_time + teardown_buffer. To check for conflicts: SELECT * FROM events WHERE venue_id = ? AND status NOT IN ('CANCELLED') AND effective_start < requested_effective_end AND effective_end > requested_effective_start. This is the standard interval overlap check (A.start < B.end AND A.end > B.start) applied to the buffered windows. The venue cannot be booked if any existing event's buffered window overlaps the new event's buffered window.”}},{“@type”:”Question”,”name”:”How does QR code check-in work with offline mode support?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”QR code payload: a signed JWT containing ticket_id, event_id, and tier. The signature uses the event's private key so it can be verified without a network call. Online check-in: scan QR, validate JWT signature, call the API to mark ticket as CHECKED_IN (prevents reuse), return go/no-go in < 200ms. Offline mode: door staff downloads the full attendee list (ticket IDs + JWT public key) before the event. Scans validate the JWT signature locally and check against the downloaded list. Offline check-ins are synced to the server when connectivity resumes. Duplicate detection in offline mode uses a local in-memory set of scanned ticket IDs for the duration of the event.”}},{“@type”:”Question”,”name”:”How do you scale event discovery with location-based search?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store each venue's coordinates as a geospatial index (PostGIS geography type or MySQL spatial index). Geosearch query: SELECT events.* FROM events JOIN venues ON events.venue_id = venues.id WHERE ST_DWithin(venues.location, ST_MakePoint(lng, lat)::geography, radius_meters) AND events.start_time > now ORDER BY events.start_time. For read-heavy discovery: cache popular geosearch results in Redis with a short TTL (60 seconds). For sub-millisecond latency at scale: pre-index events into Elasticsearch with geo_point fields; use geo_distance queries. Elasticsearch shards geospatially and returns top-K results across shards with a merge step.”}}]}

See also: Airbnb Interview Prep

See also: Stripe Interview Prep

See also: Shopify Interview Prep

Scroll to Top