Appearance
9.7.9 — Movie Ticket Booking
"Design a movie ticket booking system. Users pick a city, a film, a cinema and a showtime, choose seats from a map, and pay."
This is the most-asked allocation problem after the parking lot, and it is a better problem, because it adds three things the parking lot does not have: a booking window (you reserve for a show that has not happened yet), groups (four friends want four seats together, or none), and payment, which introduces a second system that can fail after you have already promised something.
There is also one modelling decision in this problem that separates good answers from average ones, and it arrives in the first five minutes. Section 4 is about it.
1. The questions to ask first
How far does browsing go? City, film, cinema, show, seat. Confirm that search and recommendations are out of scope, because otherwise you will spend fifteen minutes on a search index that nobody asked for.
Can a user pick specific seats, or just a count? Specific seats, almost always, and confirming it matters because a seat map is a much harder allocation problem than "give me any three".
Do group bookings need adjacent seats? This is the highest-value question in the whole problem. If the answer is yes, you have a constraint that changes both the allocation and the concurrency, and asking it unprompted signals that you have thought about groups rather than single tickets.
What varies? Pricing certainly does — weekday and weekend, recliner and standard, matinee, a premium for the first weekend of a release. Seat categories vary by cinema. Cancellation rules vary by cinema chain. Three interfaces, right there.
What are the numbers? A screen holds a few hundred seats. A popular opening night means thousands of people looking at the same seat map at the same second. That number is the one that matters, because it tells you the design must survive a crowd all wanting the same few seats.
State the contract: "I will design the browse hierarchy, seat selection with a hold, and payment through to a confirmed ticket. Pricing and cancellation rules are pluggable. I am deferring search, recommendations and the actual payment gateway."
2. Classify it
Allocation with a time window, plus a payment lifecycle. So the skeleton is: bounded resources that get claimed and released, a conditional claim because two users will want the same one, a hold that expires, and a state machine for the booking.
3. Entities, values and roles
Entities. Cinema, Screen, Show, Booking, User. And — the important one — ShowSeat, which section 4 explains.
Values. Money, SeatLabel (row and number, like H12), TimeSlot, SeatCategory.
Roles.
typescript
interface PricingStrategy { // (1)
priceFor(show: Show, seat: ShowSeat, at: Instant): Money;
}
interface SeatSuggester { // (2)
suggest(show: ShowId, count: number, preference: Preference): SeatLabel[] | null;
}
interface CancellationPolicy { // (3)
refundFor(booking: Booking, at: Instant): Money;
}(1) Price depends on the show, the seat and the moment, so all three are parameters. Note that it takes an instant rather than reading a clock, which means "what will this cost tomorrow" needs no clock trickery.
(2) "Give me four together near the middle" is a search over the seat map, and it is a genuine variation axis — cinemas differ on whether they will split a group, whether they leave single-seat gaps, and whether accessibility seats are offered by default.
(3) Refund rules differ by chain and by how close to showtime you cancel.
4. The modelling decision that separates answers
Here is the trap. A Screen has seats. A Show happens on a Screen. So the instinct is:
typescript
class Seat {
label: SeatLabel;
category: SeatCategory;
isBooked: boolean; // ← this is the bug
}Seat H12 in screen 3 is booked for the 6pm show and free for the 9pm show. One boolean cannot represent both. The moment you write isBooked on the physical seat, booking the 6pm show marks the seat taken for every show that screen will ever run.
The fix is to separate the physical thing from the per-show thing.
typescript
class Seat { // physical, created once when the screen is built
constructor(
readonly id: SeatId,
readonly label: SeatLabel, // H12
readonly category: SeatCategory, // recliner, standard, accessible
readonly row: number,
readonly column: number, // (1) adjacency needs coordinates
) {}
}
class ShowSeat { // one per seat per show
#state: "free" | "held" | "booked" = "free";
#heldBy: HoldId | null = null;
#heldUntil: Instant | null = null;
constructor(readonly showId: ShowId, readonly seat: Seat) {}
hold(by: HoldId, until: Instant, now: Instant): boolean { // (2)
if (this.#state === "booked") return false;
if (this.#state === "held" && this.#heldUntil! > now) return false; // (3)
this.#state = "held";
this.#heldBy = by;
this.#heldUntil = until;
return true;
}
confirm(by: HoldId, now: Instant): boolean { // (4)
if (this.#state !== "held" || this.#heldBy !== by) return false;
if (this.#heldUntil! <= now) return false;
this.#state = "booked";
return true;
}
}(1) Row and column, not just a label. "Four seats together" is a question about coordinates, and a design that stores only "H12" has to parse strings to answer it.
(2) hold returns a boolean rather than throwing, because losing a seat to another user is an ordinary Tuesday, not an exception.
(3) An expired hold is treated as free. This is the detail worth pointing at: the hold expires by being ignored, not by being deleted. No background job writes to this object, so there is no moment where a sweeper and a paying customer race for the same seat.
(4) Confirmation checks that you are the holder and that your hold has not lapsed. A user whose hold expired while their card was processing must not get the seat, and this method is where that is decided.
The browse hierarchy is the easy part, and worth one sentence rather than a diagram: City → Cinema → Screen → Show, and a film has many shows across many cinemas. The interesting query is "all shows of film F in city C tomorrow", which is why Show carries both a film id and a cinema id rather than being reachable only by walking down from the city.
5. The booking lifecycle
6. Holding seats, including groups
Single seats first:
typescript
async function hold(showId: ShowId, labels: SeatLabel[], user: UserId): Promise<HoldResult> {
const holdId = HoldId.new();
const until = clock.now().plus(HOLD_DURATION); // (1) ten minutes
const claimed = await db.transaction(async tx => { // (2) all or nothing
const rows = await tx.holdSeats(showId, labels, holdId, until);
return rows; // number of seats actually held
});
if (claimed !== labels.length) { // (3)
await db.releaseHold(holdId); // (4) give back the partial win
return HoldResult.lost(await freeSeatsFor(showId));
}
return HoldResult.held(holdId, until, priceFor(showId, labels));
}(1) The hold has an explicit end, computed once and stored, so every later check compares against the same value.
(2) The whole claim is one transaction. This is the group requirement made real: four seats or none.
(3) If fewer seats were claimed than requested, somebody else got one of them.
(4) And you must give back the ones you did win. A partial hold that is never released takes seats out of circulation for ten minutes for no reason, and on an opening night that is the difference between a full house and a half-empty one.
The claim itself is the conditional write, and it is where the correctness lives:
sql
UPDATE show_seats
SET state = 'held', held_by = :holdId, held_until = :until
WHERE show_id = :showId
AND seat_label = ANY(:labels)
AND (state = 'free' OR (state = 'held' AND held_until < now()));The WHERE clause is the check and the SET is the act, performed together by the database (9.5.1). The row count that comes back tells you how many you won. Two hundred users hitting the same seat in the same second produce one winner and a hundred and ninety-nine clean "someone just took that" responses, and that is true whether you run one server or fifty.
Adjacency for groups is a search over the seat map, not a database concern:
typescript
function findAdjacent(rows: SeatRow[], count: number): SeatLabel[] | null {
for (const row of rows) {
let run: Seat[] = [];
for (const seat of row.seatsInColumnOrder()) { // (1)
run = seat.isFree ? [...run, seat] : []; // (2) reset on any gap
if (run.length === count) return run.map(s => s.label); // (3)
}
}
return null;
}(1) Column order matters, which is why Seat carries coordinates rather than only a label. (2) A booked seat breaks the run. (3) First fit is fine; a cinema that wants "as central as possible" scores the candidate runs instead, and that is a different SeatSuggester, not a different data model.
One real-world subtlety worth a sentence. Some cinemas refuse to leave a single seat stranded between two groups, so booking seats 2 and 3 of a five-seat row is disallowed because it orphans seat 1. That rule belongs in the suggester and in a validation step, and mentioning it shows you have thought about the business rather than the algorithm.
7. The payment gap
The hold lasts ten minutes. The card takes eight seconds. Usually fine — and then one day the gateway is slow, the hold lapses at second 601, and the payment succeeds at second 604.
You now have a customer whose money you have taken and whose seat somebody else is sitting in. There is no clever ordering that prevents this, for the same reason the ATM cannot make dispensing atomic (9.7.8): two systems, one of which you do not control.
What you do instead is make the confirmation conditional and decide the failure behaviour in advance.
typescript
async function confirm(holdId: HoldId, paymentRef: PaymentRef): Promise<Outcome> {
const rows = await db.confirmSeats(holdId, clock.now()); // (1) conditional
if (rows === 0) {
await payments.refund(paymentRef, "hold expired before confirmation"); // (2)
await notify.holdExpired(holdId);
return Outcome.expired();
}
await db.markBookingConfirmed(holdId, paymentRef); // (3)
return Outcome.confirmed();
}(1) confirmSeats is UPDATE show_seats SET state='booked' WHERE held_by = :holdId AND held_until > :now. If the hold lapsed, it affects zero rows, and you find out before issuing a ticket.
(2) The refund is automatic and immediate, and the customer is told what happened. This is a product decision — we would rather refund and apologise than sell a seat twice — and stating it as a decision rather than an accident is the point.
(3) Only now does a booking exist.
Three details that make this robust and are worth volunteering.
Charge as late as possible. Authorise the card during the hold, capture it only after the seats are confirmed. Then the failure case is a released authorisation rather than a refund, which is faster for the customer and cheaper for you.
Make confirmation idempotent. Gateways send webhooks more than once, and users double-click. Key the booking on the hold id with a unique constraint, so the second confirmation finds the booking already exists and returns the same ticket instead of creating a second one (9.6.3).
Extend the hold when payment starts. A user who has reached the payment page has demonstrated intent, so many systems push the expiry out by a few minutes at that moment. It shrinks the window that causes the problem, and it costs one update.
8. The twists, pre-walked
"Add dynamic pricing — the last twenty percent of seats cost more." A new PricingStrategy that reads current occupancy. One class, one registry line, nothing else changes. This is why pricing was an interface.
"Add coupons." A discount applied after pricing, which is a decorator around the strategy rather than a change to it. Worth naming as a decorator explicitly (9.4.8) because the composition is the interesting part: three coupons stack in a defined order.
"Users should be able to cancel and get a partial refund." CancellationPolicy already exists. The new work is the transition and freeing the seats, and the design point worth mentioning is that a cancelled seat returning to the pool minutes before the show is a different product than one returning a week before.
"A cinema wants unreserved seating — first come, first served, no seat map." This one restructures, and say so. There is no ShowSeat at all; there is a counter, and the claim becomes UPDATE shows SET sold = sold + :n WHERE id = :id AND sold + :n <= capacity. The lesson is that the seat map and the counter are two different allocation models, and a system supporting both needs the choice behind an interface at the show level rather than a flag threaded through every query.
9. What the interviewer will push on
"Two users click the same seat at the same time." The single most likely follow-up. The answer is the conditional update, and the giveaway that you have thought it through is saying that the loser gets a normal response, not an error — the seat map refreshes and shows it gone. If you say "I would lock the show", expect a follow-up about what happens on opening night when every request for that film queues behind one lock.
"What if payment succeeds after the hold expires?" They are checking whether you noticed that two systems cannot be made atomic. Answer with the conditional confirmation, the automatic refund, and the mitigations: authorise early and capture late, and extend the hold when payment begins.
"How do you expire holds?" The wrong answer is a cron job that sets seats back to free, because it races with a user confirming at that exact moment. The right answer is that expiry is implicit in the data — a hold with a past held_until is already ignored by every query — and that any cleanup job is housekeeping with no correctness role. Being able to say "the sweeper is an optimisation, not a guarantee" is the sentence they are listening for.
"Four seats together, or none." They want the transaction and the release of the partial win. Candidates who claim seats one at a time and forget to release the ones they got are the common failure here.
"Where does isBooked live?" Sometimes asked directly, more often revealed by your class diagram. If availability is on the physical Seat, the design cannot represent two shows in the same room, and the interviewer will find that out by asking about the 9pm screening.
"What breaks when the film is a blockbuster and fifty thousand people arrive at 9am?" Not really an LLD question, but it gets asked. The honest answer names three things: the seat map read is the hot path and should be cached with a short lifetime and served stale, the writes are already safe because they are conditional, and the queue in front is a product feature — a waiting room with a position — rather than a database concern. Then point at Chapter 11.16, which builds that funnel properly, and stop; going further is scope you were not asked for.
The thing to volunteer that nobody asks for: the hold duration is a business parameter, not a constant. Ten minutes is generous on a quiet Tuesday and expensive on opening night, when every held-and-abandoned seat is a lost sale. Saying that it belongs in configuration, per cinema or per show, shows you understand which numbers in a design are decisions.
Next: 9.7.10 â the wallet, where a lost seat is an annoyance and a lost pound is fraud.
Recall
- Classify: allocation with a time window, plus a payment lifecycle. Conditional claim, expiring hold, booking state machine.
- The modelling decision: availability belongs to
ShowSeat(one per seat per show), never to the physicalSeat.isBookedonSeatcannot represent booked at 6pm and free at 9pm. Seatcarries row and column, not just a label, because adjacency is a question about coordinates.- The hold expires by being ignored, not deleted —
held_until < now()is treated as free by every query, so no sweeper ever races a paying customer. A cleanup job is housekeeping, not a guarantee. - Groups are one transaction: all seats or none, and you must release the partial win or seats vanish from sale for ten minutes.
- The claim is a conditional update whose row count tells you how many you won. The loser gets a normal "someone just took that", not an error.
- Payment can succeed after the hold lapses. Confirmation is conditional on the hold still being valid; if it affects zero rows, refund automatically. Mitigate by authorising early, capturing late, extending on payment start, and keying the booking on the hold id so confirmation is idempotent.
Self-test: Why can't isBooked live on Seat? What does the row count of the claim update tell you? Why is a hold-expiry cron job the wrong answer? What happens when payment succeeds four seconds after the hold lapsed? What has to happen when you win three of four requested seats?
Quiz Bank
FoundationalWhy does seat availability belong to a show-seat rather than to the seat, and what else does that decision buy you?
Because a seat's availability is not a property of the seat. Seat H12 in screen 3 is booked for the 6pm show and free for the 9pm show. Both facts are true at the same moment, so a single isBooked flag on the physical Seat cannot hold them. Writing that flag means booking one show marks the seat taken for every show that room will ever run, which is a bug that appears the first time anybody tests a second screening.
The fix is to give the pair of (seat, show) its own object. Seat is furniture: a label, a category, a row and a column, created once when the screen is built and never changed. ShowSeat is one per seat per show, and it holds the state — free, held or booked — plus who holds it and until when.
What else the split buys you, beyond fixing the obvious bug.
Per-show pricing becomes possible. The same recliner costs more on Friday night than on Tuesday afternoon. That is a property of the show-seat, not the seat, and with the split it has somewhere to live.
The physical layout stays immutable. Nothing about a screen changes when tickets are sold, which means the layout can be cached forever, shared between shows, and read without any concurrency concern at all. All the contention is confined to ShowSeat, which is exactly where you want it.
Creating a show is an explicit act. Scheduling a show generates its show-seats from the screen's layout, which gives you a natural place to apply show-specific rules — blocking a row for staff, marking seats out of service for that night only, or releasing a held block of house seats an hour before curtain.
Historical accuracy survives a refurbishment. If the cinema converts row H to recliners next year, past bookings still know what was sold, because the show-seat recorded the category at the time.
The general lesson worth naming, since it transfers to every allocation problem: when a fact depends on two things, it belongs to the pair, not to either one. Availability depends on the seat and the show. A hotel room's availability depends on the room and the night. A meeting room's depends on the room and the time slot. Every one of these has the same trap and the same fix.
AppliedTwo hundred users try to book seat H12 for the same show in the same second. Walk through exactly what happens and what each user sees.
Before the click, everybody is looking at a stale map. The seat map is a read, and two hundred people fetched it at slightly different moments. All of them believe H12 is free. That belief is not a bug and it cannot be prevented — making the map perfectly accurate would mean serialising every viewer behind every buyer. The design consequence is that the map is a hint and the claim is the truth, and the interface must be built to survive being told "actually, that one just went".
The click sends two hundred claims to one conditional update.
sql
UPDATE show_seats SET state='held', held_by=:holdId, held_until=:until
WHERE show_id=:show AND seat_label='H12'
AND (state='free' OR (state='held' AND held_until < now()));The database serialises writes to that row by itself. The first to arrive finds state='free', matches the WHERE, and updates — one row affected. The other one hundred and ninety-nine now find state='held' with a future held_until, fail the WHERE, and get zero rows affected. No lock was written by the application, and the result is identical whether the traffic came through one server or fifty.
What each user sees. The winner goes to the payment page with a ten-minute countdown. The losers get a normal response, not an error: their seat map refreshes, H12 is shown as taken, and the message is "someone just booked that seat". This distinction matters more than it sounds. Losing a race for a popular seat is the expected outcome for almost everyone in this scenario, so it must be modelled as an ordinary code path with a good message, not as an exception that surfaces as "something went wrong".
What makes this better than the alternatives, which is the part to say out loud. A lock over the show would serialise every purchase for that film, so a blockbuster throttles the whole system. A lock in the application would stop working the moment a second instance is deployed, and the oversell would return exactly at peak traffic. Neither is needed, because the invariant lives where the state lives.
Two refinements worth adding.
Reduce the collisions at the source. Push updates to the seat map — or just poll it every few seconds — so most users see H12 disappear before they click it. This does not change correctness; it changes how many people have a disappointing experience.
Shape the load at the edge for an opening night. A waiting room with a queue position converts a stampede into a line, gives every user a definite answer, and stops fifty thousand simultaneous requests from reaching the database at all. That is a user-experience and capacity decision layered on top of a correctness guarantee that already holds without it, and keeping the two motivations separate is what makes the design sound deliberate rather than defensive.
InterviewA colleague proposes a background job that runs every minute and sets expired holds back to free. What is wrong with it?
It introduces a race that the design did not previously have. At the instant a hold expires, two things may be happening: the sweeper is writing state='free', and the original user's payment is confirming. Depending on the order, you can end up with a seat marked free that has also been sold, or a confirmed booking whose seat was handed to somebody else moments later. The job has created a second writer for a row that had exactly one.
It also makes availability wrong for up to a minute. A hold that lapsed at 12:00:01 is still marked held until the job next runs, so the seat is invisible to buyers during a window where it should have been on sale. On a busy show that is real lost revenue, and increasing the frequency of the job only shrinks the window while making the race more likely.
The better design is to make expiry implicit in the data. Nothing writes anything. Every query that cares treats a hold whose held_until has passed as free:
sql
WHERE state = 'free' OR (state = 'held' AND held_until < now())Now the seat becomes available at the exact microsecond it should, with no job involved and no second writer. The confirmation query has the mirror-image condition — held_by = :holdId AND held_until > now() — so a user whose hold lapsed cannot confirm, and the two conditions can never disagree because they are evaluated against the same row at the same moment by the same database.
What the sweeper is still allowed to be. A housekeeping job that tidies old rows so the table does not carry dead state forever is perfectly reasonable. The critical difference is that it now has no correctness role at all — if it never runs, the system is still completely correct, just slightly untidier. Being able to say that sentence is the point of the question.
And the general principle to state, because it recurs everywhere: a status that can be derived should be derived, not stored. Storing it creates a second source of truth that has to be kept in sync, and keeping two sources of truth in sync across concurrent writers is the hardest problem in this chapter. The same reasoning applies to a parking hold, a shopping cart reservation, and a session timeout.
StaffDesign the booking flow so a user is never charged for a seat they do not get, and be honest about what is impossible.
Start with the impossibility, because the rest of the answer follows from it. The seat lives in your database and the money lives in the payment provider's. You cannot commit to both atomically, and no ordering of two calls survives a crash or a slow response in between. So the goal is not "make it impossible", it is make the bad outcome rare, detectable, and automatically corrected.
Layer one: shrink the window. Authorise the card during the hold rather than capturing it. An authorisation places a hold on the customer's funds and can be released cheaply; a capture moves money and needs a refund to undo. If you authorise early and capture only after the seats are confirmed, the common failure becomes a released authorisation the customer barely notices, rather than a charge and a refund several days apart.
Layer two: extend the hold when payment starts. A user who has reached the payment page has shown intent. Pushing the expiry out by a few minutes at that moment costs one update and eliminates most of the cases where a slow gateway causes the problem at all.
Layer three: make the confirmation conditional. The final step is UPDATE show_seats SET state='booked' WHERE held_by = :hold AND held_until > now(), and the row count decides everything. If it matches the number of seats, capture the payment and issue the ticket. If it is zero, the hold lapsed and somebody else took the seats, so you release the authorisation — or refund, if you already captured — and tell the user what happened. The important property is that the seat decision comes first and the money follows it, so you never take money for a seat you have not just successfully claimed.
Layer four: make everything idempotent. Payment webhooks arrive more than once, and users refresh the confirmation page. Key the booking on the hold id with a unique constraint, so a second confirmation attempt finds the booking already exists and returns the same ticket rather than creating a second one or charging again. Key the capture on the same reference so the provider deduplicates on their side too.
Layer five: reconcile, because layers one to four still leave a residue. A capture can succeed and the response can be lost, leaving you believing it failed. So a periodic job compares your bookings against the provider's settled transactions and flags anything that exists on one side only. Money taken with no booking gets refunded automatically; a booking with no money gets suspended and reviewed. This is the layer teams skip, and it is the one that catches everything the others missed.
What to say about the residual case that survives all five. A customer is charged and has no seat, for a few minutes, until reconciliation catches it. The product answer is that this is acceptable if the refund is automatic and the customer is told immediately, and unacceptable if they have to phone somebody. So the design commitment is not "this never happens" — it is "this is corrected without the customer having to ask". Framing it that way is the honest version, and it is also the version a real payments team would recognise.
Flashcards
FlashSeat versus ShowSeat
Seat is furniture: label, category, row, column, immutable. ShowSeat is one per seat per show and holds free/held/booked. Availability depends on two things, so it belongs to the pair.
FlashThe seat claim
One conditional update; the row count tells you how many you won. Losing is a normal response, not an error. Groups are one transaction, and you must release the partial win.
FlashHold expiry
Expire by being ignored — held_until < now() reads as free. Never a job that writes state='free', which races a confirming user. A cleanup job is housekeeping, not a guarantee.
FlashPayment after expiry
Confirmation is conditional on the hold; zero rows means refund automatically. Mitigate: authorise early, capture late, extend the hold when payment starts, key the booking on the hold id.
FlashBooking interview probes
Same seat two users · payment after hold lapse · how holds expire · four together or none · where isBooked lives · what breaks on opening night.
Scenario Drill
DrillExtend the design to support a cinema chain that sells a single ticket covering a film plus a meal at the attached restaurant, where the meal has its own capacity per time slot. Work through what changes, what stays, and where the new failure modes are.
The instinct is to treat this as a bigger booking. It is better understood as two allocations that must both succeed, which is a genuinely different shape and the reason this makes a good extension question.
What stays exactly as it is. The seat model, the conditional claim, the hold with implicit expiry, and the booking state machine are all untouched. That is the payoff for having kept the seat logic self-contained: adding a second resource does not perturb the first.
What the meal actually is. A restaurant sitting has a capacity per time slot, and — this is the question to ask — does the customer choose a table, or just a slot? Almost certainly a slot, which means the meal is a counter rather than a seat map. So the two halves of this ticket use the two different allocation models named in section 8: the film is a map with individually claimable units, the meal is a counter with a conditional decrement.
sql
UPDATE meal_slots SET taken = taken + :covers
WHERE slot_id = :slot AND taken + :covers <= capacity;Same principle, different shape. Zero rows means that sitting is full.
Where the new failure mode is, and it is the interesting part. The customer wants seats and a meal slot. Either can be lost independently. Four combinations exist, and only one of them is good.
Both succeed: fine. Both fail: fine, tell them nothing was available. Seats won, meal lost — now you are holding four cinema seats for a customer who may not want them without the meal. Meal won, seats lost — the mirror image.
The naive fix is to wrap both in one database transaction, and that works only if both live in the same database. If the restaurant system is separate, which in a real chain it usually is, you cannot. So this becomes the two-system problem again, and the answer is the same shape as the payment gap in section 7: claim both as holds, and treat a partial win as something to undo.
Take the seat hold first, because it is the scarcer resource and the one the customer cares most about. Then take the meal hold. If the meal hold fails, release the seat hold immediately and tell the customer the film is available but the 7pm sitting is not, offering the alternative sittings — which is a much better experience than a bare failure, and it is only possible because you held the seats first and can now afford to ask.
Ordering matters and should be justified out loud. Claim the scarce, non-substitutable thing first. Seats for a specific show cannot be substituted; a meal at 7:15 instead of 7:00 usually can. Claiming the substitutable thing first means you frequently have to release it, and every release is a moment where somebody else could have had it.
Expiry now has two clocks, and they must agree. If the seat hold lasts ten minutes and the meal hold lasts five, a customer paying at minute seven gets a seat and no meal. Either use one duration for both, or — better — have the combined booking carry a single deadline and pass it to both systems, so there is one number and it is the booking's.
Confirmation becomes two conditional confirmations, and the same rule applies: seats first, meal second, and if the meal confirmation fails after the seats succeeded, you have a decision to make that is a business question rather than a technical one. Do you sell the film ticket alone at the film-only price and apologise about the meal, or do you cancel both? Ask. My recommendation would be the former, because the customer is already at the cinema and a partial delivery beats none, but the important thing is that this is decided in advance and encoded, not discovered during an incident.
Pricing gains a wrinkle worth naming. The combined ticket is usually cheaper than the two bought separately, so the price is not the sum of two strategies. That is a package price, and it belongs as its own PricingStrategy implementation over the pair rather than as a discount bolted onto either half — which is exactly the kind of decision the interface was there to absorb.
And the thing to say at the end. This extension did not require redesigning anything, because the original design put the guarantee in the state and kept the seat logic behind a clean boundary. What it did require was a decision about ordering and a decision about partial success, and those two questions are what every multi-resource booking comes down to — whether the second resource is a meal, a parking space, a hotel room, or a connecting flight.