Android System Design Interview Questions
Design a checkout screen.
Tier: Less commonDifficulty: Hard
Design the checkout flow that turns a shopping cart into a confirmed order. The customer reviews items, address and total, chooses payment and sees whether the order succeeded, failed or is still being checked.
The problem
Cover the Android screen state, the server calls and recovery after interruption. The server must validate current prices and stock. The app must keep track of an in progress checkout across rotation or restart, and repeating a submission must not create another order or charge.
For example, Ana taps Pay and loses her connection before a response arrives. Payment may already have succeeded. Reopening checkout should check that same attempt and eventually show its result. The design needs a pending state because a missing response does not establish failure.
Assume a payment provider handles payment details. Focus on coordinating cart validation, payment and order confirmation, including what the customer sees at each step.
Starting the design
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 during 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 request key belongs to the quote it was created for. If the user edits the cart while payment status is unknown, check the existing attempt first. The new cart must not replace that attempt or use its key.
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.
A payment provider forgetting a request key does not prove the payment failed. The backend keeps its own order record and checks with the provider. If it still cannot confirm success or failure, the attempt stays unknown and needs further investigation before another charge is allowed. For example, Stripe documents that a reused key can create a new request after the old key is pruned. Card details stay in the payment SDK, and only its token crosses this API.
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.
//
// Persist a stable identity before submission. Later taps reconcile the
// existing attempt. The backend owns safe provider retry and durable lookup.
// The owner serializes calls; a real Room store also makes attempt creation
// conditional so independent entry points cannot submit separate attempts.
public final class CheckoutCoordinator {
// A quote cannot change while its attempt is unresolved. The timestamp
// is for diagnostics, never proof that no charge occurred.
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();
}
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) {
// Resolve an existing attempt before considering this tap's quote.
Attempt pending = store.pending();
if (pending != null) return settle(api.lookup(pending.idempotencyKey()));
if (!quote.valid(nowMillis)) return new ChargeOutcome.QuoteExpired();
Attempt attempt = 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() {
Attempt pending = store.pending();
if (pending == null) return null;
// Key expiry is not a decline. Consult the backend's durable ledger.
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 reconciles the same attempt rather than creating a charge. 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;
}
}
package com.androidinterview.checkout.order;
import com.androidinterview.checkout.payment.ChargeOutcome;
import com.androidinterview.checkout.payment.PaymentApi;
public final class CheckoutCoordinator {
public record Attempt(String idempotencyKey, String quoteId, long startedAtMillis) {
}
public interface AttemptStore {
void save(Attempt attempt);
Attempt pending();
void clear();
}
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) {
Attempt pending = store.pending();
if (pending != null) return settle(api.lookup(pending.idempotencyKey()));
if (!quote.valid(nowMillis)) return new ChargeOutcome.QuoteExpired();
Attempt attempt = new Attempt(freshKey, quote.quoteId(), nowMillis);
store.save(attempt);
return settle(api.charge(attempt.idempotencyKey(), quote.quoteId(), paymentToken));
}
public ChargeOutcome reconcileOnLaunch() {
Attempt pending = store.pending();
if (pending == null) return null;
return settle(api.lookup(pending.idempotencyKey()));
}
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;
}
}
package com.androidinterview.checkout.order;
public record Quote(String quoteId, long totalMinor, String currency, long expiresAt) {
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 {
}
}
package com.androidinterview.checkout.payment;
public sealed interface ChargeOutcome {
record Confirmed(String orderId) implements ChargeOutcome {
}
record Declined(String reason) implements ChargeOutcome {
}
record RequiresAction(String challengeUrl) implements ChargeOutcome {
}
record Unknown() implements ChargeOutcome {
}
record QuoteExpired() implements ChargeOutcome {
}
}
com.androidinterview.checkout.payment.PaymentApi.java
package com.androidinterview.checkout.payment;
// The authenticated backend sits behind this API and keeps a durable order
// ledger. Card details remain in the payment SDK; this API receives its token.
// Unknown or missing provider history must not be mapped to Declined.
// Only an authoritative final failure may return Declined.
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);
}
package com.androidinterview.checkout.payment;
public interface PaymentApi {
ChargeOutcome charge(String idempotencyKey, String quoteId, String paymentToken);
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. Its quote cannot change while its outcome is unresolved.
// The timestamp is useful for diagnostics, never proof that no charge occurred.
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.
//
// Persist a stable identity before submission. Later taps reconcile the
// existing attempt. The backend owns any safe provider retry and durable
// lookup, even after the provider forgets an idempotency key.
// The owner serializes calls; a real Room store also makes attempt creation
// conditional so two entry points cannot create separate pending attempts.
class CheckoutCoordinator(private val store: AttemptStore, private val api: PaymentApi) {
suspend fun placeOrder(
quote: Quote,
paymentToken: String,
freshKey: String,
nowMillis: Long,
): ChargeOutcome {
// Resolve an existing attempt before considering this tap's quote.
// Even a different cart must not overwrite an unknown payment.
store.pending?.let { pending ->
return api.lookup(pending.idempotencyKey).also(::settle)
}
if (!quote.valid(nowMillis)) return ChargeOutcome.QuoteExpired
val attempt = 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(): ChargeOutcome? {
val attempt = store.pending ?: return null
// Provider key expiry is not a decline. The backend's durable order
// ledger must resolve it; an unavailable answer stays Unknown.
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 SDK continuation and
// later lookup refer to that same attempt. 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
}
}
package com.androidinterview.checkout.order
import com.androidinterview.checkout.payment.ChargeOutcome
import com.androidinterview.checkout.payment.PaymentApi
data class Quote(
val quoteId: String,
val totalMinor: Long,
val currency: String,
val expiresAt: Long,
) {
fun valid(nowMillis: Long) = expiresAt > nowMillis
}
data class Attempt(val idempotencyKey: String, val quoteId: String, val startedAtMillis: Long)
interface AttemptStore {
var pending: Attempt?
}
class CheckoutCoordinator(private val store: AttemptStore, private val api: PaymentApi) {
suspend fun placeOrder(
quote: Quote,
paymentToken: String,
freshKey: String,
nowMillis: Long,
): ChargeOutcome {
store.pending?.let { pending ->
return api.lookup(pending.idempotencyKey).also(::settle)
}
if (!quote.valid(nowMillis)) return ChargeOutcome.QuoteExpired
val attempt = Attempt(freshKey, quote.quoteId, nowMillis)
store.pending = attempt
return api.charge(attempt.idempotencyKey, quote.quoteId, paymentToken).also(::settle)
}
suspend fun reconcileOnLaunch(): ChargeOutcome? {
val attempt = store.pending ?: return null
return api.lookup(attempt.idempotencyKey).also(::settle)
}
private fun settle(outcome: ChargeOutcome) {
if (outcome is ChargeOutcome.Confirmed || outcome is ChargeOutcome.Declined) store.pending = null
}
}
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 authenticated backend sits behind this API and keeps a durable order
// ledger. Card details remain in the payment SDK; this API receives its token.
// Unknown or missing provider history must not be mapped to Declined.
// Only an authoritative final failure may return Declined.
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
}
package com.androidinterview.checkout.payment
sealed interface ChargeOutcome {
data class Confirmed(val orderId: String) : ChargeOutcome
data class Declined(val reason: String) : ChargeOutcome
data class RequiresAction(val challengeUrl: String) : ChargeOutcome
data object Unknown : ChargeOutcome
data object QuoteExpired : ChargeOutcome
}
interface PaymentApi {
suspend fun charge(idempotencyKey: String, quoteId: String, paymentToken: String): ChargeOutcome
suspend fun lookup(idempotencyKey: String): ChargeOutcome
}
This coordinator handles user actions one at a time and saves attempts to persistent storage. Repeated taps and restored screens check a pending attempt instead of creating another charge. The backend decides whether a provider request can be retried with the same payload and key. A Room implementation also needs to prevent two callers from creating an attempt at once. The retry delay is explained in handling data syncing on an unstable network.
How I'd build this on Android
I'd keep the form state in CheckoutViewModel and put quotes, payment requests and order status checks in CheckoutRepository. If several screens share validation rules, those rules can live in PlaceOrderUseCase. Room stores the attempt ID, quote ID and order status, never card numbers or security codes. Finishing the payment SDK flow does not prove the server confirmed the order.
sealed interface CheckoutState {
data object Ready : CheckoutState
data object Submitting : CheckoutState
data class PendingConfirmation(val attemptId: String) : CheckoutState
data class Confirmed(val orderId: String) : CheckoutState
data class Rejected(val reason: String) : CheckoutState
}
interface CheckoutRepository {
// Commits the attempt ID before calling the server. A retry reuses it.
suspend fun submitOrReconcile(quoteId: String): CheckoutState
fun observeAttempt(): Flow<CheckoutState>
}
class CheckoutViewModel(private val repository: CheckoutRepository) : ViewModel() {
private val _state = MutableStateFlow<CheckoutState>(CheckoutState.Ready)
val state = _state.asStateFlow()
private var submitJob: Job? = null
fun placeOrder(quoteId: String) {
if (submitJob?.isActive == true) return
submitJob = viewModelScope.launch {
_state.value = CheckoutState.Submitting
// Repository maps an ambiguous timeout to PendingConfirmation.
_state.value = repository.submitOrReconcile(quoteId)
}
}
}
The example prevents two clicks from starting work at once. The full ViewModel also reads the saved attempt when the screen opens. If that attempt is unresolved, it checks its status before allowing another payment. The server uses the same attempt ID to recognise retries, which is what idempotency means here. A disabled button alone cannot prevent a duplicate charge after an app restart.
The Compose route collects state with collectAsStateWithLifecycle(). It shows field errors next to the input and disables Submit while payment is pending. An effect keyed by the saved attempt ID launches the SDK. Its callback goes back through the repository. After a browser return or app restart, the repository asks the server for the order status before showing success.
I'd pass the API, attempt DAO and payment adapter into the repository's constructor. An SDK launcher that needs an Activity stays in the UI. WorkManager can check an existing payment, but it cannot approve a new charge for the user. An offline retry must not silently create a payment to send later. The Notes example shows how to create and inject these dependencies.
I'd check a few failure cases in the interview.
- The server charges the user, but the response never arrives. Retry checks the same attempt.
- The screen rotates during the SDK flow, or the SDK sends its callback twice.
- The quote expires before submission.
- The app restarts while payment status is still unknown.
I'd measure how many payments stay pending and how long they take to resolve.
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 charging twice 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