How to read this. Sections 1 to 7 are the platform-neutral design, with Kotlin/Android code. Section 8 shows what changes on iOS. The hotel flow is the main case; ride hailing is a contrast in section 6.
TL;DR. Money and inventory live on the server, so the client is a thin view over server-owned state. Every mutating request carries an idempotency key that is persisted before the first send. Time is server-authoritative: the client counts down from a server-computed duration on a monotonic clock, never from the device wall clock. The booking state machine only advances when the server says so, and after any interruption (3-D Secure, process death, lost response) the client asks the server what happened instead of guessing. Booking is online-only; viewing saved bookings works offline.
1. Clarify the requirements (about 5 min)
Ask before drawing anything. Good opening questions:
- Hotels only, or rides too? (Assume hotels as the main flow, rides as a contrast.)
- Do we hold inventory during checkout? (Yes: a short server-side hold, for example 10 minutes. The value is a product decision.)
- Which payment methods? (Cards, Google Pay, Apple Pay, with 3-D Secure where the issuer requires it.)
- Can users book offline? (No. They can view saved bookings offline.)
- Scale? (Search is read-heavy and cached on the backend; keep the focus on the client.)
Functional: search with autocomplete, browse a list and a map, hold a room, pay, see the confirmation, find the booking later, open it from a link.
Non-functional (the part that makes it a mobile question):
| Concern | Target |
|---|---|
| Money | Never charged twice, never charged without a booking |
| Inventory | No double booking; the server enforces the hold |
| Time | Countdown correct even with a wrong device clock |
| Search | Suggestions feel instant; stale responses never overwrite fresh ones |
| Offline | Saved bookings readable; booking disabled with a clear reason |
| Recovery | Killing the app mid-payment never leaves the user guessing |
Out of scope for now: loyalty, reviews, host tools, multi-room carts. Say this out loud so the interviewer can redirect you.
2. High-level architecture (about 10 min)
Official architecture mapping: Compose screen plus ViewModel form the UI layer (the ViewModel is the state holder, exposing one
uiState); the Search, Booking and Trips repositories are the data layer entry points, with Room and the REST client as data sources; no domain layer (add use cases only if logic is shared). See the Android architecture recommendations.
Two decisions carry the design.
- The server owns inventory, price, time and booking state. The client keeps a view of it plus local bookkeeping: the idempotency key, an attempt id, the last known server state.
- The write path is an official online-only write. The Android offline-first guide lists online-only writes as right when the write must happen in near real time (its example is a bank transfer) and says to disable or warn in the UI when offline. A queued write would let a user "book" a room that no longer exists. The read path (saved trips) is offline-first.
3. Data model and API
Local tables (Room)
booking_attempt(attemptId, idempotencyKey, offerId, holdId, status, version): written before the first network call. The recovery anchor after process death.trip(bookingId, status, propertyJson, dates, voucherCode, syncedAt): confirmed bookings, readable offline.recent_search(query, placeId, usedAt)andresult_page(queryKey, pageIndex, itemsJson, quotedAt).
REST
GET /v1/places/suggest?q=lis
GET /v1/offers?placeId=..&dates=..&cursor=.. → items + nextCursor, each with quoteId, price, quotedAt
GET /v1/offers?bbox=..&dates=.. (map viewport)
POST /v1/holds { offerId, quoteId } Idempotency-Key → { holdId, expiresInMs, price }
POST /v1/bookings { holdId, paymentToken } Idempotency-Key → { bookingId, state, version, nextAction? }
GET /v1/bookings/{id}
GET /v1/bookings?attempt=<idempotencyKey> (recovery lookup)
Return expiresInMs, a duration computed at response time, not only an absolute timestamp. A duration is immune to the device clock.
Server-driven state
HELD → PAYMENT_REQUIRED → ACTION_REQUIRED → PROCESSING → CONFIRMED
↘ EXPIRED ↘ FAILED ↘ CANCELLED
The client mirrors this as a sealed type and never advances it on its own. Each response carries a version; ignore anything not newer, so late responses cannot move the state backwards.
4. Holds and server-authoritative time
The trap is System.currentTimeMillis(): a user whose clock is 20 minutes fast sees an instantly expired hold. The server decides expiry; the client only displays it.
POST /holdsreturnsexpiresInMs.- Record a monotonic timestamp before sending the request and compute remaining time from it. This is conservative: latency makes the UI show slightly less time than the server grants, never more.
- Use a monotonic clock (
SystemClock.elapsedRealtime()), which does not jump when the user changes the date. - At zero, lock the pay button but re-read the hold from the server rather than declaring it dead; the server may have extended it.
- The pay request is still validated server-side. On
EXPIRED, show "Room no longer held" with a re-check action.
// Sketch: countdown anchored to a monotonic clock
data class HoldClock(val expiresInMs: Long, val sentAtElapsed: Long) {
fun remainingMs(now: Long = SystemClock.elapsedRealtime()): Long =
(expiresInMs - (now - sentAtElapsed)).coerceAtLeast(0)
}
5. Idempotent booking, payment and recovery
The classic failure: the request reaches the server, the response is lost, a retry or a second tap sends it again. Without protection that is two charges.
- Generate the key once per checkout attempt (a UUID) and write it to
booking_attemptbefore the first send. Reuse it on every retry of that attempt. - Send it as
Idempotency-Key. Stripe's docs describe the pattern: the client generates a unique key (V4 UUIDs suggested, up to 255 characters), the server saves the first result, and retries with the same key return it. Stripe also compares parameters with the original request and errors on a mismatch, so use a new key when the cart really changes. - Keys expire. Stripe says API v1 keys can be pruned once at least 24 hours old. Your backend picks its own window.
- Double tap: the ViewModel ignores
Paywhile the state isSubmitting. The key is the safety net, not the first defence. - On timeout, look before resending:
GET /bookings?attempt=<key>. If a booking exists, adopt it. If not, resend with the same key.
// Sketch: the key comes from the persisted attempt (wrapped by a data source; not called from the ViewModel)
interface BookingApi {
@POST("v1/bookings")
suspend fun confirm(
@Header("Idempotency-Key") key: String,
@Body body: ConfirmRequest,
): BookingDto
}
Work lifetime: the pay request is app-oriented work, so the repository runs it in an injected external CoroutineScope (leaving the screen must not cancel a charge in flight) and the ViewModel only awaits the result. The recovery lookup on launch could also be a WorkManager job, but a plain startup call is enough here. BookingApi is wrapped by a remote data source behind BookingRepository, so the ViewModel never calls it.
Wallets and 3-D Secure
The sheet produces a token; the client never charges anyone.
- Google Pay: the user picks a method in the sheet and Google Pay returns a token to the app, which sends it with the purchase details to your backend, which passes it to the payment provider.
PaymentsClient.isReadyToPaygates the button andloadPaymentDataopens the sheet. - Apple Pay:
PKPaymentAuthorizationControllerpresents the sheet; indidAuthorizePaymentyou send the token to your server and report the result. - 3-D Secure: in Stripe's flow the PaymentIntent moves to
requires_action, and the SDK presents the challenge. A web-view fallback uses areturn_urlthat routes back into the app (custom scheme or universal link). Stripe notes wallets are among the payments where 3DS is not always supported, so cards hit it more often.
The redirect result is a hint, not the truth. The user may finish the bank page and never return, or return to a fresh process. On resume or on the return link, move to Processing and ask the server for the booking state (poll with backoff, or wait for a push). The server learns the real outcome from provider events (Stripe lists charge.succeeded) and exposes it as booking state.
Process death mid-payment
SavedStateHandle survives system-initiated process death but not a force stop or a swipe from Recents, and it is meant for small values. Keep only the attemptId there and the rest in Room. On startup, find booking_attempt rows not in a terminal state and reconcile each with GET /bookings?attempt=<key>. After a force stop the Room row still lets this run on next launch; only the in-flight UI is lost.
6. Search, staleness, links and the ride contrast
Autocomplete. Debounce (a short delay, for example 250 ms, tuned with data), trim, drop duplicates, and cancel the previous request so a slow "lis" cannot overwrite "lisbon". Show recents from Room when the field is short, and cache suggestion responses by query.
// Sketch: debounce + cancel-previous with flatMapLatest
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repo: PlaceRepository) : ViewModel() {
private val query = MutableStateFlow("")
val suggestions: StateFlow<List<Place>> = query
.debounce(250)
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.length < 2) repo.recents() // Flow<List<Place>> from Room
else flow { emit(repo.suggest(q)) } // cancelled by the next query
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onQueryChanged(q: String) { query.value = q }
}
List and map. Use Paging 3 with a RemoteMediator: the database is the source of truth, and the mediator is the signal that cached data ran out, so it fetches the next page into Room. Include the whole query (place, dates, guests, filters) in the page key so a filter change starts a fresh list. Prefetch the next page before the end (PagingConfig.prefetchDistance). For the map, query by viewport once the camera stops, debounce, cancel the previous box request, and cluster pins on the server.
Staleness. Treat every price as a quote. Items carry quoteId and quotedAt; show "Prices may have changed" on old cached pages, refresh on pull and on foreground after a long gap, and always revalidate at checkout: POST /holds takes the quoteId, and if the price differs the server answers 409 PRICE_CHANGED with the new price. The user accepts or goes back. A SOLD_OUT returns to results with the item marked. The client never computes the final total.
Offline. Confirmed bookings sit in trip with the confirmation code and address, so the user can show them at the door. Search shows cached pages with a stale banner; hold and pay are disabled with a reason.
Deep links. A confirmation email opens https://example.com/trips/<id>. On Android, verified App Links (intent filter with autoVerify, plus assetlinks.json on your domain) open the app without the disambiguation dialog. On iOS, use universal links. Validate every parameter: Apple's guide calls universal links a potential attack vector and says to discard malformed URLs. Route by id, authenticate first if signed out, show the cached trip, then refresh from the server. Never trust state encoded in the link.
Ride hailing: what changes. The skeleton (server-owned state, idempotent requests, state machine) is the same, but the constraint flips from scarce inventory to scarce supply in motion.
| Aspect | Hotel | Ride |
|---|---|---|
| Reserved | A room for dates, held for minutes | A driver, matched in real time |
| Timeout | Hold expiry | Matching timeout; show "no drivers", offer retry |
| Price | Quote, revalidated at checkout | Estimate shown before request |
| Live data | Almost none | Driver location stream |
| Payment | At booking | Often authorized up front, finalized after the trip |
| Offline | Saved bookings useful | Little value |
For live location, open a WebSocket (OkHttp, or URLSessionWebSocketTask on iOS) only while the trip screen is visible, interpolate the marker between points, and on reconnect fetch the latest trip state by id instead of replaying the stream. The ride request is idempotent like a booking, and after process death the client resumes by querying the active trip.
7. Failure modes and edge cases
| Scenario | Handling |
|---|---|
| Device clock wrong | Server duration plus monotonic clock; server enforces expiry |
| Double tap on Pay | ViewModel ignores while Submitting; idempotency key collapses duplicates |
| Response lost after booking created | Look up by attempt key; adopt server state; keep the same key |
| Hold expires during 3-D Secure | Server returns EXPIRED or voids per its rules; client shows server state, offers a re-check |
| Process killed in the bank app | booking_attempt in Room; startup reconciliation |
| User returns without finishing | Treat as unknown; fetch state; offer resume or cancel |
| Price changed since search | 409 with new price; user confirms |
| Last room sold | SOLD_OUT at hold; back to results |
| Late response for old search | Cancelled by flatMapLatest; keyed by query |
| Out-of-order updates | Compare version; ignore older |
8. How to build a booking app on iOS (Swift)
The design is the same. Only the primitives change.
| Concern | Android | iOS |
|---|---|---|
| Local DB | Room | SwiftData or Core Data (or GRDB) |
| Wallet | Google Pay API (PaymentsClient) | PKPaymentAuthorizationController |
| 3-D Secure return | PSP SDK; return_url via App Link | PSP SDK; return_url via universal link or custom scheme |
| Process death | SavedStateHandle (ids) + Room | Persisted attempt record, restored on launch |
| Deep links | App Links | Universal links (associated domains, NSUserActivity) |
| Monotonic clock | SystemClock.elapsedRealtime() | ContinuousClock |
| Cancel-previous search | flatMapLatest | Cancel the previous Task |
| Paging | Paging 3 + RemoteMediator | Cursor loading; no official paging library |
| Live location | OkHttp WebSocket | URLSessionWebSocketTask |
What is genuinely different:
- No durable retry framework is needed. Booking is online-only on both platforms, so there is no outbox, only the persisted attempt and launch-time reconciliation.
- Scene phase. SwiftUI's
ScenePhasereportsactive,inactiveandbackground. Use the return toactiveafter the bank page to fetch booking state, and close the driver socket when leaving the foreground. - The Apple Pay handler blocks the flow. Apple documents that the controller waits for you to call the handler before the next delegate method. Keep the server call short and let slow confirmation continue as
PROCESSING.
// Sketch: Apple Pay delegate sending the wallet token with the persisted attempt key
final class PayDelegate: NSObject, PKPaymentAuthorizationControllerDelegate {
let api: BookingAPI
let attemptKey: String
init(api: BookingAPI, attemptKey: String) { self.api = api; self.attemptKey = attemptKey }
func paymentAuthorizationController(
_ controller: PKPaymentAuthorizationController,
didAuthorizePayment payment: PKPayment,
handler completion: @escaping (PKPaymentAuthorizationResult) -> Void
) {
let token = payment.token.paymentData
Task {
do {
_ = try await api.confirm(idempotencyKey: attemptKey, walletToken: token)
completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
} catch {
completion(PKPaymentAuthorizationResult(status: .failure, errors: nil))
}
}
}
func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
controller.dismiss()
}
}
9. What changes by level
- Mid-level: a working flow (search, hold, pay), error states, cached trips, and an idempotency key on the pay request.
- Senior: server-authoritative time with a monotonic countdown, the server-driven state machine, reconciliation after process death and 3-D Secure returns, 409 revalidation, and cancellable debounced search. They raise double taps and lost responses unprompted.
- Staff: reconciliation across hold, payment and booking services, observability (checkout funnel, price-changed rate, unknown-outcome rate), staged rollout of payment changes behind flags, device risk signals, and a modular split (search, checkout, trips) so teams ship independently.
10. Follow-up questions to expect
- How do you support modify and cancel? (Same idempotent pattern with a version precondition; refund state comes from the server.)
- How do you handle two devices on one account? (Server state is the truth; trips sync by version; a hold belongs to the user.)
- How do you test checkout? (A fake API that drops the response after success, forces
PRICE_CHANGEDandEXPIRED, and sends out-of-order versions; kill the process mid-attempt.) - How do you measure it? (Hold-to-pay conversion, unknown-outcome rate, time to confirmation.)
Further reading (official docs)
- Idempotent requests (Stripe API): key generation, length, expiry, parameter comparison
- Authenticate with 3D Secure (Stripe):
requires_action, return URLs, SDK challenge flow - Google Pay API for Android: overview
- PaymentsClient reference (Google Pay)
- Offline-first guidance (Android): online-only, queued and lazy writes
- Architecture recommendations (Android)
- Data layer (Android): repositories, data sources, work lifetimes
- Paging 3 from network and database (Android)
- Save UI states with ViewModel and SavedStateHandle (Android)
- Android App Links
- Apple Pay (PassKit)
- PKPaymentAuthorizationController (Apple)
- PKPaymentAuthorizationControllerDelegate (Apple)
- Supporting universal links in your app (Apple)
- ScenePhase (Apple)
- URLSessionWebSocketTask (Apple)
Verification notes (last verified 13 Oct 2026)
Android architecture audited against the official guidance on 2026-10-13; remaining deviations: snippets omit dispatcher and scope injection, and the official docs do not cover payment recovery or hold timers. Verified against fetched docs: Stripe idempotency (V4 UUID suggestion, 255 character limit, 24 hour pruning for API v1, parameter comparison, saved response on retry) and 3DS (requires_action, return URL, wallets noted as not always supporting 3DS, charge.succeeded); Google Pay token flow, isReadyToPay, loadPaymentData; Android offline-first write strategies, RemoteMediator behavior, SavedStateHandle process-death scope and size guidance, App Links; Apple PKPaymentAuthorizationController and its delegate (via developer.apple.com JSON), universal link handling and security warning, ScenePhase, URLSessionWebSocketTask. Unverified: the 10 minute hold and 250 ms debounce are illustrative; PagingConfig.prefetchDistance is from general knowledge (the fetched page did not cover it); how Google Pay returns its result to the activity; SystemClock.elapsedRealtime and ContinuousClock semantics; the ride-hailing table (pre-authorization, matching) is industry practice, not a documented source; Apple Pay sheet time limits. Code is unrun: the Swift delegate sketch may warn under Swift 6 strict concurrency (non-Sendable completion captured in Task), and BookingAPI.confirm, ConfirmRequest, BookingDto, PlaceRepository are placeholders. The iOS sections have not yet been reviewed by an iOS engineer. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), kotlinx.coroutines 1.10.2, lifecycle 2.10.0, with stubs only for app-defined placeholders. The BookingApi Retrofit interface is unverified: missing cached retrofit2 artifacts. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders.
Keep reading
Follow new case studies
Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.
New case studies appear in the feed first. Any RSS reader works.