🔗 Backend Repository: github.com/im-rk/rushtix-backend
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.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
- Architecture Overview
- Core Features
- Technical Deep Dives
- Database Schema
- API Reference
- Tech Stack
- Project Structure
- Local Development Setup
- Key Engineering Decisions
- Performance Characteristics
- What I Learned
- Roadmap
- License
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;
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.
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=1If 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
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.
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.
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.
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 ALLSQL 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.
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.
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
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.
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 --> [*]
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.
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
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.
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.
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
}
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 Users — deleted_at timestamp instead of hard delete. Preserves FK integrity of booking records and financial audit trail for compliance.
@Version on Concurrency-Sensitive Entities — seats, 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 Schemas — venues.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 Fields — Event.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.
| 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 |
| 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 |
| 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 |
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
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.
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.
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.
MIT License














