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.
payment·inventory·
pricing·notif·user
1 / request
JWT denylist
QR gen.
Reservation flow — the concurrency-critical path
Concurrency & data integrity
| Mechanism | Implementation |
|---|---|
| Oversell prevention | A 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 consistency | The PostgreSQL inventory mirror is written via a single atomic conditional
UPDATE … WHERE available_count >= :qty, not a read-modify-write. |
| TOCTOU guard | Availability 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 machine | An 11-state Spring State Machine (one instance per request) governs the booking lifecycle, rejecting illegal transitions like checking in a cancelled booking. |
| Idempotency | Stripe 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
| Metric | Value |
|---|---|
| REST endpoints | 30 across 9 controllers |
| Role-restricted endpoints (@PreAuthorize) | 18 |
| Domain services | 21 |
| API-boundary DTOs | 25 (JPA entities never exposed) |
| Database migrations | 14 (Flyway, immutable once applied) |
Quality & CI/CD
| Metric | Result |
|---|---|
| Backend test suite | 228 / 228 passing |
| Instruction coverage (JaCoCo) | 83% (gate: 80% minimum) |
| Dedicated concurrency tests | 2 — a 100-thread Redis floor-guard proof, plus a full end-to-end reservation-path test (DB write included) |
| Integration tests | Real 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.
| Scenario | Result |
|---|---|
| Read-path ceiling, 1 replica | 660 req/s sustained before p95 crossed the budget — 107,839 requests, median 2.40ms, p95 511ms, 0 errors |
| Read-path ceiling, 2 replicas | 870 req/s — 133,383 requests, 0 errors. Scaling factor 1.32× |
| Latency under horizontal scale | At 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 exercise | 569,066 requests, 0 failed, 0 server errors across five valid runs |
| Bottleneck identified | Both 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.
| Scenario | Result |
|---|---|
| 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 path | Exactly 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
| Scenario | Result |
|---|---|
| Capacity ramp — live Railway, 10→200 VUs over 16 min | 32,577 requests, 0 failed, 0 server errors, read-path p95 held at 394ms at peak (single replica, read-only journeys) |
Scope
| Result | Establishes | Applies to |
|---|---|---|
| Correctness | The inventory invariant holds under concurrent contention | Any deployment. The constrained environment raises collision probability, making it a stricter test than production. |
| Saturating resource | CPU binds before the connection pool, Redis or Postgres | Any deployment — determined by the code and the pool size, not by the host. |
| Throughput | 660 req/s per 1-CPU instance, 870 across two | This envelope. Real infrastructure shifts the figure in both directions: network latency lowers per-client rate, a networked database raises per-query cost. |
| Production ramp | 200 concurrent users sustained at sub-400ms p95 | The 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.
| Control | Defends against |
|---|---|
| Parameterized queries (JPA/JPQL only) | SQL injection |
| React JSX auto-escaping · JSON-only API · CSP | Cross-site scripting (XSS) |
| Stateless JWT auth, no ambient cookies | Cross-site request forgery (CSRF) |
| Role-based access control (RBAC) | Unauthorized endpoint access — 18 of 30 endpoints gated |
| Object-level authorization | Broken object-level authorization / IDOR |
JWT jti + Redis denylist | Session/token hijacking after logout |
| Redis Lua rate limiting | Brute force / credential stuffing (10/min/IP) |
| BCrypt password hashing | Credential exposure on data breach |
| Environment-variable-driven config | Secret leakage — no hardcoded secrets in source |
Full 16-row audit trail in the project README.