Engineering Deep-Dive

How zero-oversell actually works.

A modular monolith with seven bounded contexts, an atomic Redis inventory guard, and a strict layered architecture — chosen for specific engineering reasons, not defaults.

System architecture

Next.js talks to a Spring Boot backend over a stateless JWT-authenticated REST API. PostgreSQL is the source of truth; Redis holds inventory counters, distributed locks, and the JWT denylist; RabbitMQ offloads QR generation and email so the request path never blocks on them.

Next.js 16 Frontend (Vercel)
Spring Boot Backend (Railway)
Application layer
REST API 30 ep, 9 ctrl
Domain Services event·booking·
payment·inventory·
pricing·notif·user
Booking State Machine 11 states,
1 / request
Stripe checkout + webhooks
Persistence & messaging
Postgres source of truth
Redis inventory, locks,
JWT denylist
RabbitMQ async notif. +
QR gen.

Reservation flow — the concurrency-critical path

1Client ── POST /api/v1/bookings ──► BookingService
2BookingService ──► Redis  acquire per-user distributed lock
3BookingService ──► Redis  re-check availability (TOCTOU guard)
4BookingService ──► Redis  reserveSeat() — atomic Lua floor guard
Seats available
5Redis decremented, success
6Postgres atomic conditional UPDATE (available_count -= n)
7Postgres INSERT Booking (state=RESERVED, expires=+5m)
8Client ◄── 201 Created
Sold out
5Redis rejects (floor guard)
6Client ◄── 409 Conflict
9BookingService ──► Redis  release lock

Concurrency & data integrity

MechanismImplementation
Oversell preventionA Redis Lua script performs the availability check and decrement as one atomic operation — verified by 100 concurrent threads against a 50-seat tier: exactly 50 succeed, 0 oversold.
DB/cache consistencyThe PostgreSQL inventory mirror is written via a single atomic conditional UPDATE … WHERE available_count >= :qty, not a read-modify-write.
TOCTOU guardAvailability is checked once before acquiring the lock, then re-checked inside the lock before committing.
Optimistic locking@Version on Booking, TicketTier, and Event; ObjectOptimisticLockingFailureException maps to a clean 409, not a raw 500.
State machineAn 11-state Spring State Machine (one instance per request) governs the booking lifecycle, rejecting illegal transitions like checking in a cancelled booking.
IdempotencyStripe webhook delivery is deduplicated via a database UNIQUE constraint, not a race-prone existsBy… pre-check; booking creation requires an Idempotency-Key header.

Reliability

  • Multi-replica-safe scheduling — the reservation-expiry job acquires a distributed lock before running, safe across multiple backend instances.
  • Structured, traceable logging — a correlation ID propagates from the inbound HTTP request through every service log line via MDC.
  • Async offloading — QR generation and transactional emails dispatch through RabbitMQ (with dead-letter queues) instead of blocking the request thread.

Tech stack

Backend

Language / Runtime
Java 21 (LTS)
Framework
Spring Boot 3.5, Security, Data JPA
State management
Spring State Machine 4.0
Payments
Stripe Java SDK 23.3
Auth
JJWT 0.11
QR generation
ZXing 3.5
Coverage
JaCoCo (80% instruction gate)
Testing
JUnit 5 · Mockito · Testcontainers
Load testing
k6 (Grafana Labs)

Data & messaging

Primary datastore
PostgreSQL 17 · Flyway (14 migrations)
Cache / locking
Redis 7 (Lettuce client)
Async messaging
RabbitMQ 4

Infrastructure

Containerization
Docker, multi-stage build (~619MB)
Local orchestration
Docker Compose, 7 health-gated services
Backend hosting
Railway
Frontend hosting
Vercel

Frontend

Framework
Next.js 16 (App Router, TS strict)
UI runtime
React 19
Styling
Tailwind CSS 4
Server state
TanStack Query 5
Client state
Zustand 5
Forms
React Hook Form 7 · Zod 4
Payments
Stripe.js
Testing
Vitest

Domain & API surface

com.ticketing
event/Events, venues, categories, search
booking/Reservation lifecycle, state machine, tickets
payment/Stripe checkout, webhooks, refunds
inventory/Redis-backed atomic seat counters
pricing/Early-bird / group / surge pricing engine
notification/Async email + QR generation consumers
user/Authentication, JWT, roles
common/Security, exceptions, correlation IDs
MetricValue
REST endpoints30 across 9 controllers
Role-restricted endpoints (@PreAuthorize)18
Domain services21
API-boundary DTOs25 (JPA entities never exposed)
Database migrations14 (Flyway, immutable once applied)

Quality & CI/CD

MetricResult
Backend test suite228 / 228 passing
Instruction coverage (JaCoCo)83% (gate: 80% minimum)
Dedicated concurrency tests2 — a 100-thread Redis floor-guard proof, plus a full end-to-end reservation-path test (DB write included)
Integration testsReal PostgreSQL, Redis, and RabbitMQ via Testcontainers

GitHub Actions runs three independent jobs on every push/PR — Maven test + JaCoCo gate, Vitest + Next.js build, and a repo-hygiene check that fails the build if any .env, target/, or node_modules/ is ever tracked. Railway and Vercel each auto-deploy from main natively.

Performance

Four measurement campaigns, each answering a different question. Ceilings were found by ramping arrival rate rather than virtual users — a VU-based test sends fewer requests as the server slows, so it throttles its own input and never reaches a limit.

Capacity — where the service stops coping

Arrival rate ramped until p95 exceeded a 500ms budget, against a container held to the production envelope — 1 CPU, 512MB, prod profile, 5-connection Hikari pool — with the load generator isolated in its own CPU allocation.

ScenarioResult
Read-path ceiling, 1 replica660 req/s sustained before p95 crossed the budget — 107,839 requests, median 2.40ms, p95 511ms, 0 errors
Read-path ceiling, 2 replicas870 req/s — 133,383 requests, 0 errors. Scaling factor 1.32×
Latency under horizontal scaleAt 800 req/s two replicas held p95 9.0ms using only 55 of 4,000 virtual users — the same workload needed 952 VUs on one replica
Reliability across the exercise569,066 requests, 0 failed, 0 server errors across five valid runs
Bottleneck identifiedBoth replicas saturated their own 1.0-CPU budget (105%) while Postgres, Redis, the connection pool and the host all had headroom — the read path is CPU-bound, not database-bound

Correctness — the no-oversell invariant under contention

Behavioural guarantees rather than throughput figures — the results that matter most for a ticketing system, because a fast oversell is still an oversell.

ScenarioResult
Inventory-pressure burst (100 VUs vs. 8-seat tier)Up to 270 req/s, 99.9%+ correctly rejected with 409, zero oversell, zero 5xx
Booking creation under contention (20 VUs)p95 55.4ms, 0 server errors, floor guard held at exactly the tier's true capacity
100 threads vs. a 50-seat tier, full reservation pathExactly 50 succeed, every time — not just the Redis script, but the lock, the Lua guard and the conditional SQL UPDATE together

Production — the deployed service under sustained load

ScenarioResult
Capacity ramp — live Railway, 10→200 VUs over 16 min32,577 requests, 0 failed, 0 server errors, read-path p95 held at 394ms at peak (single replica, read-only journeys)

Scope

ResultEstablishesApplies to
CorrectnessThe inventory invariant holds under concurrent contentionAny deployment. The constrained environment raises collision probability, making it a stricter test than production.
Saturating resourceCPU binds before the connection pool, Redis or PostgresAny deployment — determined by the code and the pool size, not by the host.
Throughput660 req/s per 1-CPU instance, 870 across twoThis envelope. Real infrastructure shifts the figure in both directions: network latency lowers per-client rate, a networked database raises per-query cost.
Production ramp200 concurrent users sustained at sub-400ms p95The Railway single-replica deployment as configured.

Coverage. p99 was not captured — k6's default summary reports p90 and p95 only. The write path has not been load-tested above 20 VUs: it carries the distributed lock, the Lua guard and two inserts, and is where the connection pool should bind hardest.

Security posture

Access control uses two independent layers: RBAC decides whether a role may call an endpoint; object-level authorization separately decides whether the specific resource belongs to the caller.

ControlDefends against
Parameterized queries (JPA/JPQL only)SQL injection
React JSX auto-escaping · JSON-only API · CSPCross-site scripting (XSS)
Stateless JWT auth, no ambient cookiesCross-site request forgery (CSRF)
Role-based access control (RBAC)Unauthorized endpoint access — 18 of 30 endpoints gated
Object-level authorizationBroken object-level authorization / IDOR
JWT jti + Redis denylistSession/token hijacking after logout
Redis Lua rate limitingBrute force / credential stuffing (10/min/IP)
BCrypt password hashingCredential exposure on data breach
Environment-variable-driven configSecret leakage — no hardcoded secrets in source

Full 16-row audit trail in the project README.

Want to see it running?