The Problem
Seat booking is a classic distributed systems problem: when two users click โBookโ on the same seat at the same millisecond, a naive implementation will issue two INSERT statements that both succeed โ resulting in a double-booking. This is not a hypothetical edge case; it is the default behaviour of any system that reads availability before writing a reservation.
The challenge is to guarantee exactly-once booking under arbitrary concurrency without serialising every request through a single lock โ which would destroy throughput. This system solves it at the database layer using a composite UNIQUE(event_id, seat_id) constraint on the tickets table, so the database itself becomes the arbiter.
System Architecture
Seven containerised services communicate over a shared Docker network. The API tier is stateless โ all shared state lives in Postgres and Redis โ so horizontal scaling is as simple as adding more API replicas behind a load balancer.
Request Flow
Every POST /api/v1/bookings call passes through six distinct layers before a 201 is returned โ and three additional async steps fire after the response is sent to the client.
db.flush() materialises the INSERT inside the open transaction. PostgreSQL enforces UNIQUE(event_id, seat_id) at flush time โ not commit time โ so conflicts surface immediately and the transaction is rolled back before any commit overhead.Concurrency Proof
The test fires 50 simultaneous booking requests for a single seat using asyncio.gather โ the closest approximation to a real thundering-herd scenario in a test suite.
The constraint that makes this work:
# backend/app/models/booking.py
class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True)
booking_id = Column(Integer, ForeignKey("bookings.id"))
event_id = Column(Integer, ForeignKey("events.id"))
seat_id = Column(Integer, ForeignKey("seats.id"))
__table_args__ = (
UniqueConstraint("event_id", "seat_id", name="_event_seat_uc"),
)The service layer catches the database integrity error and converts it to a 409:
# backend/app/services/booking_service.py
try:
db.flush() # triggers UNIQUE constraint check
db.commit()
return booking
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="Seat already booked")Performance & Caching
A Redis read-through cache sits in front of every GET /events/:id and event-listing query. On cache miss the result is stored with a 300-second TTL and atomic counters (Redis INCR) track hits, misses, and latency for the live /api/v1/metrics endpoint.
Cache instrumentation (simplified):
# backend/app/services/cache_service.py
async def get_event(event_id: int) -> dict | None:
t0 = time.monotonic()
data = await redis.get(f"event:{event_id}")
latency_ms = (time.monotonic() - t0) * 1000
if data:
await redis.incr("metrics:cache_hits")
await redis.incrbyfloat("metrics:cache_ms_total", latency_ms)
return json.loads(data)
await redis.incr("metrics:cache_misses")
return None # caller fetches from DB and populates cacheLLD / Data Model
Six core entities. The Ticket table is the junction between a Booking and an Event + Seat pair โ and it carries the uniqueness constraint that enforces single-occupancy.
Design Decisions
Every non-trivial architectural choice involved a trade-off. The diagram below documents five key decisions with the alternatives considered and the reasoning for the final choice.
Tech Stack
Scaling Roadmap
The current architecture handles ~37 RPS comfortably on a single API replica. Here is the path to 10ร, 100ร, and beyond without changing the core booking logic.









