Why synchronised methods still overdraw
The first version most candidates write gives Wallet a synchronised getBalance() and a synchronised debit(), and calls them one after the other from the transfer method. Each method is thread-safe. The transfer is not, because the rule "balance never goes negative" spans both calls, and another thread can run between them.
That gap is the whole question. Two concurrent transfers out of the same wallet both call getBalance(), both see 100, both decide 80 is affordable, and both debit. No individual method misbehaved, yet the invariant is broken. The fix is not more synchronized keywords but a wider critical section: the decision and the change it justifies have to sit under the same lock, with nothing in between that another thread can observe.
The same reasoning rules out a lone AtomicLong per wallet for transfers. A compare-and-set loop can keep one balance non-negative, but debiting one wallet and crediting another are then two separate atomic steps. A reader summing all balances between them sees money that has left one wallet and not arrived in the other.
A transfer that holds both locks in id order
A transfer touches two wallets, so it needs both locks, and two locks invite a deadlock. Alice paying Bob takes Alice's lock then Bob's; Bob paying Alice at the same instant takes Bob's then Alice's; each waits for the other forever. The cure is to derive the order from the wallets rather than from the direction of the payment, exactly as the deadlock page for two locks argues in general.
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
public final class WalletService {
public record TransferRequest(String fromId, String toId, long amountMinor) {}
public enum Status { COMPLETED, INSUFFICIENT_FUNDS }
public record Receipt(TransferRequest request, Status status) {}
private static final class Wallet {
final String id;
final boolean mayOverdraw;
final ReentrantLock lock = new ReentrantLock();
private long balanceMinor;
private final List<Long> entries = new ArrayList<>();
Wallet(String id, boolean mayOverdraw) { this.id = id; this.mayOverdraw = mayOverdraw; }
void post(long deltaMinor) {
entries.add(deltaMinor);
balanceMinor += deltaMinor;
}
}
Map<String, Wallet> wallets = <>();
Map<String, CompletableFuture<Receipt>> byKey = <>();
{ wallets.put(, (, )); }
{ wallets.putIfAbsent(id, (id, )); }
{
wallet(id);
w.lock.lock();
{ w.balanceMinor; } { w.lock.unlock(); }
}
Receipt {
(req.amountMinor() <= ) ();
(req.fromId().equals(req.toId())) ();
wallet(req.fromId());
wallet(req.toId());
CompletableFuture<Receipt> mine = <>();
CompletableFuture<Receipt> earlier = byKey.putIfAbsent(idempotencyKey, mine);
(earlier != ) {
earlier.join();
(!first.request().equals(req)) ();
first;
}
{
move(from, to, req);
mine.complete(result);
result;
} (RuntimeException e) {
byKey.remove(idempotencyKey, mine);
mine.completeExceptionally(e);
e;
}
}
Receipt {
from.id.compareTo(to.id) < ? from : to;
(first == from) ? to : from;
first.lock.lock();
{
second.lock.lock();
{
(!from.mayOverdraw && from.balanceMinor < req.amountMinor()) {
(req, Status.INSUFFICIENT_FUNDS);
}
from.post(-req.amountMinor());
to.post(req.amountMinor());
(req, Status.COMPLETED);
} { second.lock.unlock(); }
} { first.lock.unlock(); }
}
Wallet {
wallets.get(id);
(w == ) ( + id);
w;
}
}
The lock is per wallet, so transfers between unrelated pairs never wait for each other. Validation and the wallet lookup happen before the idempotency key is reserved, so a malformed request cannot claim a key.
Entries, not just a number
Each digital wallet transfer writes a debit on one wallet and a matching credit on the other, inside the same locks. That pair is a small version of double-entry bookkeeping, and it buys you a second invariant: the sum of every balance in the system, including the funding account, is zero after every completed transfer. Top-ups are transfers out of FUNDING, the one account permitted to go negative, because its negative balance mirrors real money held outside the system.
The cached balanceMinor exists for speed; the entries are the truth, and replaying them must reproduce it. If the interviewer wants the production-grade version, with immutable rows, corrections by compensating entry and per-currency accounts, that is the ledger with provable balances. Money is a long of minor units because a double cannot represent 0.10 exactly, and repeated additions drift.
Idempotency keys make retries safe
A mobile client that times out will retry, and it cannot know whether the first request landed. The client sends the same idempotency key with both. The first call reserves the key with putIfAbsent; any duplicate, even one arriving while the first is still holding the locks, waits on the same future and returns the same receipt, including an INSUFFICIENT_FUNDS answer. Reusing a key for a different amount is a client bug, and the request comparison rejects it loudly. In a real service the key map needs a retention window, or it grows forever. The general HTTP shape of this is covered in making a POST endpoint idempotent.
Proving concurrent transfers never overdraw
"It looks right" is not evidence. Write a test that opens ten digital wallets, funds each with 1,000, then starts many threads behind a CountDownLatch so they begin together. Each thread fires thousands of concurrent transfers with random pairs and random amounts. When they finish, assert two things: no wallet other than funding is below zero, and all balances, funding included, sum to zero. Repeat it in a loop, because a race that fires once in a few hundred runs is still a race.
Then break the code on purpose. Move the balance check outside the locks and rerun the test, which should now go red. A test that cannot catch the bug it was written for proves nothing, and showing that it does is the strongest thing you can do in the last ten minutes of the round.
The invariant lives in one line: the comparison and the debit under the same locks. Everything else (id-ordered locking, paired entries, idempotency keys) exists so that line stays true under concurrent transfers, retries and audits.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).