A concurrency-safe seat reservation system, built to solve one problem properly: when 50,000 people try to buy the last 100 seats at the same time, exactly 100 succeed. No overselling, no double booking, no locks blocking unrelated requests.
This is a portfolio project. It's deliberately scoped small so the concurrency design stays the focus.
Ticket booking is a race condition wearing a business disguise. Two users can both see a seat as available, both click "reserve" at the same millisecond, and only one of them should win. Get this wrong and you oversell. Get it right with naive locking and your system falls over under real load.
This project solves it with lock-free compare-and-set (CAS) operations instead of synchronized blocks, so seats that nobody is contending for never pay a performance cost for seats that are.
Seat is an immutable record, not a mutable object. Every state change (reserve, book, release) returns a new Seat rather than mutating one in place. This is what makes CAS possible: AtomicReference.compareAndSet(expected, newValue) compares by reference identity, so it can only tell "did anything change since I read this?" if the old and new values are genuinely different objects.
Reservation logic is a read-build-CAS loop:
while (true) {
Seat current = seatRef.get();
Seat candidate = current.reserve(userId, now);
if (seatRef.compareAndSet(current, candidate)) {
return candidate; // won
}
// someone else changed it first, loop back and re-check
}No thread ever blocks waiting for a lock. A losing thread finds out immediately, on its next loop iteration, whether it lost because someone else grabbed the seat (in which case Seat.reserve() itself throws, since the precondition is gone) or because it just needs to retry against fresh state.
Seats live in a ConcurrentHashMap<String, AtomicReference<Seat>> inside Event. The map handles safe concurrent access to the collection itself (adding, looking up entries). The AtomicReference handles safe concurrent updates to one seat's value. Two different concurrency problems, two different tools, layered correctly.
Reservations expire lazily, not on a timer. A RESERVED seat holds a reservedAt timestamp. Any time reserveSeat or confirmReservation touches a seat, it checks whether 10 minutes have passed and reclaims it if so. There's no background sweep constantly scanning every seat in every event burning CPU on seats nobody's touching. The cost of checking expiry only exists exactly when something is already happening.
Virtual threads (Java 21) run the reservation workload. Thousands of concurrent reservation attempts don't need thousands of expensive OS threads.
Domain layer Seat, Event - business rules, validation, immutability
Service layer SeatReservationService - CAS logic, the actual concurrency work
EventService, EventRegistry - event creation, in-memory lookup
Persistence SeatEntity, EventEntity - JPA, mapped separately from domain models
Postgres via Flyway migrations
API layer REST controllers + SSE stream - HTTP boundary, exception -> status code mapping
The domain model and the persistence model are intentionally separate classes. Seat has no idea a database exists. SeatEntity has no idea what a reservation is. A small mapper translates between them. This keeps the concurrency-critical domain code free of JPA's mutable, reflection-driven object lifecycle.
Postgres is for durability. In-memory CAS is for concurrency correctness. These are different jobs. On every successful reservation or confirmation, the new state is written through to Postgres synchronously, so a paid booking is never just sitting in memory with no durable record. Failed attempts never touch the database at all. Recall from the seat map: reads from the database are eventually consistent with reality by a few milliseconds; the actual seat allocation is strongly consistent, always, because that's the one guarantee that actually matters.
Requires Docker and Java 21.
cp .env.example .env
docker compose up -d
./mvnw spring-boot:runThe app starts on localhost:8080. Flyway applies the schema automatically on startup.
Create an event
POST /events
{ "id": "evt-1", "title": "Eras Tour", "venue": "MetLife Stadium", "numberOfSeats": 100 }
Reserve a seat
POST /events/{eventId}/seats/{seatId}/reserve
{ "userId": "user-alice" }
Returns 409 Conflict if the seat is already taken.
Confirm a reservation
POST /events/{eventId}/seats/{seatId}/confirm
{ "userId": "user-alice" }
Only succeeds if userId matches who reserved the seat and the 10 minute window hasn't expired.
View the live seat map
GET /events/{eventId}/seats
Stream live updates
GET /events/{eventId}/stream
Server-Sent Events. Pushes the full seat map to connected clients whenever any seat changes, no polling.
Standard unit tests cover Seat and Event's validation and state transitions. The one worth actually reading is SeatReservationServiceTest's concurrency test: 100 virtual threads, gated by a CountDownLatch so they all fire at the same instant, all racing for one seat. The test asserts exactly one wins and 99 get rejected. This is the test that actually proves the CAS logic works, not just that it compiles.
EventServiceIntegrationTest runs against a real Postgres instance and verifies the event and seat rows are actually persisted correctly.
These are scoping decisions, not oversights.
- No authentication.
userIdis trusted from the request body. A real system would derive it from a verified auth token, and the ownership check inconfirmReservationwould work exactly the same way against that trusted value. Auth is a large enough feature to be its own project. - Single instance only. The concurrency guarantees hold for one running JVM. Two instances behind a load balancer would each have their own in-memory
AtomicReferencefor "the same" seat and could double-book. Fixing this means moving concurrency control into the database or a distributed lock, which is a different, harder problem than the one this project demonstrates. - No pricing, no refunds, no seat inventory changes after event creation. All orthogonal to concurrency correctness, cut to keep the domain model focused.
- 10 minute reservation TTL is hardcoded, not configurable per event.
- The seat map broadcast over SSE sends the full seat list on every change, not a diff. Fine at this scale, would need to change for a venue with tens of thousands of seats.