Skip to content

Gabicle/ticket-booking-system

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ticket Booking System

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.

The problem

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.

Core design

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.

Architecture

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.

Running it locally

Requires Docker and Java 21.

cp .env.example .env
docker compose up -d
./mvnw spring-boot:run

The app starts on localhost:8080. Flyway applies the schema automatically on startup.

API

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.

Testing

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.

Known limitations

These are scoping decisions, not oversights.

  • No authentication. userId is trusted from the request body. A real system would derive it from a verified auth token, and the ownership check in confirmReservation would 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 AtomicReference for "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.

About

Concurrency-safe ticket booking system in Java. Lock-free CAS-based seat reservation, Postgres persistence, real-time seat map via SSE. Built to prove exactly one winner when many threads race for one seat.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages