Course Content
Mobile System Design Interview
11 sections · 23 lessons
Hotel Reservation: the persisted flow, payment safety and follow-ups
Five screens, several minutes, and an operating system that can end the process between any two of them. The architecture question is where the half-finished booking lives.
This lesson answers that question, then spends the most important five minutes of the design on the payment call at the end of the flow, and closes with the follow-ups an interviewer uses to test whether you treat offline and client-supplied data seriously.
The naive design and its failure
Hold the flow state in an object owned by the flow's first screen, or pass it forward through navigation arguments. Both work in testing, where nobody leaves the app.
Then a real user reaches the payment screen, switches to their banking app for a one-time code, and comes back ninety seconds later to a relaunched process on a blank search screen. Everything they typed is gone. On a booking flow, a meaningful share of users abandon at that point, and the ones who do not have to type it all again.
Persist the flow
Model the flow as a row in the database, not as an object in memory.
booking_flow( flow_id primary key, step enum, -- ROOM | GUEST | PAYMENT | REVIEW | SUBMITTING | DONE quote_id, quote_expires_at, guest_json, -- names, contact; NOT card details payment_token, -- provider token; NOT a card number idempotency_key, -- generated at CONFIRM, never regenerated booking_id, -- filled on success updated_at)Write on every step transition, before navigating. It is a single small row, so the write costs a few milliseconds and buys the whole recovery story.
On launch, if an unfinished flow exists and its quote has not expired, offer to resume it: "You were booking Hotel Meridian, 12–14 October. Continue?" That prompt is worth building — it converts a total loss into a two-tap recovery.
The rule for what may be resumed
Draw a line through the flow at the moment the booking request is sent.
Before it — room selection, guest details, payment token collection, review — nothing has been submitted, so resuming means restoring fields and continuing. The only thing to check is the quote: if it has expired, re-quote and show the price difference explicitly.
At or after it, resuming is a different operation entirely. The app must not re-submit. It must reconcile: call the lookup-by-key endpoint and find out what actually happened. The payment design below covers this.
What never goes in the flow row
Card numbers and security codes. The payment provider's software development kit exchanges them for a single-use token, and the token is what you persist. Storing the card details yourself pulls your app into a compliance regime you do not want and creates a file on the device that should not exist.
Payment safety on the client
This is the most important five minutes of this design. It has the same shape as order placement in the trading app, with a different consequence: there, an unwanted trade; here, a duplicate charge.
The three truths behind one timeout
The app sends POST /v1/bookings with a payment token. Thirty seconds pass. No response.
- The request never arrived. No booking, no charge.
- The request arrived, the booking was created, and the card was charged. The response was lost.
- The request arrived and was rejected. No booking, no charge.
From the client, all three look identical. This is not a rare case — a tunnel, a lift, a handover between cell towers, and a network switch from Wi-Fi to cellular all produce it, and the payment call is the longest-running request in the app, which makes it the most likely to be interrupted.
What a blind retry does
Resending creates a second booking and a second charge in case 2. The user is charged twice for one room, and the recovery is a support ticket and a refund that takes days. This is the concrete outcome of "we retry on failure", and naming that outcome is what makes the point land in an interview.
The design
1. Generate the idempotency key once, at Confirm. It is stored in the flow row before the request is sent. Every retry of this attempt reuses it, so the server recognises the repeat and returns the original booking rather than creating a second. Generating a new key on retry defeats the entire mechanism, and it is a real bug people ship.
2. Move to an explicit UNKNOWN state, not an error. On timeout, write step = SUBMITTING with an unresolved marker and show the user an honest screen: "We're confirming your booking — this can take a moment. Don't book again; we'll show your confirmation here." Do not show a generic failure with a Try Again button, because the user will press it.
3. Reconcile, do not resend. With the key in hand, ask:
GET /v1/bookings?idempotency_key=<key> → 200 booking : it exists. Show the confirmation. Done. → 404 : it does not. Safe to submit again — with the same key.Run this on reconnect, on app launch while an unresolved flow exists, and behind a manual "check status" button. Bound the automatic attempts and then surface a support path rather than looping forever.
4. Never queue a payment in the outbox. This is the sharpest contrast with the chat app's outbox. A chat message sent an hour later is fine. A booking submitted an hour later charges a card for a room whose price and availability were valid at a moment long past, with the user nowhere near the app. Block at review, keep the flow, and let them finish when they are back online.
Offline: payment is blocked with a clear reason, the flow is preserved, and an already submitted booking in UNKNOWN state shows "confirming when you reconnect" rather than an error.
Follow-ups
Caching search results, and their expiry
Search results are cached so returning to the list after viewing a hotel is instant. They carry a short time-to-live — a few minutes — and a capture timestamp.
The rule that matters: a cached search result may be displayed but must not be booked from directly. Selecting a room from stale results always goes through a fresh quote call, and if the price has changed the difference is shown as a change rather than substituted. Displaying an old price is acceptable; charging one is not.
Showing an existing booking offline
The bookings list reads from the database, always. Store everything a traveller standing at a reception desk needs with no signal: reference number, hotel name and address, check-in and check-out dates and times, guest names, the total paid, the cancellation policy text, and the hotel's phone number.
Two details that make it genuinely useful: store the map as a static image captured at booking time, because a live map needs a network; and offer to add the booking to the device calendar and wallet, both of which work offline by construction. This is the highest-value, lowest-difficulty feature in the design, and it is the one most candidates omit.
Cancellation and refunds
Cancellation is another money-moving write, so it gets the same treatment: an idempotency key, an explicit unknown state, and reconciliation. Show the refund amount and expected timing from the policy before confirming — a cancellation the user did not understand becomes a support ticket. Cancellation is disabled offline, since the policy window may have changed.
Calendar and map integration
Both need runtime permissions the user can decline, and the design must work when they do. If calendar access is refused, offer a downloadable calendar file instead. Never make a permission a hard requirement for a core flow, and request it at the moment of use with a sentence explaining why, rather than at launch.
The deep link into the middle of a flow
A marketing link opens the app directly on a room with dates prefilled. Two rules keep it safe.
Validate everything in the link before using it. Dates in the past, an unavailable room, and a hotel that has been removed all arrive in real links. Fall back to the hotel detail screen with an explanation rather than an error.
Never trust a price from a link. A price in a deep link is not an offer; it must go through the quote endpoint like every other path. Otherwise a shared or edited link becomes a way to book at a price the server never issued.
Build a synthetic back stack, so back leads to the hotel and then to search rather than out of the app.