Low-level design · Wallets and payments
Digital wallet with atomic transfers
- Listed in prep guides
- Command
- Compensating action
- Lock ordering
- Idempotency key
- 1 source
Prep guides list it as a common prompt; no candidate write-up was found.
Design a digital wallet in which users hold a balance, add money and send money to each other. Every movement has to keep the books correct: a transfer either happens in full or not at all, nothing is lost or created when a step fails halfway, and a repeated request must not move the money twice.
Restated in our own words, not a quote from Paytm or any page. The answer below is written for this site.
Clarify first
Pin the scope down before you name a single class.
Questions to ask first: are balances kept in integer minor units (paise) or decimals, is there a maximum balance per wallet, can a wallet be frozen, is there one currency, must a client retry of the same request be safe, what should happen when two transfers touch the same wallet at the same time, and do we need a history that includes failed attempts.
The prep guide only says wallet questions test transaction integrity, concurrent writes and duplicate transactions, so the rest of this scope is an assumption. Assumed scope: integer paise, one currency, a balance cap and a frozen flag per wallet, top-ups from outside the system recorded as ledger entries, transfers between two wallets, an idempotency key supplied by the caller, and an append-only ledger whose entries are COMPLETED or FAILED. Out of scope: bank links, KYC, fees, refunds, multi-currency, authentication and persistence.
Entities and relationships
Who owns what, and who talks to whom.
- Wallet holds an id, a balance in integer paise, a cap and a frozen flag. Its
debitandcreditenforce their own rules and throw aWalletErrorwith a reason. - WalletService is the orchestrator. It owns the wallets, the lock manager, the ledger and a cache of results by idempotency key, and exposes
open,topUpandtransfer. Every money movement goes through one sharedexecutemethod. - Step is a plain object with
runandundo, a command with its compensating action. A transfer is the list debit then credit. - LockManager keeps one lock per wallet id with an owner.
tryAcquirereturns false instead of waiting, so a failed acquire is visible. - Ledger is an append-only list of entries with an id, type, from, to, amount, key and status.
forWalletreturns the history of one wallet. - Result cache maps an idempotency key to the result of its first execution.
The design
The class map, the main flow and the patterns that earn their place.
- A repeated idempotency key with the same request returns the stored result and stops. The same key with a different request is rejected.
- The wallets involved are locked in sorted id order. A failed lock returns busy.
- The steps run in order: debit the source, then credit the target.
- Each step changes a wallet and pushes its undo, which runs in reverse if a later step throws.
- A COMPLETED or FAILED entry is appended, the locks are released and the result is stored.
Open on the whiteboardAll 2 diagrams on one tidy board, with a step-by-step walkthrough.
Money. Balances and amounts are integers in paise, so no rounding error can appear. An amount that is fractional, zero or negative throws a RangeError before anything is touched, which keeps bad input apart from business failures such as insufficient funds.
Atomic transfer. The only way money moves is execute(key, entry, steps, lockIds). A transfer is two steps, debit the source and credit the target. Each step that succeeds pushes its undo onto a stack. If any step throws, the stack is popped and the undo actions run in reverse order, which restores the source, and a FAILED ledger entry is appended. If every step succeeds a COMPLETED entry is appended. The invariant is that the total across all wallets changes only through top-ups and that a failed transfer changes nothing; the checks assert both. Only a WalletError counts as a business failure. Any other error rolls the steps back, releases the locks and is rethrown with nothing recorded, because it is a bug and not a declined transfer.
The credit step is where late failures appear, since the target may be frozen or over its cap and that is only known when the credit is tried. A design that validates everything up front still needs the rollback, because a real credit goes through a database or another service that can fail.
Concurrency. Two transfers must not interleave their read, check and write on the same wallet. Each wallet has a lock, and execute acquires the locks of all wallets involved in sorted id order. Sorting matters: if one transfer locked w1 then w2 and another locked w2 then w1, each could hold one lock and wait forever for the other. A failed acquire releases whatever was taken and returns busy without recording anything, so the caller can retry with the same key. This single-threaded program shows the protocol only. A real service uses a database transaction that debits, credits and inserts the ledger rows together, with row locks taken in id order or an optimistic version column; the undo steps then become the transaction rollback.
Idempotency. The result of each key is cached, so a retried request returns the same result and money does not move twice. The cache also keeps a fingerprint of the request (type, source, target and amount), so the same key sent with a different request is rejected with an error instead of quietly returning the first result. A busy result is not cached because nothing happened. A FAILED result is cached, so the same key always gives the same failure and a new attempt needs a new key. In production this cache is a unique constraint on the key in the ledger table, not process memory.
Trade-off. Debit then credit with compensation is simple and works for in-process steps. Across services it needs a saga with durable state, because a crash between steps must be recoverable.
The code
Runnable JavaScript, with the checks it passes.
class WalletError extends Error {}
class Wallet {
constructor(id, cap) {
this.id = id;
this.cap = cap;
this.balance = 0;
this.frozen = false;
}
debit(amount) {
if (this.frozen) throw new WalletError("wallet frozen");
if (this.balance < amount) throw new WalletError("insufficient funds");
this.balance -= amount;
}
credit(amount) {
if (this.frozen) throw new WalletError("wallet frozen");
if (this.balance + amount > this.cap) throw new WalletError("balance cap exceeded");
this.balance += amount;
}
}
class LockManager {
constructor() {
this.held = new Map();
}
tryAcquire(id, owner) {
if (this.held.has(id) && this.held.get(id) !== owner) return false;
this.held.set(id, owner);
return true;
}
release(id, owner) {
if (this.held.get(id) === owner) this.held.delete(id);
}
}
class Ledger {
constructor() {
this.entries = [];
}
append(entry) {
const stored = { id: this.entries.length + 1, ...entry };
this.entries.push(stored);
return stored;
}
forWallet(id) {
return this.entries.filter((e) => e.from === id || e.to === id);
}
}
class WalletService {
constructor() {
this.wallets = new Map();
this.locks = new LockManager();
this.ledger = new Ledger();
this.results = new Map();
}
open(id, cap) {
if (!Number.isInteger(cap) || cap <= 0) throw new RangeError("cap must be positive whole minor units");
if (this.wallets.has(id)) throw new Error("wallet exists " + id);
this.wallets.set(id, new Wallet(id, cap));
}
wallet(id) {
const wallet = this.wallets.get(id);
if (!wallet) throw new Error("no wallet " + id);
return wallet;
}
total() {
return [...this.wallets.values()].reduce((sum, w) => sum + w.balance, 0);
}
topUp(id, amount, key) {
const wallet = this.wallet(id);
const steps = [{ run: () => wallet.credit(amount), undo: () => (wallet.balance -= amount) }];
return this.execute(key, { type: "TOP_UP", from: "external", to: id, amount }, steps, [id]);
}
transfer(from, to, amount, key) {
if (from === to) throw new Error("cannot transfer to the same wallet");
const source = this.wallet(from);
const target = this.wallet(to);
const steps = [
{ run: () => source.debit(amount), undo: () => (source.balance += amount) },
{ run: () => target.credit(amount), undo: () => (target.balance -= amount) },
];
return this.execute(key, { type: "TRANSFER", from, to, amount }, steps, [from, to]);
}
execute(key, entry, steps, lockIds) {
if (!Number.isInteger(entry.amount) || entry.amount <= 0) throw new RangeError("amount must be whole minor units");
const fingerprint = [entry.type, entry.from, entry.to, entry.amount].join("|");
const prior = this.results.get(key);
if (prior) {
if (prior.fingerprint !== fingerprint) throw new Error("key " + key + " was used for a different request");
return prior.result;
}
const owner = "tx:" + key;
const taken = [];
for (const id of [...lockIds].sort()) {
if (this.locks.tryAcquire(id, owner)) {
taken.push(id);
} else {
taken.forEach((held) => this.locks.release(held, owner));
return { ok: false, reason: "busy" };
}
}
const undo = [];
let result;
try {
for (const step of steps) {
step.run();
undo.push(step.undo);
}
result = { ok: true, entry: this.ledger.append({ ...entry, key, status: "COMPLETED" }) };
} catch (error) {
while (undo.length > 0) undo.pop()();
if (!(error instanceof WalletError)) throw error;
result = { ok: false, reason: error.message, entry: this.ledger.append({ ...entry, key, status: "FAILED" }) };
} finally {
taken.forEach((id) => this.locks.release(id, owner));
}
this.results.set(key, { fingerprint, result });
return result;
}
}Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.
Try your own input
Write JavaScript that calls the code above and prints with console.log. It runs after the solution.
Extend it
The change requests an interviewer throws next.
- How would you add a transfer fee that is paid into a platform wallet inside the same atomic operation?
- The process crashes after the debit and before the credit. What do you persist, and what happens on restart?
- How would you enforce a daily spending limit for each wallet?
- How would the lock protocol work when several service instances share the same wallets?
- How would you reverse a completed transfer later, for a dispute, while keeping the ledger append-only?
Where candidates slip
The mistakes this problem punishes.
Using floating point for balances. Checking the balance and writing it in two separate steps with no lock, so two transfers both pass the check. Taking locks in argument order, which can deadlock opposite transfers. An undo that forgets a step or runs the steps forward instead of in reverse. Writing a ledger entry only on success, so failed attempts vanish from the history. Applying a retried request twice, or treating a reused key as proof that the request is the same. Allowing a transfer from a wallet to itself. Treating a validation error and a business failure as the same kind of problem.
Have a go before you read.
Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.
Now make it stick
Where this was reported
Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.