Design the client and ledger of a wallet or peer-to-peer payments app: integer money, idempotency keys, lost responses and an invariant you can check.
Predict first
A response is lost one time in four and the client retries. What differs between a stable and a fresh key?
print("Double: ten payments of 0.10 sum to", (0..<10).reduce(0.0) { a, _ in a + 0.1 }, "== 1.0?", (0..<10).reduce(0.0) { a, _ in a + 0.1 } == 1.0)
struct Rng { var s: UInt64; mutating func next(_ n: Int) -> Int { s = s &* 6364136223846793005 &+ 1442695040888963407; return Int((s >> 33) % UInt64(n)) } }
// the server posts a transfer as two ledger entries (debit, credit) in integer cents; the response can be lost
final class Server {
var balance = ["alice": 100_000, "bob": 50_000]
var done: [String: Bool] = [:]; var applied = 0
func transfer(key: String, from: String, to: String, cents: Int) {
if done[key] != nil { return } // idempotency: same key, same effect
guard balance[from]! >= cents else { done[key] = false; return }
balance[from]! -= cents; balance[to]! += cents; done[key] = true; applied += 1
}
}
func simulate(stableKey: Bool) -> (intended: Int, applied: Int, total: Int, alice: Int) {
var rng = Rng(s: 3); let s = Server()
for i in 0..<300 {
let cents = 100 + rng.next(400)
var attempt = 0
while true {
let key = stableKey ? "op\(i)" : "op\(i)-try\(attempt)"
s.transfer(key: key, from: "alice", to: "bob", cents: cents)
attempt += 1
let responseLost = rng.next(4) == 0
if !responseLost || attempt == 4 { break } // client retries on timeout
}
}
return (300, s.applied, s.balance.values.reduce(0, +), s.balance["alice"]!)
}
for stable in [true, false] { let r = simulate(stableKey: stable)
print(stable ? "stable key per operation:" : "fresh key per attempt: ", "intended", r.intended, "applied", r.applied, "| money in the system", r.total, "| alice", r.alice) }
- Stable key: 300 applied; fresh key per attempt: 330 applied; the total money is 150000 in both
- Both apply 300
- Fresh keys apply 300 and stable keys apply 330
Mental model. A cheque number. If the bank sees number 1042 twice, it knows it is the same cheque presented again, not a second payment.
Start from invariants: money is neither created nor destroyed, and an operation takes effect exactly once even when the network lies. Represent amounts as integer minor units (cents) with a currency code, never Double: the check sums ten payments of 0.10 and gets 0.9999999999999999, which is not equal to 1.0. The server records a transfer as balanced ledger entries (debit one account, credit another) in one transaction, so the sum over all accounts never changes. In the simulation the two balances always sum to 150,000 cents. The client’s job is to survive a lost response: the transfer may have been applied although the app saw a timeout. So it sends a client-generated idempotency key with the request and reuses the same key on every retry (Q324), and the server returns the stored result for a key it has already seen.
The check compares the two designs over 300 transfers where one response in four is lost and the client retries up to four times. With a stable key per operation exactly 300 transfers are applied. With a fresh key per attempt 330 are applied: thirty customers were charged twice, and the total of money is still 150,000 because each duplicate is itself balanced, which is why the sum invariant alone does not catch this bug and you also need per-operation counts. Model each transfer as a persisted state machine (pending → posted or failed, with unknown after a timeout resolved by asking the server for the key’s status, never by assuming failure), show pending money distinctly in the UI, and reconcile balances from the server on launch. Add the usual guards: server-side limits and fraud checks, authentication before sending (Q351), no card or account data in logs, and a nightly reconciliation of the ledger against the processor. Real regulation (licensing, KYC) is outside this model.
The idempotency key makes a retry a replay, not a second transfer; the balance sum cannot detect duplicates.
Common traps
- Generating a new idempotency key on each retry. It defeats the key and double-charges, as the 330 versus 300 shows.
- Treating a timeout as failure. The outcome is unknown; query by key before offering a retry.
They might follow up with
- Why can the balances still sum correctly while customers are overcharged? Each duplicate is a balanced pair of entries; the invariant must include “one effect per operation”.
- Where is the key generated? On the client when the user confirms, and stored with the pending operation so it survives a crash.
Related questions
- 12.5.1 How should a client retry failed requests: what to retry, how long to wait, and what idempotency has to do…
- 26.1.4 Design a shopping app with cart and checkout: catalogue, cart merging, price authority, payment, idempotency…
- 14.5.1 How would you threat-model a consumer app that handles accounts, payments and personal data?
- 13.7.1 How would you design multi-device sync: identity, change tracking, conflict resolution, deletions and…
← Design a collaborative offline text editor: why index-based… Why does last-writer-wins by device clock lose edits, and how do… →