Skip to content

im-rk/rushtix-frontend

Repository files navigation

RushTix 🎟️

Production-Grade Event Ticketing Platform

🔗 Backend Repository: github.com/im-rk/rushtix-backend

Java Spring Boot Next.js PostgreSQL Redis Stripe Status


java logo spring logo nextjs logo postgresql logo redis logo

RushTix is a production-grade event ticketing platform built to solve a problem every major ticketing system faces: two users clicking the same seat at the same millisecond. It combines a Spring Boot 4 REST API, a Next.js 16 organizer dashboard, PostgreSQL with optimistic concurrency control, Redis distributed locking, and Stripe for split and single payment flows — producing a system that is correct under concurrent load, operationally reliable, and ready to scale. This is not a tutorial project; it implements real distributed systems patterns including a Saga orchestrator for group payments, a Transactional Outbox for reliable event delivery, and an automated booking expiry scheduler that reclaims leaked seat inventory.


📸 Application Previews

Public Attendee Experience

Public 1 Public 2 Public 3
Public 4 Public 5 Public 6
Public 7 Public 8

Organizer Dashboard

Org 1 Org 2 Org 3
Org 4 Org 5 Org 6
Org 7

Table of Contents


Architecture Overview

RushTix is a two-service system. The backend is a Spring Boot 4 application that owns all business logic, database transactions, and payment processing. The frontend is a Next.js 16 application that serves as the Organizer Panel — allowing event creators to manage their events, venues, seat maps, and view booking dashboards in real time.

The two services communicate over HTTP. Spring Security enforces JWT-based authentication on all protected endpoints. PostgreSQL acts as the system of record for all state. Redis (Upstash in production) is used exclusively for distributed seat locking during the checkout window — it is a soft guarantee, not the source of truth. Stripe handles all payment processing including split-payment checkout sessions for the group booking flow.

graph TD
    subgraph Client [Client Tier]
        Desktop[Web Browser / Organizer Dashboard]
        Mobile[Mobile Device / Attendees]
    end

    subgraph Frontend [Next.js Application Frontend]
        AppRouter[Next.js App Router]
        Zustand[Zustand State Mgmt]
        Query[TanStack Query Data Fetching]
        StripeElem[Stripe Elements JS]
    end

    subgraph APIGateway [Security Layer]
        CORS[CORS Filter]
        JWTFilter[JWT Authentication Filter]
        SpringSec[Spring Security Context]
    end

    subgraph Core [Spring Boot Core Backend]
        subgraph Controllers [REST Controllers]
            AuthCtrl[AuthController]
            OrgCtrl[OrganizerController]
            BookingCtrl[BookingController]
            GroupCtrl[GroupBookingController]
            PublicCtrl[PublicEventsController]
            WebhookCtrl[StripeWebhookController]
        end

        subgraph Business [Business Logic Services]
            AuthSvc[AuthenticationService]
            EventSvc[EventManagementService]
            SeatSvc[SeatManagementService]
            BookingSvc[BookingUserService]
            SagaSvc[GroupPaySagaOrchestrator]
            LockSvc[RedisLockService]
            PaymentSvc[PaymentGatewayService]
        end

        subgraph Background [Background Jobs]
            ExpiryJob[BookingCleanUpScheduler]
            TimeoutJob[GroupBookingTimeoutWorker]
            OutboxProc[Outbox Processor Worker]
        end

        subgraph DataAccess [Data Access Layer]
            Repositories[Spring Data JPA Repositories]
            OutboxRepo[OutboxMessageRepository]
        end
    end

    subgraph Infrastructure [Data Infrastructure]
        PG[(PostgreSQL 16<br/>Relational DB / OCC)]
        Redis[(Redis 7<br/>Distributed Locks)]
    end

    subgraph External [External Services]
        StripeAPI[Stripe Payments API]
    end

    %% Client to Frontend
    Desktop --> AppRouter
    Mobile --> AppRouter
    AppRouter --> Zustand
    AppRouter --> Query

    %% Frontend to Backend
    Query -- HTTP/JSON --> CORS
    CORS --> JWTFilter
    JWTFilter --> SpringSec
    SpringSec --> Controllers

    %% Stripe Elements
    StripeElem -- Tokenize Card --> StripeAPI

    %% Controllers to Services
    AuthCtrl --> AuthSvc
    OrgCtrl --> EventSvc
    OrgCtrl --> SeatSvc
    BookingCtrl --> BookingSvc
    GroupCtrl --> SagaSvc
    WebhookCtrl --> PaymentSvc

    %% Service interactions
    BookingSvc --> LockSvc
    BookingSvc --> PaymentSvc
    SagaSvc --> LockSvc
    SagaSvc --> PaymentSvc

    %% Services to Data
    EventSvc --> Repositories
    SeatSvc --> Repositories
    BookingSvc --> Repositories
    SagaSvc --> Repositories
    PaymentSvc --> Repositories
    AuthSvc --> Repositories

    %% Lock Service to Redis
    LockSvc -- SET NX / DEL --> Redis

    %% Repositories to DB
    Repositories -- JPA / Hibernate --> PG
    OutboxRepo -- JPA --> PG

    %% Background Jobs
    ExpiryJob -- Poll / Update --> Repositories
    TimeoutJob -- Check Status --> Repositories
    OutboxProc -- Poll / Publish --> OutboxRepo

    %% External System Integration
    PaymentSvc -- Create Intent / Refund --> StripeAPI
    StripeAPI -- Webhook Events --> WebhookCtrl

    classDef client fill:#f9f9f9,stroke:#333,stroke-width:2px,color:#000;
    classDef frontend fill:#000,stroke:#333,stroke-width:2px,color:#fff;
    classDef backend fill:#6db33f,stroke:#333,stroke-width:2px,color:#fff;
    classDef data fill:#336791,stroke:#333,stroke-width:2px,color:#fff;
    classDef redis fill:#d82c20,stroke:#333,stroke-width:2px,color:#fff;
    classDef external fill:#635bff,stroke:#333,stroke-width:2px,color:#fff;

    class Desktop,Mobile client;
    class AppRouter,Zustand,Query,StripeElem frontend;
    class CORS,JWTFilter,SpringSec,Controllers,Business,AuthCtrl,OrgCtrl,BookingCtrl,GroupCtrl,PublicCtrl,WebhookCtrl,AuthSvc,EventSvc,SeatSvc,BookingSvc,SagaSvc,LockSvc,PaymentSvc,Background,ExpiryJob,TimeoutJob,OutboxProc,DataAccess,Repositories,OutboxRepo backend;
    class PG data;
    class Redis redis;
    class StripeAPI external;
Loading

Core Features

Event Management

Organizers can create, update, publish, and cancel events through a full CRUD REST API protected by role-based access control (ROLE_ORGANIZER). Events adhere to a strict lifecycle state machine: DRAFT → PUBLISHED → CANCELLED. Publishing makes the event visible in the public discovery API, whereas cancellation records a reason and a timestamp. Events carry computed fields (seatsSold, seatsLocked, totalRevenue) calculated on-the-fly via Hibernate @Formula subqueries, thereby avoiding a denormalized counter susceptible to concurrent drift.

Each event is associated with a Venue storing GPS coordinates (utilizing BigDecimal for precision mapping), a JSONB seatMapConfig for customized seat layout structures, and timezone metadata. Booking windows (booking_opens_at, booking_closes_at) are strictly enforced independently of the physical event date.

Concurrent Seat Booking with Distributed Locking

This constitutes the core engineering challenge of any high-traffic ticketing system. RushTix solves the double-booking anomaly utilizing a Two-Phase Lock strategy:

Phase 1 — Redis Soft Lock (10-minute TTL): When a user proceeds to checkout, RedisLockService.acquireSeatLocks() attempts a Redis SET NX (Set if Not eXists) command for each requested seat:

// RedisLockService.java
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent("seat:lock:" + seatId, userId.toString(), Duration.ofMinutes(10));

If any lock acquisition fails, all previously acquired locks in the batch are explicitly released and the user immediately receives a rejection, preventing unnecessary database contention.

Phase 2 — PostgreSQL Optimistic Locking (OCC): Each Seat entity carries an OCC @Version column. At transaction commit time, Hibernate executes:

UPDATE seats SET status='BOOKED', version=2, ... WHERE id=? AND version=1

If 0 rows are affected, it implies a version mismatch indicating another transaction has already modified the record. This throws an OptimisticLockException and triggers a full rollback.

Standard Booking Flow:

1. POST /api/v1/bookings/reserve
   -> RedisLockService.acquireSeatLocks()       [Phase 1: Redis SET NX, 10-min TTL]
   -> seat.setStatus(LOCKED)                    [Database write within transaction]
   -> Booking(status=PENDING) created

2. POST /api/v1/bookings/{id}/pay-single
   -> Stripe PaymentIntent initialized

3. POST /api/v1/stripe/webhook (Stripe Webhook)
   -> seat.setStatus(BOOKED)                    [Phase 2: OCC UPDATE WHERE version=N]
   -> seat.setQrToken("RUSH-TIX-" + UUID)
   -> Redis seat locks released

Group Booking — Split-Pay Saga

RushTix supports decentralized group purchases where each participant pays their own share. This mechanism is implemented as a Saga pattern — orchestrating distributed Stripe checkout sessions. The GroupPaySagaOrchestrator guarantees that seats are permanently confirmed only when all group members have successfully completed payment, and handles automated compensating refunds if the session window expires before completion.

Saga Orchestration Flow:

1. POST /api/v1/group-bookings/initiate
   -> acquireSeatLocks() for all seats          [Redis SET NX]
   -> GroupBooking(status=0 PENDING) created
   -> N Stripe Checkout Sessions instantiated

2. POST /api/v1/stripe/webhook (For each member)
   -> GroupPaymentItem(status=PAID) updated
   -> If ALL items evaluate to PAID -> commitSaga()
        -> GroupBooking(status=1 CONFIRMED)
        -> All seats -> BOOKED with QR tokens
        -> Redis seat locks released

3. Timeout Condition -> rollbackSaga()          [Compensating Transaction]
   -> GroupBooking(status=2 FAILED_REJECTED)
   -> All seats -> AVAILABLE
   -> Async Stripe refunds dispatched to paid members

State is enumerated via a smallint (0/1/2) backed by a database-level CHECK constraint. Compensating Stripe refunds execute via CompletableFuture.runAsync() to decouple the critical path database commit from external network latency.

Automated Inventory Reclamation

A scheduled Spring worker (BookingCleanUpScheduler) executes on a fixed rate interval of 60 seconds:

@Scheduled(fixedRate = 60000)
@Transactional
public void cleanUpExpiredBookings() {
    List<Booking> expired = bookingRepository
        .findAllByStatusAndExpiresAtBefore(BookingStatus.PENDING, OffsetDateTime.now());

    for (Booking booking : expired) {
        booking.setStatus(BookingStatus.CANCELLED);
        for (Seat seat : booking.getSeats()) {
            seat.setStatus(SeatStatus.AVAILABLE);
            seat.setBooking(null);
            seat.setLockedBy(null);
            seat.setLockedUntil(null);
        }
    }
    bookingRepository.saveAll(expired);
}

This mitigates inventory leakage, ensuring that a 10-minute checkout lock does not become permanent due to client abandonment.

Transactional Outbox Pattern

The OutboxMessage entity implements the Transactional Outbox pattern. Writing to the outbox table occurs inside the identical database transaction as the booking commit. This entirely eliminates the dual-write hazard where a booking successfully commits but the downstream notification event is lost due to an application crash.

Each outbox record consists of: aggregate_type, aggregate_id, event_type, a JSONB payload, exponential backoff indicators (retry_count, max_retries, next_retry_at), and an idempotency_key string to prevent double-processing anomalies upon retry.

Organizer Dashboard

The Next.js 16 Organizer Panel equips administrators with robust event lifecycle control capabilities. Features include:

  • Dashboard: Revenue charting, seat occupancy metrics, and comprehensive booking overviews.
  • Events: Entity management containing publish/cancel lifecycle controls.
  • Bookings: Paginated, deeply filterable interface merging standard and group bookings via native UNION ALL SQL querying.
  • Venues: Spatial profiles and localized seat map JSON structure administration.
  • Seat Mapping: Seat categorization and dynamic pricing tier allocation.

Client architecture implements Zustand for authentication state synchronization and TanStack Query for cached server-state orchestration. Client forms employ react-hook-form coupled with zod for rigorous type-safe schema validation.

Ticket Pass and QR Code Generation

Following successful payment processing, each finalized seat allocates a cryptographically unique qr_token:

  • Individual bookings format: RUSH-TIX-{32-character UUID hex}
  • Group bookings format: RUSH-GROUP-{32-character UUID hex}

The React frontend dynamically renders these identifier strings as scannable QR passes utilizing qrcode.react. The secure /api/v1/user/tickets endpoint subsequently returns all confirmed tickets mapped to the authenticated user's session identifier.


Technical Deep Dives

Preventing Double Bookings — Two-Phase Locking

The most critical challenge in event ticketing is ensuring two users cannot book the same seat. RushTix utilizes a Two-Phase Lock strategy to guarantee correctness without sacrificing throughput.

sequenceDiagram
    autonumber
    participant Client
    participant API as BookingUserService
    participant Redis as Redis (Soft Lock)
    participant PG as PostgreSQL (OCC)

    Client->>API: POST /bookings/reserve
    API->>Redis: SET NX seat:lock:123
    alt Lock Acquired
        Redis-->>API: OK
        API->>PG: UPDATE seats SET status='LOCKED'
        PG-->>API: 1 row affected
        API-->>Client: Booking PENDING
    else Lock Failed
        Redis-->>API: nil (Key exists)
        API-->>Client: 409 Conflict (Seat taken)
    end
Loading

Phase 1 — Redis Soft Lock: Redis provides an atomic SET NX (Set if Not eXists) operation. This guarantees that only the first thread attempting to lock a specific seat ID will succeed. This operation happens before any database transaction begins, serving as a high-speed filter that rejects conflicting requests in under 10 milliseconds.

Phase 2 — PostgreSQL Optimistic Concurrency Control: If Redis is completely unavailable, the system safely falls back to the database. The true source of truth remains the database's ACID properties, specifically enforced via Optimistic Locking during the state mutation.

Group Booking Saga Orchestration

To handle group payments where multiple users split the cost, RushTix implements a choreography-free Saga pattern. The GroupPaySagaOrchestrator centralizes the coordination of multiple distributed Stripe checkout sessions.

stateDiagram-v2
    direction LR
    [*] --> PENDING_GROUP_PAYMENT : Initiate
    PENDING_GROUP_PAYMENT --> ALL_PAID : Webhooks (Stripe)
    PENDING_GROUP_PAYMENT --> TIMEOUT : Scheduler (60m)
    
    state ALL_PAID {
        [*] --> UpdateSeats
        UpdateSeats --> CommitSaga
        CommitSaga --> [*]
    }
    ALL_PAID --> CONFIRMED
    
    state TIMEOUT {
        [*] --> ReleaseSeats
        ReleaseSeats --> AsyncRefunds
        AsyncRefunds --> RollbackSaga
        RollbackSaga --> [*]
    }
    TIMEOUT --> FAILED_REJECTED
    
    CONFIRMED --> [*]
    FAILED_REJECTED --> [*]
Loading

When a timeout occurs, a compensating transaction (rollbackSaga()) is executed. Crucially, the external Stripe refunds are dispatched asynchronously via CompletableFuture.runAsync(). This decouples the database rollback from external network latency, ensuring the database transaction never blocks waiting for the Stripe API.

Optimistic Concurrency Control (OCC)

RushTix avoids database-level row locking (SELECT FOR UPDATE) to maintain high throughput during traffic spikes. Instead, entities like Seat, Event, and TicketCategory carry a @Version column.

sequenceDiagram
    participant UserA as Transaction A
    participant DB as PostgreSQL
    participant UserB as Transaction B

    UserA->>DB: SELECT seat (version=1)
    UserB->>DB: SELECT seat (version=1)
    
    Note over UserA,DB: Checkout Window Processing
    
    UserA->>DB: UPDATE seat SET version=2 WHERE version=1
    DB-->>UserA: Success (1 row updated)
    
    UserB->>DB: UPDATE seat SET version=2 WHERE version=1
    DB-->>UserB: Failure (0 rows updated)
    Note over UserB,DB: OptimisticLockException<br/>Transaction Rollback
Loading

Under high concurrency, OCC allows complete parallelism. Conflict detection only occurs precisely at commit time. This design choice prevents database connection pool exhaustion that would otherwise occur if transactions were held open during long payment windows.

Booking Expiry — Scheduled Auto-Release

To prevent users from indefinitely holding inventory, a dedicated @Scheduled(fixedRate = 60000) worker continuously polls the database for expired reservations. When a PENDING booking exceeds its 10-minute Time-To-Live, the worker forcefully reverts the associated seats to AVAILABLE and clears the Redis lock, returning the inventory to the market.


Database Schema

erDiagram
    USERS ||--o{ EVENTS : creates
    USERS ||--o{ BOOKINGS : makes
    USERS ||--o{ GROUP_BOOKINGS : initiates
    VENUES ||--o{ EVENTS : hosts
    EVENTS ||--o{ TICKET_CATEGORIES : has
    EVENTS ||--o{ SEATS : has
    EVENTS ||--o{ BOOKINGS : receives
    TICKET_CATEGORIES ||--o{ PRICE_HISTORY : tracks
    TICKET_CATEGORIES ||--o{ SEATS : categorizes
    BOOKINGS ||--o{ PAYMENTS : generates
    BOOKINGS ||--o{ SEATS : includes
    GROUP_BOOKINGS ||--o{ GROUP_PAYMENT_ITEMS : contains
    SEATS ||--o{ GROUP_PAYMENT_ITEMS : assigned_to
    
    USERS {
        uuid id PK
        varchar email
        varchar full_name
        varchar password_hash
        enum role
        enum status
        varchar verification_token
        timestamptz verified_at
        varchar reset_token_hash
        timestamptz reset_token_expires
        timestamptz last_login_at
        timestamptz deleted_at
        timestamptz created_at
        timestamptz updated_at
    }
    EVENTS {
        uuid id PK
        uuid organizer_id FK
        uuid venue_id FK
        varchar title
        text description
        varchar category
        varchar image_url
        timestamptz event_date
        timestamptz event_end_date
        timestamptz booking_opens_at
        timestamptz booking_closes_at
        enum status
        int total_seats
        int version
        timestamptz cancelled_at
        text cancellation_reason
        timestamptz created_at
        timestamptz updated_at
    }
    VENUES {
        uuid id PK
        uuid organizer_id FK
        varchar name
        varchar city
        varchar state
        text address_line
        varchar pincode
        decimal latitude
        decimal longitude
        int total_capacity
        varchar timezone
        jsonb seat_map_config
        enum status
        timestamptz created_at
        timestamptz updated_at
    }
    TICKET_CATEGORIES {
        uuid id PK
        uuid event_id FK
        varchar name
        int display_order
        decimal base_price
        decimal current_price
        decimal min_price
        decimal max_price
        int capacity
        enum pricing_algorithm
        boolean dynamic_pricing_enabled
        int version
        timestamptz created_at
        timestamptz updated_at
    }
    SEATS {
        uuid id PK
        uuid event_id FK
        uuid category_id FK
        uuid locked_by FK
        uuid booked_by FK
        uuid booking_id FK
        varchar row_label
        varchar seat_number
        varchar display_label
        boolean is_accessible
        enum status
        decimal price_paid
        decimal price_multiplier
        text qr_token
        timestamptz attended_at
        timestamptz locked_until
        int version
        timestamptz updated_at
    }
    BOOKINGS {
        uuid id PK
        uuid user_id FK
        uuid event_id FK
        enum status
        decimal total_amount
        varchar idempotency_key
        timestamptz expires_at
        timestamptz confirmed_at
        text cancellation_reason
        int version
        timestamptz created_at
        timestamptz updated_at
    }
    GROUP_BOOKINGS {
        uuid id PK
        uuid initiator_user_id FK
        uuid event_id FK
        smallint status
        decimal total_amount
        decimal per_person_amount
        timestamptz expires_at
        timestamptz created_at
    }
    GROUP_PAYMENT_ITEMS {
        uuid id PK
        uuid group_booking_id FK
        uuid assigned_seat_id FK
        varchar friend_email
        enum status
        text stripe_checkout_url
        varchar stripe_payment_intent_id
    }
    PRICE_HISTORY {
        uuid id PK
        uuid category_id FK
        uuid event_id FK
        decimal old_price
        decimal new_price
        decimal base_price
        decimal multiplier
        double demand_score
        double velocity_score
        double urgency_score
        int seats_remaining
        int bookings_last_hour
        varchar pricing_trigger
        varchar algorithm_version
        timestamptz recorded_at
    }
    OUTBOX_MESSAGES {
        uuid id PK
        varchar aggregate_type
        uuid aggregate_id
        varchar event_type
        jsonb payload
        enum status
        int retry_count
        int max_retries
        timestamptz next_retry_at
        text last_error
        varchar idempotency_key
        timestamptz created_at
        timestamptz processed_at
    }
    PAYMENTS {
        uuid id PK
        uuid booking_id FK
        varchar provider
        varchar provider_payment_id
        decimal amount
        varchar currency
        enum status
        varchar idempotency_key
        text provider_metadata
        timestamptz completed_at
    }
Loading

Schema Design Decisions

UUID Primary Keys — Prevents sequential ID enumeration attacks. Enables ID generation at the application layer (no DB round-trip). Supports future multi-region sharding without collision.

Soft Delete on Usersdeleted_at timestamp instead of hard delete. Preserves FK integrity of booking records and financial audit trail for compliance.

@Version on Concurrency-Sensitive Entitiesseats, events, and ticket_categories all carry a version INTEGER for optimistic locking. Chosen over pessimistic locking for throughput reasons.

Append-Only price_history — Never updated or deleted. Every price change is a new row, creating a complete audit trail and ML training dataset. Columns demand_score, velocity_score, urgency_score, and algorithm_version are ML feature columns captured at decision time.

JSONB for Flexible Schemasvenues.seat_map_config stores seat layout as JSONB. Different venue types (theatre, stadium, standing) have fundamentally different structures — JSONB avoids a polymorphic table design with many nullable columns.

@Formula for Derived FieldsEvent.seatsSold, Event.seatsLocked, Event.totalRevenue are Hibernate @Formula subqueries, not denormalized counters. Avoids counter drift under concurrent writes at the cost of subquery CPU at read time.

Database Indexes

Index Name Table Columns Reason
idx_event_status_date events status, event_date Powers public event discovery queries
idx_event_organizer events organizer_id Powers GET /organizer/events
idx_seat_event_status seats event_id, status Powers seat availability queries
idx_seat_booking seats booking_id Powers booking → seats join
uk_seat_event_row_seat seats event_id, row_label, seat_number Unique constraint — DB-level duplicate seat prevention

Tech Stack

Backend

Technology Version Purpose
Java 17 Primary language
Spring Boot 4.0.6 Application framework
Spring Boot Web MVC 4.0.6 REST API layer
Spring Boot Data JPA 4.0.6 ORM and repository layer (Hibernate)
Spring Boot Security 4.0.6 JWT auth and role-based access control
Spring Boot Data Redis 4.0.6 Redis client for distributed seat locking
Spring Boot Validation 4.0.6 Bean validation (@Valid, @NotNull)
Spring Boot Flyway 4.0.6 Database schema migrations
JJWT (api/impl/jackson) 0.11.5 JWT generation (HS256) and validation
PostgreSQL Driver runtime JDBC driver
Flyway PostgreSQL managed Flyway PostgreSQL dialect
Lombok latest Boilerplate reduction
MapStruct 1.5.5.Final Type-safe DTO mapping
Stripe Java SDK 28.0.0 Payment processing + refunds
PostgreSQL 16 Primary relational database
Redis / Upstash 7 Managed Redis for distributed locking

Frontend

Technology Version Purpose
Next.js 16.2.6 React framework with App Router
React 18.3.1 UI component library
TypeScript 5.3.3 Type-safe JavaScript
TanStack Query 5.37.1 Server state, caching, refetch
Zustand 4.4.7 Client-side auth state
Axios 1.7.2 HTTP client with JWT interceptors
React Hook Form 7.79.0 Form state management
Zod 4.4.3 Runtime schema validation
@stripe/react-stripe-js 6.6.0 Stripe Payment Element (PCI-compliant)
@stripe/stripe-js 9.8.0 Stripe.js browser SDK
qrcode.react 4.2.0 QR code rendering for ticket passes
lucide-react 0.427.0 Icon library
clsx 2.1.0 Conditional CSS class composition
Tailwind CSS 3.4.1 Utility-first CSS framework

Project Structure

Frontend

frontend/src/
├── app/
│   ├── dashboard/                ← Organizer dashboard (events, bookings, venues)
│   ├── booking/                  ← Attendee checkout flow
│   ├── events/                   ← Public event discovery
│   ├── group-pay/                ← Group booking saga initiation
│   ├── split/                    ← Split payment claim links
│   ├── login/                    ← User authentication
│   ├── signup/                   ← User registration
│   └── profile/                  ← User ticket management
├── components/
│   ├── ui/                       ← Reusable Tailwind UI components
│   ├── layout/                   ← Application layouts and navigation
│   └── events/                   ← Event-specific components
├── lib/
│   ├── api-client.ts             ← Axios instance with interceptors
│   ├── api.ts                    ← API endpoint definitions
│   └── hooks.ts                  ← TanStack Query data fetching
├── store/
│   ├── authStore.ts              ← Zustand authentication state
│   └── themeStore.ts             ← Zustand theme management
├── hooks/
│   └── useApi.ts                 ← Custom React hooks
├── config/
│   └── constants.ts              ← Global configuration constants
├── types/                        ← TypeScript interfaces
└── utils/                        ← Helper functions

Key Engineering Decisions

1. UUID Primary Keys over Auto-Increment Auto-increment IDs are sequential and guessable. An attacker can enumerate /bookings/1, /bookings/2 etc. UUIDs are 122-bit random values. GenerationType.UUID generates IDs at the application layer, removing a DB round-trip. Future multi-region sharding has no ID collision risk.

2. Optimistic Locking over Pessimistic (SELECT FOR UPDATE) SELECT FOR UPDATE holds a row lock for the entire Stripe payment window (2-10 seconds). Under concurrent load this serializes all checkouts for popular seats. OCC allows full parallelism — conflicts are only detected at commit time, and the conflict rate is low when users choose different seats.

3. Redis as Soft Lock, Not Source of Truth Redis is fast but not ACID. The seat lock in Redis gives users immediate feedback without a DB write. If Redis is down, the system falls back to PostgreSQL OCC — Redis is intentionally outside the correctness critical path. Designs that make Redis a source of truth create split-brain risk.

4. Saga Pattern for Group Payments over Two-Phase Commit Two-phase commit across Stripe's API (an external system) is not feasible. The Saga uses compensating transactions: if any member's payment fails, rollbackSaga() reverses all DB changes and asynchronously refunds paid members via Stripe. Stripe refunds are async to prevent API timeouts from blocking the DB rollback.

5. Append-Only price_history over In-Place Updates Updating the price in-place destroys the audit trail. Every price change is a new row with ML feature values captured at decision time. This table doubles as the ML training dataset. The schema is already provisioned for algorithm_version tracking across model iterations.

6. @Formula for Derived Fields over Denormalized Counters Maintaining seats_sold_count as a denormalized counter requires UPDATE to the event row on every seat status change, adding write contention and drift risk. @Formula subqueries compute values on read. For the organizer dashboard (low frequency, no sub-millisecond SLA) this is the right trade-off.

7. @Scheduled Cleanup over Redis Keyspace Expiry Notifications Redis Keyspace Notifications must be explicitly enabled in Redis config and can be lost on connection failure. A simple Spring @Scheduled polling job is operationally simpler, predictable, and easy to monitor. The 60-second sweep window is a deliberate product decision — shorter would add load for no user-visible benefit.

8. Soft Delete on Users Hard-deleting a user row would orphan booking records (FK violation) or cascade-delete them (destroying financial audit trail). deleted_at IS NULL in application queries effectively hides deleted users while preserving all historical records for compliance.

9. JSONB for Venue Seat Map Theatre, stadium, and standing venue types have fundamentally different seat map structures. A polymorphic relational schema with many nullable columns is harder to evolve. JSONB allows each venue to define its own layout structure without migration. The constraint — no column-level querying — is acceptable since seat_map_config is always read/written as a whole blob.

10. Transactional Outbox for Reliable Side Effects Writing notificationService.send() after bookingRepository.save() is a common bug: if the JVM crashes between them, the booking commits but the notification is lost. The Outbox writes the notification record inside the same transaction as the booking. A background worker delivers it. The DB's durability guarantee replaces the JVM staying alive.


Performance Characteristics

Booking reservation latency: POST /bookings/reserve typically completes in under 100ms. Redis SET NX calls fail-fast on contested seats. The DB transaction writes only to bookings and seats — no complex joins.

Seat lock TTL: 10 minutes (Duration.ofMinutes(10) in RedisLockService). Key pattern: seat:lock:{seatId}.

Booking expiry sweep: Every 60 seconds. At most 60 seconds of inventory leakage after abandonment.

JWT expiry: 10 days (864000000ms in application.properties). Configurable.

Stripe webhook processing: Synchronous within the request thread. Payment, Booking, and all Seat records are committed atomically. Stripe's 72-hour retry window handles transient failures.

HikariCP connection pool: Spring Boot default (10 connections). Tune spring.datasource.hikari.maximum-pool-size for production database tier.


What I Learned

The hardest problem in this project was not writing code — it was identifying the exact point at which two concurrent requests could both believe a seat was available. My initial implementation used SELECT FOR UPDATE (pessimistic locking). It was correct, but under simulated concurrent load it became a bottleneck: every checkout for a popular event serialized behind the others waiting for Stripe payments to complete. A 2-10 second payment window × 100 concurrent users = 200-1000 seconds of total queue time.

The shift to a two-phase approach — Redis for fast early rejection, PostgreSQL OCC for correctness — came from recognizing that conflicts are rare when thousands of users choose different seats. OCC has no lock contention under normal operation. The deliberate Redis fallback was not an afterthought: Redis is cloud-hosted and can have cold starts, network partitions, and rate limits. Any design that makes Redis required for correctness is fragile by definition.

The Group Booking Saga took the longest to reason about correctly. A Saga is not ACID — it is ACD (Atomic, Consistent, Durable, but not Isolated from other transactions). While a group booking is PENDING_GROUP_PAYMENT, other users see those seats as unavailable (locked in Redis and DB), but the booking is not yet "durably confirmed." Designing the rollback — ensuring async Stripe refunds do not block the DB transaction that releases seat locks — required CompletableFuture.runAsync() to decouple the two concerns.

The Transactional Outbox changed how I think about reliability. Before this project, I would have written notificationService.send() after bookingRepository.save() — which is wrong. If the JVM crashes between those two lines, the booking commits but the notification is lost forever. Writing to the outbox inside the same transaction as the booking guarantees delivery by the database's durability guarantee, not by the JVM surviving.

Working with Spring Data JPA native queries taught me that the gap between JPQL and raw SQL is larger than it looks. JPQL interface projections with UNION ALL require snake_case column aliases matching the getter naming convention. CAST(uuid AS text) is required because the JDBC driver cannot map a PostgreSQL uuid column to Java String via reflection without explicit type coercion. These constraints only surface as runtime 400 errors — there is no compile-time safety.


License

MIT License

About

The Next.js client interface for RushTix, a high-performance event ticketing platform featuring real-time interactive seat maps, optimistic UI states, temporary checkout countdown timers, and secure multi-method Stripe checkout integration.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages