Seat Reservation — Pessimistic Concurrency
Serializing concurrent seat reservations with pessimistic locking, MySQL advisory locks via Prisma, and lock timeout handling.
A concert venue where customers reserve specific seats. Multiple customers attempt to reserve the same seat concurrently. This sample demonstrates pessimistic concurrency with advisory locks — the right strategy when command handlers involve expensive validation and wasted retries are costly.
Full source: samples/sample-seat-reservation — clone and run locally with
npm start.
Stack: Prisma + MySQL + Testcontainers
Why Pessimistic
The reservation handler is expensive: it checks seat availability, validates customer eligibility, applies adjacency rules, and computes pricing. With optimistic concurrency, two customers reserving the same seat would both run this full validation pipeline, only for one to fail at save time and retry — wasting all that computation.
With pessimistic locking, the second customer waits briefly for the lock, then runs validation against the current state. No wasted work.
The Aggregate
export const Venue = defineAggregate<VenueTypes>({
initialState: { seats: {} },
commands: {
CreateVenue: (command) => ({
name: "VenueCreated",
payload: {
venueId: command.targetAggregateId,
seatIds: command.payload.seatIds,
},
}),
ReserveSeat: (command, state, infrastructure) => {
const { seatId, customerId } = command.payload;
const seat = state.seats[seatId];
if (!seat) {
return {
name: "ReservationRejected",
payload: { seatId, customerId, reason: "seat_not_found" },
};
}
if (seat.status !== "available") {
return {
name: "ReservationRejected",
payload: { seatId, customerId, reason: `seat_${seat.status}` },
};
}
// Expensive validation: adjacency rules, pricing, eligibility...
const _now = infrastructure.clock.now();
return { name: "SeatReserved", payload: { seatId, customerId } };
},
ReleaseSeat: (command) => ({
name: "SeatReleased",
payload: { seatId: command.payload.seatId },
}),
},
apply: {
VenueCreated: (payload) => {
const seats: Record<string, SeatInfo> = {};
for (const id of payload.seatIds) seats[id] = { status: "available" };
return { seats };
},
SeatReserved: (payload, state) => ({
seats: {
...state.seats,
[payload.seatId]: { status: "reserved", heldBy: payload.customerId },
},
}),
SeatReleased: (payload, state) => ({
seats: { ...state.seats, [payload.seatId]: { status: "available" } },
}),
ReservationRejected: (_payload, state) => state,
},
});The ReserveSeat handler uses infrastructure.clock — a real-world handler would call external services for pricing and eligibility.
Domain Configuration
const locker = new PrismaAdvisoryLocker(prisma, "mysql");
const domain = await configureDomain<VenueInfrastructure>({
writeModel: { aggregates: { Venue } },
readModel: { projections: {} },
infrastructure: {
aggregatePersistence: () => prismaInfra.eventSourcedPersistence,
unitOfWorkFactory: () => prismaInfra.unitOfWorkFactory,
aggregateConcurrency: {
strategy: "pessimistic",
locker,
lockTimeoutMs: 5000,
},
provideInfrastructure: () => ({ clock: new SystemClock() }),
},
});PrismaAdvisoryLocker uses MySQL's GET_LOCK('Venue:concert-hall', 5) to serialize access. The lock key is derived from the aggregate name and ID. lockTimeoutMs: 5000 means if a customer can't acquire the lock within 5 seconds, a LockTimeoutError is thrown instead of waiting indefinitely.
What Happens at Runtime
1. Create venue with seats A1, A2, A3
2. 3 customers fire ReserveSeat for A1 concurrently (Promise.all)
3. Customer 1 acquires GET_LOCK('Venue:concert-hall', 5) → runs handler → SeatReserved
4. Customer 2 acquires lock (Customer 1 released it) → runs handler → seat is now reserved → ReservationRejected
5. Customer 3 acquires lock → same → ReservationRejected
6. Result: 1 reserved, 2 rejected — no retries, no wasted validationEach customer runs the handler exactly once. The lock ensures they see the correct state — no stale reads.
Running the Sample
cd samples/sample-seat-reservation && yarn startRequires Docker running (Testcontainers spins up MySQL automatically).
Key Patterns Demonstrated
- Pessimistic concurrency with
PrismaAdvisoryLocker— serialized access via MySQLGET_LOCK - Lock timeout —
lockTimeoutMs: 5000prevents indefinite blocking; throwsLockTimeoutError - Infrastructure injection —
Clockpassed to command handlers for time-dependent validation - Rejection events —
ReservationRejectedrecords why a reservation failed (seat not found, already taken) - Testcontainers — real MySQL database, no external setup
- Prisma adapter —
createPrismaPersistence+PrismaAdvisoryLocker