Android System Design Interview Questions
Design a checkout screen.
Tier: Less commonDifficulty: Hard
The interesting part of a checkout screen isn't the layout, it's that this is the one screen in most apps where getting a network failure wrong costs the business real money, a duplicate charge or a lost order. I'd spend most of the design budget there.
What I'd clarify first
- Does the app handle payment directly, or does it hand off to a payment gateway SDK, since that changes what data the client is even allowed to touch.
- Can the cart change between screens, prices, stock, promo eligibility, or is it locked the moment checkout starts.
- What happens if the app is killed mid-payment, does the business need the client to reconcile that state on relaunch.
The flow and its components
- Order summary, itemized cart, editable quantities, a promo code field that validates server-side, never client-side, since a discount rule the client enforces is a discount rule a user can bypass.
- Address and delivery selection, saved addresses plus an add-new flow, with delivery estimate and cost recalculated server-side whenever the address changes, not computed locally from a static table that can drift out of date.
- Payment method selection, saved cards or a new one entered through the payment provider's own SDK component, never a raw text field your app reads, that's how you end up needing PCI compliance you don't want. The provider's SDK returns a token, and only that token, never the card number, is what your backend ever sees.
- Review and place order, a final confirmation step that shows the exact total about to be charged, and is the point where the actual charge request fires.
The retry problem
The riskiest failure mode here is a timeout on the charge request. The request may have succeeded on the server and the client has no way to know. What was lost is the response, not the charge. Retrying blindly risks a duplicate charge, not retrying risks an order the user thinks failed but actually went through.
The fix is an idempotency key, generated once when the user taps "place order" and sent with every retry of that same charge attempt. The backend uses it to recognize a retried request as the same one and returns the original result instead of charging again. This is table stakes for any payment API, and worth naming explicitly, since it's the detail that separates a checkout design that's actually production-safe from one that just looks right on a happy path.
The key is bound to the quote it was minted for. If the user edits the cart after an attempt went unknown, that is a new quote and a new key. Reusing the old key would hand back the abandoned order's charge, and the screen would confirm an order the user no longer wants.
One more outcome belongs in the set. In most markets a card charge can come back asking the user to pass a bank challenge, 3D Secure, before the bank will answer at all. That is not a decline. The screen opens the challenge, and the retry after it carries the same key, so the provider ties the answer to the attempt it already knows.
The code
Small, and all of it about the retry problem above. The idempotency key is generated once when the user taps place order and written to disk before the charge fires, which is the detail that makes the difference. A key held in a ViewModel is lost exactly when it is needed, at the low memory kill in the middle of the payment.
Unknown does not last forever. A key older than the provider's idempotency window, usually a day, resolves as declined, because a key the provider has never seen is a charge that never arrived. The card details never appear because they never reach this code, the provider's SDK returns a token and only the token travels.
Java
com.androidinterview.checkout.order.CheckoutCoordinator.java
package com.androidinterview.checkout.order;
import com.androidinterview.checkout.payment.ChargeOutcome;
import com.androidinterview.checkout.payment.PaymentApi;
// The whole answer to the duplicate charge problem, and it is small.
//
// One idempotency key is generated when the user taps place order, and every
// retry of that tap carries the same key. The backend recognises the second
// request as the first one and returns the original result rather than
// charging again. Without it, retrying risks charging twice and not retrying
// risks an order the user believes failed.
public final class CheckoutCoordinator {
// One attempt. The key is bound to the quote it was minted for, so an
// edited cart is a new attempt, and the time it started is what lets an
// unknown outcome eventually resolve.
public record Attempt(String idempotencyKey, String quoteId, long startedAtMillis) {
}
// A one row table, written in one transaction, so the attempt survives the
// process dying. A key held in a ViewModel is lost exactly when it is
// needed, which is the crash or the low memory kill mid charge.
public interface AttemptStore {
void save(Attempt attempt);
Attempt pending();
void clear();
}
// How long the provider remembers a key. Past this a lookup answers not
// found, and a key the provider has never seen is a charge that never
// arrived.
public static final long KEY_WINDOW_MILLIS = 24 * 60 * 60 * 1000L;
private final AttemptStore store;
private final PaymentApi api;
public CheckoutCoordinator(AttemptStore store, PaymentApi api) {
this.store = store;
this.api = api;
}
public ChargeOutcome placeOrder(Quote quote, String paymentToken, String freshKey, long nowMillis) {
// Not a decline. Nothing was sent, the screen refreshes the total.
if (!quote.valid(nowMillis)) return new ChargeOutcome.QuoteExpired();
// Reuse the key on disk only for the same quote. That is a retry of the
// same tap, even across a restart. A different quote means the user
// edited the cart, and sending it under the old key would hand back the
// abandoned order's charge as if it were this one.
Attempt pending = store.pending();
Attempt attempt = pending != null && pending.quoteId().equals(quote.quoteId())
? pending
: new Attempt(freshKey, quote.quoteId(), nowMillis);
store.save(attempt);
return settle(api.charge(attempt.idempotencyKey(), quote.quoteId(), paymentToken));
}
// Run on launch. A charge that was in flight when the app died is resolved
// by asking the provider what became of the key, which is why the key had
// to be on disk rather than in memory.
public ChargeOutcome reconcileOnLaunch(long nowMillis) {
Attempt pending = store.pending();
if (pending == null) return null;
if (nowMillis - pending.startedAtMillis() > KEY_WINDOW_MILLIS) {
store.clear();
return new ChargeOutcome.Declined("the charge never reached the provider");
}
return settle(api.lookup(pending.idempotencyKey()));
}
// Only a final answer clears the attempt. Unknown keeps it for the next
// launch, and a bank challenge keeps it because the retry after the
// redirect has to carry the same key. There is no optimistic success here
// on purpose, a confirmed screen that later turns out to be a failed charge
// is worse than a short honest wait.
private ChargeOutcome settle(ChargeOutcome outcome) {
if (outcome instanceof ChargeOutcome.Confirmed || outcome instanceof ChargeOutcome.Declined) {
store.clear();
}
return outcome;
}
}
com.androidinterview.checkout.order.Quote.java
package com.androidinterview.checkout.order;
// The total, computed by the server and carried as an opaque quote. The client
// renders it and never recomputes it, because a promo rule the client enforces
// is a promo rule a user can bypass, and a price the client adds up is a price
// that drifts the moment stock or a discount changes between two screens.
//
// Money is a long of minor units. A double for a price is a rounding bug with
// a delivery date.
public record Quote(String quoteId, long totalMinor, String currency, long expiresAt) {
// A quote that expired has to be refetched before the charge fires, not
// charged and reconciled afterwards. This is the cheap half of the price
// race, and the expensive half is on the backend.
public boolean valid(long nowMillis) {
return expiresAt > nowMillis;
}
}
com.androidinterview.checkout.payment.ChargeOutcome.java
package com.androidinterview.checkout.payment;
// The honest outcomes of a charge. Unknown is the one most designs are missing.
// A timeout is not a failure, it is a lost response, and the money may well
// have moved. RequiresAction is the other one they miss, the bank wants the
// user to pass a challenge before it will answer at all.
public sealed interface ChargeOutcome {
record Confirmed(String orderId) implements ChargeOutcome {
}
record Declined(String reason) implements ChargeOutcome {
}
// 3D Secure, or strong customer authentication. Not a decline. The screen
// opens the challenge, and the retry afterwards carries the same key.
record RequiresAction(String challengeUrl) implements ChargeOutcome {
}
record Unknown() implements ChargeOutcome {
}
// Not a charge outcome. The total went stale before anything was sent, so
// the screen refreshes the quote rather than saying the card was refused.
record QuoteExpired() implements ChargeOutcome {
}
}
com.androidinterview.checkout.payment.PaymentApi.java
package com.androidinterview.checkout.payment;
// The gateway SDK sits behind this. The card number never reaches our code and
// never reaches our backend, only the token the provider hands back, which is
// the difference between a payment screen and a PCI audit.
public interface PaymentApi {
ChargeOutcome charge(String idempotencyKey, String quoteId, String paymentToken);
// The reconciliation path. Asking what happened to a key is what turns an
// unknown into a real answer, and it is the call people forget to ask the
// payment provider for.
ChargeOutcome lookup(String idempotencyKey);
}
Kotlin
com.androidinterview.checkout.order.Checkout.kt
package com.androidinterview.checkout.order
import com.androidinterview.checkout.payment.ChargeOutcome
import com.androidinterview.checkout.payment.PaymentApi
// The total, computed by the server and carried as an opaque quote. The client
// renders it and never recomputes it, because a promo rule the client enforces
// is a promo rule a user can bypass, and a price the client adds up is one
// that drifts the moment stock or a discount changes between two screens.
//
// Money is a Long of minor units. A Double for a price is a rounding bug with
// a delivery date.
data class Quote(
val quoteId: String,
val totalMinor: Long,
val currency: String,
val expiresAt: Long,
) {
// A quote that expired is refetched before the charge fires, rather than
// charged and reconciled afterwards. This is the cheap half of the price
// race, and the expensive half is on the backend.
fun valid(nowMillis: Long) = expiresAt > nowMillis
}
// One attempt. The key is bound to the quote it was minted for, so an edited
// cart is a new attempt, and the time it started is what lets an unknown
// outcome eventually resolve.
data class Attempt(val idempotencyKey: String, val quoteId: String, val startedAtMillis: Long)
// A one row table, written in one transaction, so the attempt survives the
// process dying. A key held in a ViewModel is lost exactly when it is needed,
// which is the crash or the low memory kill mid charge.
interface AttemptStore {
var pending: Attempt?
}
// The whole answer to the duplicate charge problem, and it is small.
//
// One idempotency key is generated when the user taps place order, and every
// retry of that tap carries the same key. The backend recognises the second
// request as the first and returns the original result rather than charging
// again. Without it, retrying risks charging twice and not retrying risks an
// order the user believes failed.
class CheckoutCoordinator(private val store: AttemptStore, private val api: PaymentApi) {
suspend fun placeOrder(
quote: Quote,
paymentToken: String,
freshKey: String,
nowMillis: Long,
): ChargeOutcome {
// Not a decline. Nothing was sent, the screen refreshes the total.
if (!quote.valid(nowMillis)) return ChargeOutcome.QuoteExpired
// Reuse the key on disk only for the same quote. That is a retry of the
// same tap, even across a restart. A different quote means the user
// edited the cart, and sending it under the old key would hand back the
// abandoned order's charge as if it were this one.
val attempt = store.pending
?.takeIf { it.quoteId == quote.quoteId }
?: Attempt(freshKey, quote.quoteId, nowMillis)
store.pending = attempt
return api.charge(attempt.idempotencyKey, quote.quoteId, paymentToken).also(::settle)
}
// Run on launch. A charge in flight when the app died is resolved by
// asking the provider what became of the key, which is why the key had to
// be on disk rather than in memory.
suspend fun reconcileOnLaunch(nowMillis: Long): ChargeOutcome? {
val attempt = store.pending ?: return null
if (nowMillis - attempt.startedAtMillis > KEY_WINDOW_MILLIS) {
store.pending = null
return ChargeOutcome.Declined("the charge never reached the provider")
}
return api.lookup(attempt.idempotencyKey).also(::settle)
}
// Only a final answer clears the attempt. Unknown keeps it for the next
// launch, and a bank challenge keeps it because the retry after the
// redirect has to carry the same key. There is no optimistic success here
// on purpose, a confirmed screen that later turns out to be a failed
// charge is worse than a short honest wait.
private fun settle(outcome: ChargeOutcome) {
if (outcome is ChargeOutcome.Confirmed || outcome is ChargeOutcome.Declined) store.pending = null
}
companion object {
// How long the provider remembers a key. Past this a lookup answers
// not found, and a key the provider has never seen is a charge that
// never arrived.
const val KEY_WINDOW_MILLIS = 24 * 60 * 60 * 1000L
}
}
com.androidinterview.checkout.payment.Payment.kt
package com.androidinterview.checkout.payment
// The honest outcomes of a charge. Unknown is the one most designs are missing.
// A timeout is not a failure, it is a lost response, and the money may well
// have moved. RequiresAction is the other one they miss, the bank wants the
// user to pass a challenge before it will answer at all.
sealed interface ChargeOutcome {
data class Confirmed(val orderId: String) : ChargeOutcome
data class Declined(val reason: String) : ChargeOutcome
// 3D Secure, or strong customer authentication. Not a decline. The screen
// opens the challenge, and the retry afterwards carries the same key.
data class RequiresAction(val challengeUrl: String) : ChargeOutcome
data object Unknown : ChargeOutcome
// Not a charge outcome. The total went stale before anything was sent, so
// the screen refreshes the quote rather than saying the card was refused.
data object QuoteExpired : ChargeOutcome
}
// The gateway SDK sits behind this. The card number never reaches our code and
// never reaches our backend, only the token the provider hands back, which is
// the difference between a payment screen and a PCI audit.
interface PaymentApi {
suspend fun charge(idempotencyKey: String, quoteId: String, paymentToken: String): ChargeOutcome
// Asking what happened to a key is what turns an unknown into a real
// answer, and it is the call people forget to ask the provider for.
suspend fun lookup(idempotencyKey: String): ChargeOutcome
}
The backoff and outbox that a retry sits inside are written out under handling data syncing on an unstable network.
Tradeoffs I'd call out
- Optimistic UI vs waiting for confirmation. Showing a success state immediately after tapping "place order" feels fast, but if the charge later fails you have to walk that back. For payment I'd wait for confirmation, this is not the screen to feel fast at the cost of being wrong.
- Client-side price calculation vs always trusting the server. Recomputing totals locally as the user edits quantities feels instant. The price actually charged still has to come from a server response right before the charge fires, otherwise a stale promo or a stock change between screens charges the wrong amount.
- One long checkout screen vs a multi-step flow. A single screen has less navigation overhead. A multi-step flow, address then payment then review, lets you show each error at the point it happened rather than five at once after one big submit.
What breaks at scale, offline, and on a poor connection
Offline, checkout shouldn't pretend to work, disable the place-order action and show a clear "no connection" state rather than letting a user tap into a request that will hang or fail confusingly. On a poor connection, the charge request needs a generous but finite timeout paired with the idempotency key above, so a slow response gets retried safely instead of either double-charging or leaving the user stuck on a spinner forever. At scale, the failure to design around is a promo code or inventory check that races two concurrent checkouts for the last unit of stock, that has to be resolved atomically on the backend, the client only ever finds out after the fact whether it won that race.
Watch