Design complete systems / CHAPTER 62
Design Ticketmaster-style seat booking
Let many buyers browse seat maps while preventing conflicting seat claims and retaining payment results needed after a timeout.
Builds on SQL versus NoSQL data stores, Strong versus eventual consistency, Synchronous versus asynchronous communication.
- Model the difference between an available seat, a temporary hold and a confirmed booking.
- Claim all requested seats in one transaction without giving the same event-seat to competing active buyers.
- Recover lost replies and unknown payments using the original provider operation ID and saved booking state.
01Functional requirements
Browsing a seat map and owning a seat are different actions. The map can be a little old because many people read it at once; a hold must check current inventory before promising seats. Payment adds a second source of uncertainty because a provider can charge successfully even when its reply is lost.
FR1: Event discovery. Search events by city, date, and performer and retrieve the selected event’s details.
FR2: Catalog administration. Let authorized owners manage event and venue records, including the seat layout used for each event.
FR3: Seat browsing. Show an approximate availability map with its timestamp or version. Seeing an available seat does not reserve it or authorize checkout; a hold request must still claim it.
FR4: Multi-seat holds. Acquire all requested seats within one event or acquire none. Return a stable hold identity and expiry; repeating the same request must recover that result rather than extend the deadline.
FR5: Checkout. Start a recoverable booking and payment process from a valid hold. Use the price saved by the server and retain operation IDs so retries recover the same booking and payment attempt.
FR6: Booking retrieval. Let the authenticated buyer retrieve the current booking and honest pending, confirmed, cancelled, or refund-required state after a timeout or reconnect.
FR7: Cancellation and refunds. Apply an explicit policy to cancellation of a confirmed booking, persist refund intent, and release inventory only according to the chosen refund and resale rules.
Out of scope
- Resale and dynamic pricing.
- Atomic purchases spanning several events. This exercise’s all-or-nothing seat group belongs to one event.
Example. Mina requests A7 and A8 for E1 while Leo requests A7. Both may have seen green seats, but only one valid claim of A7 can commit. We must also recover a lost hold response and a payment that succeeds after its shopping hold expires.
02Nonfunctional requirements
These are independently chosen exercise assumptions to validate, not measured production guarantees.
NFR1: Hold-creation latency. At least 95% of measured hold requests must finish within 500 ms under the admitted, tested load. Include the complete claim transaction and saved retry result; measuring only the cached map response would miss the important work.
NFR2: Hold duration. The shopping hold expires two minutes after creation, at the original deadline saved by the inventory service. Matching retries return that same expiry. Delayed cleanup does not extend it; later claims check fresh server-controlled time against the saved deadline.
NFR3: Browse capacity. Plan for 100,000 browse requests/second at the assumed launch peak. Serve approximate maps separately from the writer and test response payload and cache capacity.
NFR4: Admitted claim capacity. Admit at most the exercise’s tested 2,000 hold attempts/second to the inventory authority. Retest the hot-seat distribution and contention; a waiting room bounds work but cannot establish seat ownership.
NFR5: Exclusive seat ownership. A seat for one event must belong to at most one active hold or booking at a time, and at most one confirmed buyer. The claim transaction checks current state and time, then commits all requested seats together or none. Converting a hold to its booking preserves that same exclusive ownership.
NFR6: Uncertain-payment recovery. Every uncertain payment must retain the original provider operation ID and saved progress so the service can query or retry that same operation. When it is unknown whether the provider collected money, do not resell the booking’s payment-pending seats. If a late successful payment has no valid inventory claim, persist a corrective action such as a refund instead of assigning seats already owned elsewhere.
03Back-of-the-envelope Estimates
Separate people looking at a map from people trying to change seat ownership. Both matter, but they stress different code and storage.
- Browse delivery. At 100,000 responses/second and 50 KB per map, payload is
100,000 ×50,000 =5 GB/secondbefore transport overhead. Serve approximate maps through caches/CDN or smaller updates, away from the seat writer. - Admitted holds. Assume testing shows the inventory database safely handles 2,000 hold attempts/second for the intended seat-contention pattern. A waiting room must limit work reaching that writer. Adding web processes cannot make the same
A7seat independently claimable. - Waiting time. With 100,000 attempts ahead and 2,000 admitted/second, the idealized wait is at least
100,000 ÷2,000 =50 seconds. Abandonment, stalled attempts and fairness can change it; do not promise an exact countdown from this division. - Seat records. A 50,000-seat event at 200 bytes per state record needs
50,000 ×200 =10 MBraw. The launch problem is many buyers competing for that small table, not merely its size. A million events would create a separate catalog-capacity problem. - Rows touched. At 2,000 holds/second with two seats each, the writer checks about 4,000 seat rows/second before conflicts and retries. Everyone choosing the same row is harder than evenly spread requests, so test that hot pattern.
- Pending payments. Approximate simultaneous payment work as
admitted checkouts/second ×mean provider delay in seconds. Size workers and reconciliation queues for those unresolved operations. An unlimited queue creates more waiting, not more than 50,000 seats.
04API Design and Retry Contracts
Mina selects seats A7 and A8 for event E1. Her app sends a key for that one intended hold; H1 is the hold record returned after both claims are saved.
POST /events/E1/holds
Idempotency-Key: mina-hold-17
Content-Type: application/json
{"seatIds":["A7","A8"]}
{"holdId":"H1","seatIds":["A7","A8"],"expiresAt":"2026-10-24T13:02:00Z","state":"held"}
- Discover and browse.
GET /events?city=…&date=…&performer=…searches the catalog.GET /events/E1/seatsreturns a map with a timestamp/version. It helps choose seats but grants no ownership. - Claim a group.
POST /events/E1/holdstakes seat IDs and an idempotency key. The server derives the buyer from login. Return201with the complete group and original expiry, or 409 if the full group cannot be acquired. The example starts at 13:00 UTC and expires at 13:02 UTC. - Repeat the same intent. Save the buyer, key, original seat parameters and hold result together. A matching retry returns
H1. Reusing the key with different seats returns a conflict rather than silently changing or extendingH1. - Start and recover checkout.
POST /holds/H1/checkoutuses another stable operation key and returns bookingB1with pending or confirmed state.GET /bookings/B1tells its authenticated buyer the current outcome after a timeout. Unknown payment is not a confirmed failure. - Administer and cancel. Authorized owners use
POST /eventsor version-checkedPATCH /events/E1for the catalog.POST /bookings/B1/cancelchecks buyer ownership, current version and the stable cancellation key. Return honest refund/pending state; accepting cancellation does not prove a provider completed a refund.
05Data Model and Access Paths
The inventory database is responsible for accepted seat writes. A cached map is only a copy for browsing. Identify a seat by event and seat ID, because A7 at tomorrow’s show is different from A7 tonight.
| Record / key | Fields and access pattern | Responsibility |
|---|---|---|
| Event / venue | Performer, city/date, venue and seat layout | Catalog search and static map |
Event seat (eventId,seatId) |
State, holdId, expiresAt, bookingId, version |
Authoritative inventory |
Hold holdId |
Account, event, seat list, expiry, state | Fixed-duration temporary claim |
Booking bookingId |
Hold, authoritative price/currency, state | Product purchase workflow |
Payment operationId |
Booking, provider reference, state | Correlate external outcomes |
| Idempotency / outbox / inbox | Request hash/result; transition/event IDs | Safe retries and durable work |
- The hold handler changes all requested event-seat rows and creates
H1with one fixed deadline. Its request-result record lets a lost reply recoverH1. - The booking handler creates
B1, saves the server’s price/currency and tracks whether checkout is pending, confirmed or needs cancellation/refund. The browser cannot choose a charge amount. - The payment worker uses a saved operation ID when asking the provider for authorization, capture or status. Authorization approves/reserves the amount; capture collects the money when the method supports these separate steps.
- An outbox row retains work to send after a database commit, such as capturing an already reserved booking. A relay reads and retries that row if a process crashes before sending.
- An inbox row saves a received provider event ID and payload. Processing checks that ID and booking state so a repeated callback does not repeat the purchase or move a confirmed booking backward.
Keep the short shopping hold separate from B1’s payment-pending inventory reservation. The latter must remain protected while a possibly successful capture is unresolved; H1’s old two-minute expiry cannot silently release it.
06Baseline: Acquire seats in one transaction
First grant one complete seat group safely, using one inventory service and transactional database. Mina and Leo may both have seen A7 as available in the map.
- Mina’s hold handler verifies her account and reads her idempotency key. If it already has a result, compare the saved seat parameters and return that same
H1result, or conflict if the parameters changed. - For a new request, begin a transaction and lock
A7thenA8in a consistent seat order. Other transactions choosing overlapping seats must wait or conflict rather than independently claim old state. - After obtaining locks, read fresh server-controlled time and each row’s current owner/state. A seat qualifies only if available or covered by an eligible expired shopping hold. A payment-pending booking reservation is not such an expired hold.
- If either seat fails, roll back every tentative claim. Mina receives neither seat, preserving the all-or-nothing contract.
- Otherwise save
H1, both seat claims, the two-minute deadline and Mina’s key-to-result record in the same transaction. Commit before returning 201. - Leo’s
A7transaction then rechecks the row. If Mina’sH1is still valid, it returns409instead of a second hold.
The time check must occur after a lock wait. In PostgreSQL, now()/CURRENT_TIMESTAMP retain the transaction-start time; a transaction waiting past a deadline could therefore test an old time. Use a fresh value such as clock_timestamp() after locking, or an equivalent decision-time mechanism.
A cleanup worker can remove expired holds and refresh maps later. It does not extend validity: every claim checks the saved deadline itself. Likewise, a conditional update can replace explicit locks only if a transaction still protects the complete seat group. Never keep seat locks while waiting for a payment-provider network call.
BUILD THE DESIGN, ONE CHANGE AT A TIME
Watch the architecture evolve.
Advance through the stages in this same diagram. Existing components keep their places; additions fill the available space, then add rows. Highlights show what changed from the previous stage, and numbered arrows match the connection key below.
Stage 1 of 3
Start here. First prove that Mina’s A7/A8 request and Leo’s A7 request cannot commit conflicting or partial groups.
Atomic seat holds
Follow the numbered arrows
- Buyer → Catalog + map sourcebrowse event and approximate seats
- Buyer → Inventory serviceclaim A7/A8 with same request key
- Inventory service → Inventory databasefresh expiry; claim complete set or none
Trace the diagram in words
- Mina’s app selects seats from an approximate map.
- The writer locks and checks the complete selected group in a consistent order.
- It commits H1 and the saved retry result; a matching retry returns the same deadline.
- The hold handler uses event-seat IDs and a short database transaction.
- It commits all claims, the fixed hold and saved request result together.
- After a lock wait it checks expiry with fresh server-controlled time.
The cost: Competing buyers still wait or receive conflict. More web processes cannot eliminate competition for the same seat, even when the table is only 10 MB.
Why change it? The launch has 100,000 browse requests/second but only 2,000 tested hold attempts/second at the writer.
Read split and admission
Follow the numbered arrows
- Inventory service → Inventory databasefresh expiry; claim complete set or none
- Buyer → Browse deliveryhigh-volume advisory browsing
- Browse delivery → Catalog + map sourcemiss/revalidate public view
- Buyer → Waiting roomauthenticated claim admission
- Waiting room → Event routingbounded admitted hold attempt
- Event routing → Inventory serviceroute event E1 to its owner
Trace the diagram in words
- Map-serving code returns a versioned approximate view from its read path.
- The waiting room limits attempts before they reach the writer.
- The event owner performs the unchanged complete-group claim check.
- Cached catalog and map-serving code handle large read payloads away from inventory writes.
- The waiting room admits only tested claim work.
- Event routing sends unrelated shows to separate owners; a token still grants only permission to try.
Inspect the connection changes
- Now: Buyer → Browse delivery: high-volume advisory browsing
- Now: Browse delivery → Catalog + map source: miss/revalidate public view
- Now: Buyer → Waiting room: authenticated claim admission
- Now: Waiting room → Event routing: bounded admitted hold attempt
- Now: Event routing → Inventory service: route event E1 to its owner
- Previously: Buyer → Catalog + map source: browse event and approximate seats
- Previously: Buyer → Inventory service: claim A7/A8 with same request key
All other connections are retained. Response paths are implicit unless drawn.
The cost: Cached maps can lead to later conflicts. Waiting-room fairness, expiry and busy-event routing need explicit policies.
Why change it? Provider calls can succeed after a lost reply or outlast the short shopping hold. The purchase needs retained states for both outcomes.
Recoverable payment workflow
Follow the numbered arrows
- Inventory service → Inventory databasefresh expiry; claim complete set or none
- Buyer → Browse deliveryhigh-volume advisory browsing
- Browse delivery → Catalog + map sourcemiss/revalidate public view
- Buyer → Waiting roomauthenticated claim admission
- Waiting room → Event routingbounded admitted hold attempt
- Event routing → Inventory serviceroute event E1 to its owner
- Inventory service → Booking workflowcheckout after durable pending intent
- Booking workflow → Inventory databasevalidate hold; commit reservation/state
- Booking workflow → Payment providersame authorization/capture operation
- Payment provider → Booking workflowsigned result or queried outcome
Trace the diagram in words
- The booking handler rechecks H1 before converting its seats to B1’s pending reservation.
- The worker sends or queries the same provider operation outside database locks.
- Established capture success confirms the booking. Definitive failure allows cancellation and release. If money was collected after the hold expired and another buyer owns the seats, save refund-required instead of changing that buyer’s claim.
- The booking service saves stable payment IDs, pending provider work and received callback IDs.
- Workers call the provider outside seat locks.
- A valid hold becomes an exclusive payment-pending reservation; unknown capture keeps it reserved, while late money without inventory enters refund recovery.
Inspect the connection changes
- Now: Inventory service → Booking workflow: checkout after durable pending intent
- Now: Booking workflow → Inventory database: validate hold; commit reservation/state
- Now: Booking workflow → Payment provider: same authorization/capture operation
- Now: Payment provider → Booking workflow: signed result or queried outcome
All other connections are retained. Response paths are implicit unless drawn.
The cost: The extra states and unresolved reservations cost operating work and temporarily unavailable seats. Payment-method capabilities and refund/resale policy must be explicit.
07Evolution: Coordinate payment as a recoverable workflow
First measure whether a larger database improves memory or disk throughput. It cannot remove competition for the same A7 row. Separate reads, admitted claims and provider work as they grow.
Protect the inventory writer
- Catalog and map-serving code use caches or read copies to handle browse traffic. A stale green seat may cause a later hold conflict; it cannot create a valid claim.
- A waiting room sends only the tested attempt rate to the hold handler. Bind its tokens to accounts/sessions and expire them; admission is permission to try, not a seat reservation.
- Assign different events to separate inventory owners so unrelated shows do not share every write limit. One exceptionally hot event can still contend on its own seats. Do not use a lagging read replica as the final claim check.
Recover checkout with authorization then capture, when supported
- While
H1is valid, the checkout handler saves pending bookingB1and a stable payment operation. This saves work to resume; it does not extendH1automatically. - A payment worker asks the provider to authorize using that operation ID, outside seat locks. A retry keeps the same provider identity so it can recover an uncertain earlier request.
- After authorization, the booking handler locks/checks
H1again with fresh time. If it is still valid and belongs to the buyer, atomically convert its seats toB1’s exclusive payment-pending reservation.H1’s short deadline no longer governs those rows. - Commit a pending capture task in the outbox. The worker sends capture with the stable payment identity and resolves its result through the provider’s supported retry/status rules.
- After established capture success, confirm
B1. After definitive capture failure, record cancellation and releaseB1’s reservation. If capture is unknown, keep the reservation while checking the provider; a timeout cannot prove that money did not move. - If the final hold check fails, void the authorization rather than capture unavailable seats. Some methods instead collect money immediately or asynchronously before a valid reservation is secured. A late success after
H1expired and seats were validly claimed elsewhere needs a saved refund/compensation process, not another seat owner.
This is a multi-step purchase across the inventory database and payment provider, often called a saga. The useful mechanism is saved states, stable external operations and corrective actions, not the label. Alert on old unresolved payments and define escalation so uncertainty does not disappear into an invisible queue.
Inventory ownership across checkout states
Follow the numbered arrows
- Available → Shopping hold H1atomic complete seat claim
- Shopping hold H1 → B1 payment-pendingauthorize; recheck; commit
- B1 payment-pending → Confirmed B1capture confirmed
- B1 payment-pending → Cancelled / releasedcapture definitively failed
- Shopping hold H1 → Availableexpiry on claim path
- Shopping hold H1 → Refund-requiredlate captured payment
Trace the diagram in words
- The hold transaction temporarily claims the complete group as H1.
- After authorization, the booking handler rechecks H1 and converts ownership to pending B1.
- Established capture success confirms B1; definitive failure can release it. A late charge made without inventory instead needs refund recovery.
08Failure and Recovery Scenarios
Follow the saved records through these separate failures:
- Lost hold reply. Mina repeats the same key and seat set. The hold handler returns
H1’s original result and expiry. A new deadline on retry would let repeated calls hoard seats. - One seat is unavailable. If
A8fails the claim check, the transaction rolls backA7too. A later repair after charging would not implement this all-or-nothing hold. - Lost payment reply. Store
B1as pending/uncertain and query or retry the original provider operation. A new charge ID could collect twice. Keep a converted payment-pending reservation protected until the money outcome is established. - Repeated or old callback. Verify the provider signature, save the received event before claiming durable acceptance and check its event ID when processing. A repeated event must not repeat the transition. Also check current
B1state so an old pending event cannot overwrite confirmed. - Crash between commit and send. An outbox row remains after the booking transaction, so another relay can send the capture task. If the provider succeeded but its callback was lost, the stored provider reference lets reconciliation query that same result.
- Money without seats. For a method that collected before valid reservation, record refund-required if the old hold expired and another buyer owns the seats. Show this honest state until the refund or chosen compensation finishes.
- Cancellation after confirmation. The cancellation handler records refund intent and follows the explicit refund/resale policy before releasing inventory. Expired-shopping-hold cleanup cannot cancel a paid booking.
Retain request results for the stated retry window. Neither a missing response nor a duplicate queue delivery tells the service which business state is currently true; it must read the appropriate saved record.
09Trade-offs and Validation
Use each mechanism for the job it actually does:
| Mechanism | Concrete job | Cost or limit |
|---|---|---|
| Cached seat map | Let many people inspect likely availability | Later hold requests may conflict |
| Short database transaction | Claim all requested seats exclusively | Contention, waits and rejected claims |
| Waiting room | Bound attempts reaching tested write capacity | Fairness, queue age and token expiry |
| Authorization before capture | Check inventory between approval and collection | More states and provider-specific support |
| Payment-pending reservation | Keep possibly paid seats exclusive during uncertainty | Inventory stays unavailable until reconciliation |
Limit simultaneous holds and protect catalog/payment administration to reduce hoarding or misuse. Validate price on the server and preserve authentication through every route; a queue token or model recommendation cannot bypass these checks.
Measure queued attempt age, hold conflicts, expiry rate, transaction latency, unresolved payment age, duplicate callbacks, pending refunds and any inventory-rule violation. Compare provider payment records with booking ownership, not just the count of callbacks received. Close by tracing who claims A7/A8, which result resolves a retry and which state prevents resale during an uncertain capture.
OPTIONAL DESIGN EXTENSIONWhere AI Fits in This Design
A model can optionally help people find an event before they try to claim seats. It cannot decide whether a seat is available or a payment succeeded.
- Input and output. For “family music this weekend,” use public event descriptions to suggest matching event IDs or city/date/category filters. Meaning-based retrieval can find related wording as well as exact terms. Validate filters and read current catalog facts before showing them; Elastic’s hybrid-search example illustrates combining these retrieval methods.
- Integration. Keep discovery separate from hold and checkout code. The inventory handler still checks current seats, server prices, authentication and saved request results. Generated text cannot reserve
A7, extendH1or confirmB1. - Fallback and budget. Assume 100 ms for optional discovery ranking, measured separately from hold latency. On timeout or poor confidence, use ordinary performer, city and date search.
- Evaluation and privacy. Test relevant-event retrieval, false claims, date/language interpretation and accessibility or family-category errors. Measure slow calls and cost/search. Use public catalog text and minimized queries, not payment details or unnecessary private booking history. Organizer edits and deletions must reach any derived semantic index.
The deterministic inventory and payment flow remains the same without AI.
DRAW. CALCULATE. EXPLAIN.
Walk a hold through three failure races
75 minutes · guided practice
Design the seat and booking state machines for a two-minute hold. Trace two customers racing for A7, a buyer retrying after a lost hold response, and a payment succeeding after the hold expires. Add a two-seat request where one seat is already held. For each case, name the saved seat and booking records. Show when to roll back the seat group, when a retry returns the same hold, and when money without seats requires a saved refund task.
Compare the worked solution
- Claim one group. The hold handler locks
A7/A8in a consistent order, checks current rows with fresh server time, and commits both claims plusH1and its key-to-result record. If either seat cannot be claimed, it rolls back the whole set. - Resolve competition and reply loss. Only the first valid
A7claim commits; Leo’s competing request receives conflict. Mina’s matching retry returnsH1with its original expiry, not a fresh two-minute hold. - Separate payment from locks. Save
B1and a stable payment operation. Authorize outside the seat transaction. Then recheckH1and convert its seats toB1’s payment-pending reservation before requesting capture.H1expiry before conversion may make the seats available; after conversion, an unknown capture keepsB1’s reservation protected. - Resolve capture. Retry or query the same provider operation. Confirm after established success; release through a saved transition after definitive failure. Do not infer failure from a timeout or issue another charge ID.
- Handle the other payment branch. If a method charges before valid reservation and success arrives after
H1expired and another buyer claimed the seats, save refund-required and complete compensation. Do not displace that valid buyer. - Resume saved work. The outbox retains commands not yet sent. The inbox recognizes repeated provider events; state checks reject backward updates. Reconciliation uses the provider reference when callbacks are missing. Explain these records rather than claiming that a queue alone completes the purchase.
The checkboxes record your own assessment. The worked solution gives you a way to check the reasoning.
RETRIEVE BEFORE YOU REVEAL
Keep the important ideas.
Say the answer aloud, then turn the card over.
CARD 01What actually prevents double booking?
The hold handler checks current event-seat rows and expiry, claims the complete requested group and saves the hold plus retry result in one transaction. A map or waiting-room token grants no seat. Matching retries return the original hold deadline.
CARD 02Why distinguish shopping hold from payment-pending reservation?
A shopping hold expires after two minutes. After authorization and a fresh hold check, convert its seats to the booking’s payment-pending reservation. If capture is unknown, keep them reserved and query the same provider operation. Established success confirms the booking; definitive failure permits a saved cancellation and release.
CARD 03How are payment and retry failures recovered?
Reuse the payment operation ID after a lost reply. Save outgoing work and received event IDs, check current booking state and ask the provider about uncertain outcomes. Money collected without a valid inventory claim needs a recorded refund, not a second owner.
CHECK THE REASONING
Can you explain the choice?
Answer every question, then finish for an immediate score and an explanation for each answer. Aim for 80%, then revisit any gaps.
CLOSE THE LOOP
Explain it without the page.
Summarise the mechanism, one trade-off, and one failure mode. Then mark this chapter complete.