DEV Community

Cover image for Ticket Booking System Design: Prevent Double Booking at Scale
MANGESH MANDLIK
MANGESH MANDLIK

Posted on Originally published at seeitflow.com

Ticket Booking System Design: Prevent Double Booking at Scale

Ticket Booking System Design: How to Prevent Double Booking Under Extreme Demand

ticket booking final architecture

At 10:00 a.m., tickets for a popular concert go on sale. Thousands of customers refresh the same seat map. Two people click the same front-row seat almost simultaneously. Both see it as available. Both press Reserve.

Only one of them can own it.

That sounds like a small database problem, but a production ticket-booking system must preserve that guarantee while handling abandoned checkouts, delayed payments, expired holds, retries, lost messages, and sudden traffic spikes. Preventing the first double-booking race is relatively straightforward. Preventing an old expiry task from releasing a new customer's seat—or a late payment from confirming a seat already sold to someone else—is where the design gets interesting.

This article develops a practical architecture for assigned-seat booking, such as concert, cinema, or stadium ticketing. General-admission inventory is a related variant, but its capacity-counter model differs from the per-seat model used here.

1. Start with the invariants, not the microservices

The primary invariant is that one event-specific seat can have at most one live owner: either an active, valid hold or a confirmed booking. A seat must never be confirmed for two unrelated bookings.

A second invariant is that one logical customer request should not create multiple holds or bookings merely because the client retries. Atomicity and idempotency solve different problems. Atomicity stops competing buyers from acquiring the same seat. Idempotency stops a repeated request from being interpreted as a new purchase attempt.

Other requirements follow from those two:

  • Users can browse events, inspect seat maps, select one or more seats, pay, receive tickets, view their bookings, and cancel when policy permits.
  • A temporary hold must expire safely if checkout is abandoned.
  • Payment outcomes may be successful, failed, or unknown; a timeout does not prove failure.
  • Availability displays may be slightly stale, but reservation decisions must use authoritative state.
  • Under exceptional demand, the system should shed or pace excess work without weakening reservation correctness.
  • Booking, payment, and ticket-issuance discrepancies must be discoverable and recoverable.

We should also clarify whether the event uses assigned seats or general admission. For assigned seats, a customer purchases a particular event-specific seat, such as Section A, Row 3, Seat 12. For general admission, a customer purchases a quantity from a bounded capacity pool. Both must prevent overselling, but they need different inventory operations.

2. A useful first-cut architecture

A booking platform benefits from two deliberately different paths.

Browsing path: Client → API gateway → Availability service → cache or read model. This path returns seat maps quickly. The underlying view can be a cached snapshot, a read replica, or a materialized projection. Its freshness target is a product decision.

Reservation path: Client → admission control, where needed → Booking service → authoritative transactional database. This path creates durable holds using atomic operations. It never grants a reservation because the cache says a seat is available.

The Booking service coordinates with a separate payment integration. After a valid confirmation, an outbox relay publishes a booking.confirmed event. Ticket issuance and notifications consume that event asynchronously and idempotently. Expiry workers release abandoned holds, while reconciliation workers investigate unresolved or inconsistent states.

Not every deployment needs a separate service for each box. A smaller system can implement several responsibilities in one application while retaining the same transactional boundaries and invariants. The important separation is between displaying availability and authoritatively allocating inventory.

3. Model the event, seat, hold, booking, and payment separately

A physical seat belongs to a venue, but the sellable inventory unit belongs to a particular event. Seat A12 can be sold for Friday's concert and independently for Saturday's concert. Therefore, the unique sellable identity is event-scoped, conceptually (event_id, seat_id).

A minimal domain model looks like this:

Entity Responsibility
Event Schedule, sale window, venue, inventory mode
Event seat Event-specific sellable seat, pricing zone, authoritative allocation state
Hold Durable identity, owner, selected seats, creation/expiry time, lifecycle state and version
Booking Customer purchase intent and confirmation lifecycle, linked to hold and payment reference
Payment Provider-owned outcome and attempt/reference information
Ticket Usable admission entitlement issued for a confirmed booking

A hold is not just seat.status = HELD. It has an identity (hold_id) that survives retries and lets delayed workers prove which allocation they are acting on. A multi-seat hold also gives several seats one shared logical reservation identity.

A possible set of separate state machines is:

state machine

Hold:     ACTIVE → CONFIRMED
                 ↘ EXPIRED / RELEASED

Booking:  CREATED → PAYMENT_PENDING → CONFIRMED
                  ↘ FAILED / CANCELLED (as applicable)

Payment:  PENDING / UNKNOWN → SUCCEEDED / FAILED
Enter fullscreen mode Exit fullscreen mode

These are illustrative state names, not a requirement to use precisely these enums. Cancellation, refund, and ticket validity can each have additional independent states. A booking can be payment-pending while its hold is active; it can also be confirmed while a refund is pending. Combining everything into one booked boolean loses essential information.

4. Why a simple availability check double-books seats

Consider a naive implementation:

race condition

User A reads A12 → AVAILABLE
User B reads A12 → AVAILABLE
User A writes A12 → HELD
User B writes A12 → HELD
Enter fullscreen mode Exit fullscreen mode

Both requests made a decision from an earlier observation. The sequence read → decide → write is not atomic. Application-level checks do not fix that race when concurrent requests can interleave.

For a single assigned seat, a conditional update in an authoritative transactional database is a strong baseline:

UPDATE event_seats
SET state = 'HELD',
    hold_id = :hold_id,
    held_until = :expires_at,
    version = version + 1
WHERE event_id = :event_id
  AND seat_id = :seat_id
  AND state = 'AVAILABLE';
Enter fullscreen mode Exit fullscreen mode

atomic reservation

Exactly one competing update can transition a currently available row to held, assuming the database provides the expected atomic update semantics and all allocation writers use the same authoritative ownership rules. The winner sees one affected row; the loser sees zero and gets a seat-conflict response.

The seat update, creation of the durable hold, and the request's idempotency record should commit in one transaction. A database uniqueness constraint on (event_id, seat_id) identifies the sellable row; an allocation constraint or guarded transition must also prevent competing live owners if allocations are stored in a separate table. Otherwise a crash could leave an allocated seat without the durable identity needed to manage it safely.

An alternative is optimistic concurrency using WHERE version = :expected_version; another is SELECT ... FOR UPDATE with a short row lock. All can enforce the invariant. The choice depends on contention and the number of related rows being changed.

Never keep a database row lock open while the customer completes payment. The lock protects the short transaction that establishes the durable hold. The hold's persisted ownership and expiry—not a long-running transaction—protect the checkout window.

5. Reserving multiple seats must be all-or-none

Suppose a customer selects A1, A2, and A3. If A2 is already held, silently reserving A1 and A3 usually violates the purchase contract.

When all selected seats share one transactional database boundary, acquire or conditionally update them in a single transaction. Process seats in a deterministic order, such as ascending seat ID, verify that every requested seat was acquired, and commit only if the complete set succeeded. If any seat conflicts, roll back the entire attempt.

Deterministic acquisition order reduces deadlocks: otherwise one request could lock A1 then wait for A2 while another locks A2 then waits for A1. It does not eliminate every possible database deadlock, so transactions still need sensible error handling and bounded retries for genuinely transient failures.

Cross-partition bookings are harder. If seats are distributed across separate transactional shards, there is no automatic all-or-none commit. Options include constraining multi-seat selections to one partition or implementing coordinated temporary sub-holds with compensation and reconciliation. A saga can aim for an eventual all-or-none business outcome using provisional sub-holds and compensation, but it does not magically provide a single instantaneous atomic transaction across shards. During partial progress, provisional allocations must remain unavailable to competing buyers; compensation can fail and therefore requires durable retries and reconciliation.

6. Hold idempotency protects against lost responses

Imagine the booking database commits a hold, but the HTTP response is lost. The customer presses Reserve again. Without idempotency, the server may treat the retry as a second independent request.

A stable idempotency key should identify the logical hold request. Persist the key and a request fingerprint—such as event ID and sorted selected seat IDs—under a durable uniqueness constraint, alongside the hold creation transaction. On retry:

  1. If the key already exists with the same fingerprint, return the existing hold and its current state, including its original expiry.
  2. If the same key is reused for a different seat selection, reject the conflict.
  3. If the key is new, attempt the atomic reservation.

Concurrent requests using the same key must also be serialized or resolved through the database's uniqueness enforcement. Merely checking for an existing key before inserting one is another check-then-act race.

A retry must not silently extend the original hold's expiry or create a fresh hold unless the product explicitly defines and safely implements that separate operation.

7. Expiry is another concurrency problem

Temporary holds prevent other buyers from taking a seat while the first buyer pays. But abandoned holds cannot occupy inventory forever. A server-side expires_at establishes the business validity window; the countdown shown in the browser is only a display.

The subtle bug is a delayed expiry worker releasing the wrong customer's seat:

10:00  Hold H1 owns A12
10:05  H1 expires and A12 is released
10:06  Hold H2 acquires A12
10:07  A delayed expiry task for H1 runs
Enter fullscreen mode Exit fullscreen mode

If the task executes release A12 WHERE state = 'HELD', it releases H2, even though H2 is legitimate.

The release must identify the specific old hold:

UPDATE event_seats
SET state = 'AVAILABLE',
    hold_id = NULL,
    held_until = NULL,
    version = version + 1
WHERE event_id = :event_id
  AND seat_id = :seat_id
  AND hold_id = :expired_hold_id
  AND state = 'HELD'
  AND held_until <= :db_now;
Enter fullscreen mode Exit fullscreen mode

Here :db_now represents a consistently evaluated authoritative time, ideally derived from the database's time semantics. The seat release and transition of the corresponding hold to its terminal state must be coordinated transactionally when they share the same database. In particular, a worker must not mark the hold expired and commit before releasing its seats in an unrelated transaction: that can strand inventory or create inconsistent ownership. For a multi-seat hold, release the complete set according to its lifecycle contract, not as unrelated best-effort seat updates.

A delayed or duplicated expiry task now becomes a safe no-op if the seat has changed owners. Confirmation must use equally strict ownership, state, and expiry checks. Expiry and confirmation cannot independently succeed for the same hold.

Worker lag is not extra hold validity. Even if an expiry worker is down, reservation and confirmation operations must honor the persisted expiry semantics. A hold with expires_at <= authoritative_now is no longer eligible for ordinary confirmation, even if its stored state has not yet been cleaned up. A new reservation may need to reclaim an expired allocation transactionally rather than wait for a background scan, but it must do so using the same identity/version protections.

Expiry workers can use indexed (state, expires_at) scans, bounded batches, delayed messages, or durable workflow timers. A Redis sorted set can accelerate scheduling but should not become the only authority for seat ownership. Every mechanism needs idempotent, identity-scoped release. Monitor the oldest overdue hold and expiry-processing lag.

8. Payment success and seat ownership are separate facts

A successful payment is necessary for a paid booking, but payment success alone cannot grant a seat. The Booking service must still prove that the corresponding hold validly owns the requested inventory when confirmation commits.

The payment service can return three broad outcomes:

  • Known success: Attempt confirmation through the guarded authoritative transaction.
  • Known failure: Transition the booking appropriately and release the hold safely, provided no other payment attempt or outcome remains unresolved.
  • Unknown: Preserve the ambiguity and resolve it through supported provider status queries, authenticated webhooks, safe same-identity retries where explicitly supported, or reconciliation reports and escalation.

A timeout is not evidence of failure. Retrying a charge as a brand-new provider operation can create a duplicate payment. A PSP status-query endpoint is useful when available, but it is not required by the design: authenticated callbacks, settlement/reconciliation files, and provider escalation can be the supported resolution channels. If none gives definitive evidence by an operational deadline, move the case to an unresolved exception queue—not automatically to FAILED. Payment and booking each need durable, stable identities so their histories can be correlated.

The hardest case occurs when a payment succeeds after the hold expires. There are two materially different outcomes.

The seat is still unallocated. The product may choose to attempt an atomic reacquisition and confirm, but only if that reacquisition wins against all competing buyers. Alternatively, it can void or refund the payment. It cannot pretend the expired hold was still valid.

The seat has been reallocated. The original customer must not reclaim it. The booking becomes an exception requiring void/refund or another explicitly supported recovery policy, customer communication, and reconciliation. The newer customer's legitimate allocation remains untouched.

Extending a hold for a bounded period after payment initiation can reduce late-success incidents. It cannot guarantee their elimination: a provider outage or delayed reconciliation can outlast any finite timeout.

The critical implementation detail is that booking confirmation, hold transition, seat ownership finalization, and the durable event-to-publish should commit together when they share one authoritative transactional database. If the design splits these facts across stores, it needs explicit coordination, compensation, and reconciliation rather than an assumed atomic commit.

9. Booking confirmation is not the same as ticket issuance

Once payment and ownership checks succeed, the booking is committed as confirmed. The system must then issue one or more tickets and notify the customer. These downstream operations can fail independently.

A direct sequence—commit booking, then publish booking.confirmed—has a dual-write gap. The process may crash after committing but before publishing. The customer has a confirmed booking but never receives a ticket.

The transactional outbox closes this durability gap:

BEGIN TRANSACTION
  Verify valid hold ownership and payment-success evidence
  Mark booking CONFIRMED
  Finalize hold and seat allocation
  Insert booking.confirmed into outbox
COMMIT

Outbox relay → broker → ticket issuance / notification
Enter fullscreen mode Exit fullscreen mode

The relay can crash after publishing but before marking the outbox row delivered. It may therefore publish again. Outbox delivery is not automatically exactly-once. Consumers must be idempotent.

For ticket issuance, a stable business uniqueness constraint such as (booking_id, seat_id) for assigned seating—or (booking_id, ticket_sequence) for quantity-based tickets—prevents a repeated event from minting a second independent entitlement. The constraint must reflect the actual product's ticket identity and reissue rules.

If ticket generation fails, the already-confirmed paid booking should remain confirmed. Retry issuance, alert on missing entitlements, and provide a way for the customer to retrieve the ticket later. Notification failures should similarly not undo a valid purchase.

10. Cached seat maps can be wrong without corrupting bookings

Seat-map reads are frequent and often vastly outnumber successful reservations. Serving every refresh from the authoritative write database would create unnecessary contention during popular on-sales.

A cache or read model can be slightly stale in either direction:

Stale positive: The UI shows A12 as available, but someone else already holds it. The authoritative reservation rejects the attempt. This hurts conversion, not correctness.

Stale negative: The UI still shows A12 as held after its hold expired and released it. Another customer misses an opportunity until the view refreshes. Again, a display issue rather than a double-allocation.

Choose a freshness target based on product expectations. TTLs, event-driven invalidation, read-model updates, polling, and WebSocket/SSE pushes are implementation choices, not correctness mechanisms. Push updates make the interface feel live; they do not make a reservation authoritative.

If the cache fails, do not let every client request fall through to the booking database at once. Rate-limit, serve acceptable stale snapshots, reduce refresh frequency, or shed nonessential browsing traffic to protect the reservation path.

11. A virtual waiting room protects the bottleneck; it does not allocate seats

A blockbuster event might attract far more simultaneous buyers than the booking database or payment provider can safely handle. Scaling API servers alone does not increase the authoritative database's ability to commit conflicting seat transitions.

A virtual waiting room controls admission to the booking flow. It can issue scoped, time-limited admission tokens and pace entry at a measured rate based on the slowest critical dependency: booking-write throughput, payment processing capacity, and operational ability to expire and reconcile holds.

The waiting room is a fairness and load-management mechanism, not a reservation guarantee. A user admitted to checkout can still lose a seat to another user who wins the atomic allocation first. Queue position may be approximate; reconnects and token reuse need deliberate handling. A token must not grant extra admission or bypass booking idempotency.

A backend message queue is not the same thing as a user-visible waiting room. An internal queue buffers work between services; a waiting room deliberately gates users before they enter a scarce-inventory checkout experience. For smaller events, clear rejection and backoff may be simpler than operating a waiting room.

The system should distinguish temporarily unavailable (all seats held, some may return) from fully sold (all inventory confirmed or otherwise permanently unavailable). Otherwise it may unnecessarily stop admission while abandoned holds are about to expire.

12. Sharding by event ID does not solve one hot event

high demand event

Partitioning by event_id is useful because unrelated concerts or screenings no longer compete on the same database partition. But a single major concert still concentrates all its reservation traffic on one shard.

For that case, finer partitioning by event → section/block → seat can distribute independent seat operations. The trade-off is important: a multi-seat request crossing partitions no longer benefits from one local all-or-none transaction.

There is no universal rule that every system should shard down to individual seats. Choose partition boundaries using observed demand, transaction patterns, and the product's multi-seat requirements. Admission control may be sufficient before the complexity of cross-partition reservation is justified.

Multi-region ownership adds another layer of risk. Two regions must not independently grant the same seat during a network partition or failover. A single authoritative writer per seat/partition, with explicit leader fencing and safe failover, is a practical default. A naive active-active setup is not a harmless availability optimization; it can violate the core inventory invariant.

13. An end-to-end reservation and confirmation contract

It helps to connect the components into one precise workflow. The following is a logical sequence, not a claim that every deployment needs these exact APIs:

POST /events/{eventId}/holds
  Idempotency-Key: <stable request key>
  body: { seatIds: ["A1", "A2"] }
  → 201 { holdId, expiresAt, state: "ACTIVE" }
  → 409 if any seat cannot be acquired

POST /bookings/{bookingId}/payment
  → starts or retrieves the payment operation with its stable identity
  → { paymentState: "PENDING" | "UNKNOWN" | "SUCCEEDED" | "FAILED" }

GET /bookings/{bookingId}
  → returns the durable booking, hold, and ticket status
Enter fullscreen mode Exit fullscreen mode

The server must authenticate and authorize each request; a caller cannot select another user's hold simply by knowing its identifier. The seat IDs must belong to the requested event and sale window, prices and eligibility must be validated on the server, and any client-submitted price must not be trusted. The booking must bind the charged amount and currency to the server-approved order snapshot; if price or seat selection changes, the system must create an explicitly new authorized operation rather than silently reusing an old payment identity.

The hold creation transaction must make three things agree: the seats' allocation, the durable hold row, and the idempotency mapping. A second transaction later confirms the booking only when payment-success evidence and current, unexpired ownership are both valid. In the local-transaction baseline, that confirmation also finalizes seats and inserts the outbox event. No database transaction stays open across the external payment call.

For an asynchronous payment method, a PENDING response is a legitimate checkout result, not a failure. A client can poll its own booking-status API; the backend may learn the financial outcome through whichever provider channels actually exist. An API reporting our recorded booking state must not be confused with an API querying the provider's payment state.

14. Ticket validity and cancellation need their own rules

Issuing a ticket is not equivalent to authorizing entry. A scannable ticket should map to a durable entitlement with a unique identity and a defined validity lifecycle. A duplicate booking.confirmed delivery must retrieve or reuse that entitlement, not mint a second independently usable one. For a venue that checks tickets at the gate, the scan path must also enforce the product's admission rule—typically preventing a single-use entitlement from being accepted twice. Offline scanners and multi-gate operation require their own synchronization/conflict policy; the booking database alone cannot guarantee online, globally coordinated admission when scanners are disconnected.

Cancellation is another state transition, not a synonym for refund completion. Validate the cancellation window and whether the ticket has already been used; revoke or invalidate the entitlement where required, release inventory only when resale is permitted, and initiate a separate refund workflow. Refunds can be pending or ambiguous just like charges. A used ticket or an offline gate that has not received a revocation requires an explicit policy rather than a claim of instant global invalidation.

15. Failures that should be in the design review

A useful review asks what the system does after a partial success, not just whether the happy path works.

Failure Required safe behavior
Hold committed, response lost Retry with the same idempotency key returns the existing hold
Two users select one seat Only one authoritative transition wins
One seat conflicts in a multi-seat request Roll back all seats if the contract is all-or-none
Old expiry task runs after a new hold Identity-scoped release affects zero rows
Expiry worker stops Expiry remains a business rule; backlog drains in bounded batches after recovery
Payment times out Remain unresolved; never infer a decline or create a fresh independent charge
Payment succeeds after seat reallocation Preserve the new owner's seat; resolve the original payment via exception handling
Duplicate or out-of-order webhook Deduplicate and validate allowed state transitions
Booking commit succeeds, event publish fails Durable outbox row is retried by relay
Ticket consumer runs twice Business-key uniqueness prevents duplicate usable entitlements
Availability cache fails Degrade browsing without overwhelming the booking-write database
One hot event saturates a partition Apply event-specific admission control; consider finer partitioning
Region fails during reservation Fence the former writer; reconcile uncertain in-flight operations

Two additional operational cases deserve explicit policies.

Cancellation versus ticket use: A cancelled booking does not mean its refund has succeeded. If tickets are scanned for admission, cancellation must coordinate ticket revocation with the admission-validation system. A ticket already used before cancellation may require a policy exception or manual review; a simple database status update is insufficient.

Hold storms: A large number of users may acquire seats but abandon payment, making an event look sold out even though many seats will return. Track hold-to-confirmation conversion and active-hold pressure, not just raw reservation attempts.

16. Reconciliation is part of the architecture, not an incident script

Retries and asynchronous messaging do not resolve every ambiguity. Reconciliation continuously compares the booking system's durable records with payment evidence, inventory ownership, outbox delivery, and ticket issuance.

Examples include paid-but-pending bookings, successful payments attached to expired holds, confirmed bookings without the expected payment evidence, seats marked held without a matching active hold, confirmed bookings without tickets, unpublished outbox events, and cancellations with unresolved refunds.

Reconciliation jobs should be idempotent, bounded, observable, and safe to rerun. A provider status API, when offered, can help operational reconciliation; otherwise use the provider's supported reports, settlement evidence, callbacks, or escalation. Settlement may arrive substantially later than the checkout request, so unresolved financial cases need an age-based escalation policy without inventing a success/failure outcome. They should apply the same guarded state transitions as live requests, not bypass correctness checks in the name of repair. Financially sensitive or ambiguous cases should enter an exception workflow rather than be guessed into a terminal state.

Metrics that matter include reservation conflict rate per event/seat, active holds and expiry lag, payment-unknown age, booking-confirmation latency, reconciliation mismatch count and oldest unresolved case, outbox lag, ticket-issuance lag, waiting-room depth, and booking-database saturation.

17. The architectural trade-offs worth explaining

A strong design does not claim every mechanism is always necessary. It explains which risk justifies each mechanism.

Decision Sensible baseline What changes the choice
Seat concurrency Conditional atomic update Complex multi-row transactions may favor short row locks; versions help with other concurrent updates
Checkout Durable temporary hold Very fast purchase flows or ample general admission may use different sequencing
Multi-seat booking One local transaction Cross-shard selections need constraints or compensation
Expiry Indexed scan with safe conditional release Delay queues/timers can improve scheduling; none replace ownership checks
Availability Cache/read model with freshness target High-demand UX may justify invalidation or push updates
Traffic spikes Admission control sized to write-path capacity Ordinary demand may need only rate limits/rejections
Event partitioning Event-level isolation One hot event may need section-level partitioning
Confirmation events Transactional outbox Simpler direct publishing leaves a crash-induced dual-write gap
Regions One fenced write owner per partition Active-active requires explicit conflict-prevention architecture

The key is to distinguish optimizations from guarantees. A faster seat map improves UX; it does not prevent double booking. A longer hold improves checkout conversion; it does not guarantee payment resolution. A waiting room improves admission control; it does not guarantee inventory. An outbox improves durable event delivery; it does not eliminate duplicate processing.

18. How to explain this in a system design interview

Start with the two workloads: fast, potentially stale availability reads and correct, authoritative reservation writes. State the no-double-sell invariant and the separate idempotency requirement. Show the naive two-buyer race before introducing a conditional atomic transition.

Then build the design in the order complexity appears: a durable hold, all-or-none multi-seat acquisition, identity-scoped expiry, payment ambiguity and late success, booking confirmation, outbox-driven ticket issuance, and reconciliation. Only after that introduce the waiting room, hot-event partitioning, and regional failover.

A useful interviewer follow-up is: “The payment succeeded, but the hold expired and somebody else now owns the seat. What happens?” The answer should not be “increase the TTL.” The answer is that the old booking cannot be confirmed against someone else's allocation; the late payment needs a defined refund/void or alternate-fulfillment path, and the system must reconcile the financial outcome.

Another is: “An expiry job for yesterday's hold runs against a seat that is held again today.” If the release checks only state = HELD, the design is unsafe. If it checks the specific hold_id and authoritative expiry/version in the same guarded transaction, the stale job is harmless.

These scenarios distinguish an architecture that works in a diagram from one that remains correct when events arrive late, twice, or in the wrong order.

Closing thought

The hardest part of ticket booking is not rendering a seat map or processing a payment. It is maintaining a consistent account of who owns scarce inventory when the network, workers, customers, and payment providers do not move in lockstep.

Atomic reservation chooses one winner. Durable, idempotent holds remember that winner. Identity-scoped expiry prevents stale cleanup from stealing the next winner's seat. Payment coordination refuses to mistake uncertainty for failure. The outbox and reconciliation paths make the system recoverable after partial success.

That is the foundation of a ticket-booking system that can handle high demand without turning operational failures into double bookings.


Explore the complete visual and technical breakdown on SeeItFlow:

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

The line that hits hardest is the one about never keeping a row lock open while the customer completes payment. It looks like a small detail, but it is really the whole design: the hold you persist before payment becomes the source of truth, and the lock only exists to establish it. Payment is the slowest and least reliable leg of the flow, so anything that waits on it serially turns into a capacity problem exactly when the queue is longest. The unknown-outcome case you list is where this gets tested: a timeout on the charge does not prove failure, and reconciliation has to own that ambiguity before any expiry sweep touches the seat. It is the same discipline idempotency gives you in payment processing, just wearing a seat-map costume.