Mobile Design Rounds
Case studyAndroid + iOS13 min read

Design a hotel or ride booking app (Android and iOS): holds, payments and recovery

Last verified . Drafted with AI assistance and checked against official documentation (how this is verified). Part of: The framework · Offline-first guide · Staff-level guide.

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):

ConcernTarget
MoneyNever charged twice, never charged without a booking
InventoryNo double booking; the server enforces the hold
TimeCountdown correct even with a wrong device clock
SearchSuggestions feel instant; stale responses never overwrite fresh ones
OfflineSaved bookings readable; booking disabled with a clear reason
RecoveryKilling 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)

readsobservesREST: server owns the truthpaytokeneventsUIsearch, checkout, tripsState holderViewModelRepositoriessearch, booking, tripsLocal databaseattempts, recents, tripsPayment sheetGoogle Pay / Apple PayPayment providertoken, 3-D SecureBackendinventory, holds
Booking app architecture: the UI observes ViewModel state; repositories call a server that owns inventory, price and booking state; payment goes through a wallet and a payment provider.

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.

  1. 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.
  2. 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) and result_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.

  1. POST /holds returns expiresInMs.
  2. 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.
  3. Use a monotonic clock (SystemClock.elapsedRealtime()), which does not jump when the user changes the date.
  4. 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.
  5. 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_attempt before 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 Pay while the state is Submitting. 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.isReadyToPay gates the button and loadPaymentData opens the sheet.
  • Apple Pay: PKPaymentAuthorizationController presents the sheet; in didAuthorizePayment you 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 a return_url that 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.

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.

AspectHotelRide
ReservedA room for dates, held for minutesA driver, matched in real time
TimeoutHold expiryMatching timeout; show "no drivers", offer retry
PriceQuote, revalidated at checkoutEstimate shown before request
Live dataAlmost noneDriver location stream
PaymentAt bookingOften authorized up front, finalized after the trip
OfflineSaved bookings usefulLittle 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

ScenarioHandling
Device clock wrongServer duration plus monotonic clock; server enforces expiry
Double tap on PayViewModel ignores while Submitting; idempotency key collapses duplicates
Response lost after booking createdLook up by attempt key; adopt server state; keep the same key
Hold expires during 3-D SecureServer returns EXPIRED or voids per its rules; client shows server state, offers a re-check
Process killed in the bank appbooking_attempt in Room; startup reconciliation
User returns without finishingTreat as unknown; fetch state; offer resume or cancel
Price changed since search409 with new price; user confirms
Last room soldSOLD_OUT at hold; back to results
Late response for old searchCancelled by flatMapLatest; keyed by query
Out-of-order updatesCompare version; ignore older

8. How to build a booking app on iOS (Swift)

The design is the same. Only the primitives change.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
WalletGoogle Pay API (PaymentsClient)PKPaymentAuthorizationController
3-D Secure returnPSP SDK; return_url via App LinkPSP SDK; return_url via universal link or custom scheme
Process deathSavedStateHandle (ids) + RoomPersisted attempt record, restored on launch
Deep linksApp LinksUniversal links (associated domains, NSUserActivity)
Monotonic clockSystemClock.elapsedRealtime()ContinuousClock
Cancel-previous searchflatMapLatestCancel the previous Task
PagingPaging 3 + RemoteMediatorCursor loading; no official paging library
Live locationOkHttp WebSocketURLSessionWebSocketTask

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 ScenePhase reports active, inactive and background. Use the return to active after 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

  1. How do you support modify and cancel? (Same idempotent pattern with a version precondition; refund state comes from the server.)
  2. How do you handle two devices on one account? (Server state is the truth; trips sync by version; a hold belongs to the user.)
  3. How do you test checkout? (A fake API that drops the response after success, forces PRICE_CHANGED and EXPIRED, and sends out-of-order versions; kill the process mid-attempt.)
  4. How do you measure it? (Hold-to-pay conversion, unknown-outcome rate, time to confirmation.)

Further reading (official docs)

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.