vishal maurya

LLD Series: Digital Wallet (Low-Level Design)

Overview

Part of the Interview Prep series. Dotted-underline words are glossary terms: hover for the meaning or open the Glossary. Track your progress on the Interview Prep tracker. Series index: Start here.

1. Problem statement and how it is asked

Typical phrasing: "Design a digital wallet (Paytm / PhonePe / PayPal style). Users can add money, transfer to other users, and view history. Make transfers safe under concurrent requests, idempotent on retries, and enforce limits. Notify the user on every transaction."

  • Asked at: PayU, Visa, Mastercard, NPCI, Amex - this is the core "money movement" LLD. Expect depth on concurrency, idempotency and ledgering.
  • Interviewer is checking: no lost updates / no deadlock in A->B and B->A, idempotency keys, double-entry ledger, long/BigDecimal money, limits as pluggable rules, Observer for notifications fired outside locks.

2. Requirements clarification

Functional

  1. Create wallet per user. Top-up (money in from bank/UPI), wallet-to-wallet transfer, balance, transaction history (ledger).
  2. Limits: per-transaction cap, daily spend cap, max balance for the receiver (KYC tier style).
  3. Every request carries an idempotency key; retries return the original result and never double-debit.
  4. Failed attempts (insufficient funds, limit) are recorded as FAILED transactions with a reason.
  5. Notifications (SMS/email/push) for every outcome.
  6. Wallet can be frozen.

Non-functional

  • Strong consistency per wallet: balance never negative (user wallets), money is conserved.
  • Throughput: unrelated wallets never block each other.
  • Auditability: append-only ledger, double entry (debits = credits).

Assumptions: single currency, INR in paise (long); the "outside world" is modelled by a SYSTEM settlement wallet (so every movement is double-entry, even top-ups); in-memory store.

Out of scope: KYC, refunds/chargebacks (follow-up), bank linking, multi-currency, fraud engine, persistence (discussed).

3. Entities, relationships, class diagram

 +-----------------+  1     *  +------------------+
 |     Wallet      |<----------|   LedgerEntry    |  (append-only; 1 DEBIT + 1 CREDIT per txn)
 | id, ownerId     |           | txnId, type,     |
 | type USER/SYSTEM|           | amount, balAfter |
 | balance + lock  |           +------------------+
 +--------^--------+                    ^
          | uses                         | appends
 +--------+--------------------------------------------+
 |                 WalletService                       |---> Ledger
 | topUp() / transfer()  -> execute(idempotencyKey,..) |
 | idempotency: Map<key, Future<Txn>>                  |
 +----+-------------------+----------------------+-----+
      | rules (Strategy)  | listeners (Observer) | produces
      v                   v                      v
 <<iface>> LimitRule   <<iface>> TransactionListener   WalletTransaction
   PerTransactionLimit    SmsNotificationListener      id, key, type, from,to,
   DailySpendLimit        (Email/Push/Audit...)        amount, status, reason
   MaxBalanceLimit

4. Design patterns and why

PatternWhereWhy / alternative rejected
Strategy / rule chainLimitRule listNew limit = new class. Rejected: if chain in transfer() (OCP violation, untestable).
ObserverTransactionListenerDecouples notifications/analytics from the money path; listeners run after locks are released and their failures are swallowed so SMS outage never fails a payment.
Ledger / event sourcing-liteLedger, LedgerEntryBalance is a cache of the ledger. Double entry gives a checkable invariant (debits = credits per txn) and audit trail. Rejected: only mutating a balance field with no history.
Idempotent receiveridempotency map of CompletableFutureExactly one thread executes a given key; concurrent duplicates wait for that result. Rejected: containsKey then put (race); computeIfAbsent running the transfer inside map bin lock (holds an internal lock while taking wallet locks).
Ordered lockingprocess()Lock wallets in ascending id => no deadlock between A->B and B->A.
(Mention) StateWalletStatusFrozen/closed handled as a status check; could become State if behaviours multiply.

5. Complete Java code

Default package; javac *.java && java Main.

5.1 Enums, money, exceptions

// Enums.java
enum WalletType { USER, SYSTEM }              // SYSTEM wallets (bank settlement, fees) may go negative
enum WalletStatus { ACTIVE, FROZEN }
enum EntryType { DEBIT, CREDIT }
enum TxnType { TOP_UP, TRANSFER }
enum TxnStatus { SUCCESS, FAILED }
// Money.java
import java.math.BigDecimal;

public final class Money {
    private Money() {}
    /** "250.50" -> 25050 paise */
    public static long of(String major) { return new BigDecimal(major).movePointRight(2).longValueExact(); }
    public static String fmt(long minor) { return "Rs " + BigDecimal.valueOf(minor, 2).toPlainString(); }
}
// WalletException.java
public class WalletException extends RuntimeException {
    public WalletException(String m) { super(m); }
}
// IdempotencyConflictException.java
/** Same idempotency key re-used with a different request payload: a client bug, reject loudly. */
public class IdempotencyConflictException extends WalletException {
    public IdempotencyConflictException(String key) { super("Idempotency key reused with different request: " + key); }
}

5.2 Wallet, ledger, transaction

// Wallet.java
import java.time.LocalDate;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Wallet owns its balance and its lock. The balance is only mutated while the lock is held
 * (the service acquires locks; mutators here are package-private to prevent misuse).
 */
public final class Wallet {
    private final String id, ownerId;
    private final WalletType type;
    private final ReentrantLock lock = new ReentrantLock();
    private long balance;                  // guarded by lock
    private WalletStatus status = WalletStatus.ACTIVE;   // guarded by lock
    private LocalDate spendDate;           // guarded by lock
    private long spentOnDate;              // guarded by lock

    public Wallet(String id, String ownerId, WalletType type) { this.id = id; this.ownerId = ownerId; this.type = type; }

    public String id() { return id; }
    public String ownerId() { return ownerId; }
    public WalletType type() { return type; }
    ReentrantLock lock() { return lock; }

    // --- caller MUST hold lock() for the ones below ---
    long balanceLocked() { return balance; }
    WalletStatus statusLocked() { return status; }
    void applyDebit(long amt) { balance -= amt; }
    void applyCredit(long amt) { balance += amt; }
    void freeze() { status = WalletStatus.FROZEN; }
    long spentOn(LocalDate d) { return d.equals(spendDate) ? spentOnDate : 0; }
    void recordSpend(LocalDate d, long amt) {
        if (!d.equals(spendDate)) { spendDate = d; spentOnDate = 0; }
        spentOnDate += amt;
    }

    /** Safe read for UI/tests. */
    public long balance() { lock.lock(); try { return balance; } finally { lock.unlock(); } }
}
// LedgerEntry.java
import java.time.Instant;

/** Immutable, append-only. A transfer always produces exactly one DEBIT and one CREDIT of equal amount. */
public final class LedgerEntry {
    public final long entryId; public final String txnId, walletId; public final EntryType type;
    public final long amount, balanceAfter; public final Instant at;

    public LedgerEntry(long entryId, String txnId, String walletId, EntryType type, long amount, long balanceAfter, Instant at) {
        this.entryId = entryId; this.txnId = txnId; this.walletId = walletId; this.type = type;
        this.amount = amount; this.balanceAfter = balanceAfter; this.at = at;
    }
    @Override public String toString() {
        return String.format("#%d %s %-6s %s %s balanceAfter=%s", entryId, txnId, type, walletId, Money.fmt(amount), Money.fmt(balanceAfter));
    }
}
// Ledger.java
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;

public final class Ledger {
    private final List<LedgerEntry> entries = new CopyOnWriteArrayList<>();
    private final AtomicLong seq = new AtomicLong();
    private final Clock clock;
    public Ledger(Clock clock) { this.clock = clock; }

    /** Called while holding both wallet locks, so entries of one txn are written together. */
    void append(String txnId, String walletId, EntryType type, long amount, long balanceAfter) {
        entries.add(new LedgerEntry(seq.incrementAndGet(), txnId, walletId, type, amount, balanceAfter, Instant.now(clock)));
    }
    public List<LedgerEntry> forWallet(String walletId) {
        return entries.stream().filter(e -> e.walletId.equals(walletId)).collect(Collectors.toList());
    }
    /** Invariant: total debits == total credits, and per txn debit == credit. */
    public boolean isBalanced() {
        Map<String, Long> perTxn = new HashMap<>();
        for (LedgerEntry e : entries) perTxn.merge(e.txnId, e.type == EntryType.DEBIT ? e.amount : -e.amount, Long::sum);
        return perTxn.values().stream().allMatch(v -> v == 0);
    }
    public int size() { return entries.size(); }
}
// WalletTransaction.java
import java.time.Instant;

public final class WalletTransaction {
    public final String id, idempotencyKey, fromWalletId, toWalletId, failureReason;
    public final TxnType type; public final long amount; public final TxnStatus status; public final Instant at;

    WalletTransaction(String id, String key, TxnType type, String from, String to, long amount,
                      TxnStatus status, String failureReason, Instant at) {
        this.id = id; this.idempotencyKey = key; this.type = type; this.fromWalletId = from; this.toWalletId = to;
        this.amount = amount; this.status = status; this.failureReason = failureReason; this.at = at;
    }
    @Override public String toString() {
        return id + " " + type + " " + fromWalletId + "->" + toWalletId + " " + Money.fmt(amount) + " " + status
                + (failureReason == null ? "" : " [" + failureReason + "]");
    }
}

5.3 Limit rules (Strategy)

// LimitRule.java
import java.time.Clock;

/** Strategy / rule chain element. Evaluated under the wallet locks, so reads are consistent. */
public interface LimitRule {
    void check(Wallet from, Wallet to, long amount, Clock clock);   // throws WalletException to veto
}
// PerTransactionLimit.java
import java.time.Clock;

public final class PerTransactionLimit implements LimitRule {
    private final long max;
    public PerTransactionLimit(long max) { this.max = max; }
    @Override public void check(Wallet from, Wallet to, long amount, Clock c) {
        if (from.type() == WalletType.SYSTEM) return;       // limits protect customers' spending, not bank top-ups
        if (amount > max) throw new WalletException("Per-transaction limit " + Money.fmt(max) + " exceeded");
    }
}
// DailySpendLimit.java
import java.time.Clock;
import java.time.LocalDate;

public final class DailySpendLimit implements LimitRule {
    private final long max;
    public DailySpendLimit(long max) { this.max = max; }
    @Override public void check(Wallet from, Wallet to, long amount, Clock c) {
        if (from.type() == WalletType.SYSTEM) return;
        long already = from.spentOn(LocalDate.now(c));
        if (already + amount > max) throw new WalletException("Daily limit " + Money.fmt(max) + " exceeded (spent " + Money.fmt(already) + ")");
    }
}
// MaxBalanceLimit.java
import java.time.Clock;

/** Receiver-side rule: e.g. a minimum-KYC wallet may not hold more than X. */
public final class MaxBalanceLimit implements LimitRule {
    private final long max;
    public MaxBalanceLimit(long max) { this.max = max; }
    @Override public void check(Wallet from, Wallet to, long amount, Clock c) {
        if (to.type() == WalletType.USER && to.balanceLocked() + amount > max)
            throw new WalletException("Receiver would exceed max balance " + Money.fmt(max));
    }
}

5.4 Observers

// TransactionListener.java
/** Observer. Invoked AFTER locks are released and AFTER the txn is durable; failures are isolated. */
public interface TransactionListener {
    void onTransaction(WalletTransaction txn);
}
// SmsNotificationListener.java
public final class SmsNotificationListener implements TransactionListener {
    @Override public void onTransaction(WalletTransaction t) {
        if (t.status == TxnStatus.SUCCESS) System.out.println("    [SMS] " + t.type + " of " + Money.fmt(t.amount) + " " + t.fromWalletId + " -> " + t.toWalletId + " ok (" + t.id + ")");
        else System.out.println("    [SMS] " + t.type + " of " + Money.fmt(t.amount) + " failed: " + t.failureReason);
    }
}

5.5 Service (the interesting part)

// WalletService.java
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

/**
 * Concurrency design:
 *  - lock per wallet (ReentrantLock), taken in ascending wallet-id order => no deadlock for A->B vs B->A.
 *  - idempotency: putIfAbsent of a CompletableFuture, so exactly one thread executes, duplicates wait for / reuse its result.
 *  - listeners are called after locks are released (never run foreign code under a lock).
 */
public final class WalletService {
    private static final class IdemEntry {
        final String fingerprint; final CompletableFuture<WalletTransaction> result = new CompletableFuture<>();
        IdemEntry(String fp) { this.fingerprint = fp; }
    }

    private final Map<String, Wallet> wallets = new ConcurrentHashMap<>();
    private final Map<String, IdemEntry> idempotency = new ConcurrentHashMap<>();
    private final List<TransactionListener> listeners = new CopyOnWriteArrayList<>();
    private final List<LimitRule> rules;
    private final Ledger ledger;
    private final Clock clock;
    private final AtomicLong txnSeq = new AtomicLong();
    private final String systemWalletId;

    public WalletService(List<LimitRule> rules, Ledger ledger, Clock clock) {
        this.rules = List.copyOf(rules); this.ledger = ledger; this.clock = clock;
        this.systemWalletId = "SYS-SETTLEMENT";
        wallets.put(systemWalletId, new Wallet(systemWalletId, "system", WalletType.SYSTEM));
    }

    public void addListener(TransactionListener l) { listeners.add(l); }

    public Wallet createWallet(String walletId, String ownerId) {
        Wallet w = new Wallet(walletId, ownerId, WalletType.USER);
        if (wallets.putIfAbsent(walletId, w) != null) throw new WalletException("Wallet exists: " + walletId);
        return w;
    }
    public Wallet wallet(String id) {
        Wallet w = wallets.get(id);
        if (w == null) throw new WalletException("Unknown wallet " + id);
        return w;
    }

    /** Money enters the system from the settlement account (e.g. bank/UPI collect succeeded). */
    public WalletTransaction topUp(String idempotencyKey, String walletId, long amount) {
        return execute(idempotencyKey, TxnType.TOP_UP, systemWalletId, walletId, amount);
    }

    public WalletTransaction transfer(String idempotencyKey, String fromWalletId, String toWalletId, long amount) {
        return execute(idempotencyKey, TxnType.TRANSFER, fromWalletId, toWalletId, amount);
    }

    private WalletTransaction execute(String key, TxnType type, String fromId, String toId, long amount) {
        if (key == null || key.isBlank()) throw new WalletException("Idempotency key required");
        if (amount <= 0) throw new WalletException("Amount must be positive");
        if (fromId.equals(toId)) throw new WalletException("Cannot transfer to the same wallet");
        Wallet from = wallet(fromId), to = wallet(toId);

        IdemEntry mine = new IdemEntry(type + "|" + fromId + "|" + toId + "|" + amount);
        IdemEntry existing = idempotency.putIfAbsent(key, mine);
        if (existing != null) {                                   // duplicate request
            if (!existing.fingerprint.equals(mine.fingerprint)) throw new IdempotencyConflictException(key);
            return existing.result.join();                        // same outcome, no second debit
        }

        WalletTransaction txn;
        try {
            txn = process(key, type, from, to, amount);
        } catch (RuntimeException e) {                            // never leave a duplicate waiting forever
            idempotency.remove(key);
            mine.result.completeExceptionally(e);
            throw e;
        }
        mine.result.complete(txn);
        for (TransactionListener l : listeners) {
            try { l.onTransaction(txn); } catch (RuntimeException ignored) { /* a bad listener must not break payments */ }
        }
        return txn;
    }

    private WalletTransaction process(String key, TxnType type, Wallet from, Wallet to, long amount) {
        String txnId = "TXN-" + txnSeq.incrementAndGet();
        Wallet first = from.id().compareTo(to.id()) < 0 ? from : to;      // global lock order
        Wallet second = first == from ? to : from;
        first.lock().lock();
        second.lock().lock();
        try {
            try {
                if (from.statusLocked() != WalletStatus.ACTIVE || to.statusLocked() != WalletStatus.ACTIVE)
                    throw new WalletException("Wallet is frozen");
                if (from.type() == WalletType.USER && from.balanceLocked() < amount)
                    throw new WalletException("Insufficient balance");
                for (LimitRule r : rules) r.check(from, to, amount, clock);
            } catch (WalletException rejected) {
                return new WalletTransaction(txnId, key, type, from.id(), to.id(), amount, TxnStatus.FAILED, rejected.getMessage(), Instant.now(clock));
            }
            from.applyDebit(amount);
            to.applyCredit(amount);
            if (from.type() == WalletType.USER) from.recordSpend(LocalDate.now(clock), amount);
            ledger.append(txnId, from.id(), EntryType.DEBIT, amount, from.balanceLocked());
            ledger.append(txnId, to.id(), EntryType.CREDIT, amount, to.balanceLocked());
            return new WalletTransaction(txnId, key, type, from.id(), to.id(), amount, TxnStatus.SUCCESS, null, Instant.now(clock));
        } finally {
            second.lock().unlock();
            first.lock().unlock();
        }
    }
}

5.6 Demo driver

// Main.java
import java.time.Clock;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
    public static void main(String[] args) throws Exception {
        Clock clock = Clock.systemUTC();
        Ledger ledger = new Ledger(clock);
        WalletService svc = new WalletService(List.of(
                new PerTransactionLimit(Money.of("25000")),
                new DailySpendLimit(Money.of("40000")),
                new MaxBalanceLimit(Money.of("200000"))), ledger, clock);
        svc.addListener(new SmsNotificationListener());
        svc.createWallet("W-ALICE", "alice");
        svc.createWallet("W-BOB", "bob");

        System.out.println("== basic flow ==");
        svc.topUp("topup-1", "W-ALICE", Money.of("50000"));
        System.out.println(svc.transfer("pay-1", "W-ALICE", "W-BOB", Money.of("1200.50")));
        System.out.println("alice=" + Money.fmt(svc.wallet("W-ALICE").balance()) + " bob=" + Money.fmt(svc.wallet("W-BOB").balance()));

        System.out.println("== idempotent retry (same key) ==");
        WalletTransaction a = svc.transfer("pay-1", "W-ALICE", "W-BOB", Money.of("1200.50"));
        System.out.println("returned same txn: " + a.id + "; alice=" + Money.fmt(svc.wallet("W-ALICE").balance()));
        try { svc.transfer("pay-1", "W-ALICE", "W-BOB", Money.of("999")); }
        catch (IdempotencyConflictException e) { System.out.println("conflict: " + e.getMessage()); }

        System.out.println("== rule failures (recorded as FAILED, no ledger entries) ==");
        System.out.println(svc.transfer("pay-2", "W-BOB", "W-ALICE", Money.of("5000")));      // insufficient
        System.out.println(svc.transfer("pay-3", "W-ALICE", "W-BOB", Money.of("30000")));     // per-txn limit
        System.out.println(svc.transfer("pay-4", "W-ALICE", "W-BOB", Money.of("25000")));     // ok: spent 26200.50
        System.out.println(svc.transfer("pay-5", "W-ALICE", "W-BOB", Money.of("20000")));     // daily limit

        System.out.println("== ledger for alice ==");
        ledger.forWallet("W-ALICE").forEach(e -> System.out.println("  " + e));

        System.out.println("== stress: same key from 50 threads ==");
        WalletService s2 = new WalletService(List.of(), new Ledger(clock), clock);
        s2.createWallet("A", "a"); s2.createWallet("B", "b");
        s2.topUp("t", "A", Money.of("1000"));
        ExecutorService pool = Executors.newFixedThreadPool(16);
        Set<String> ids = ConcurrentHashMap.newKeySet();
        List<Future<?>> fs = new ArrayList<>();
        for (int i = 0; i < 50; i++) fs.add(pool.submit(() -> ids.add(s2.transfer("dup-key", "A", "B", Money.of("100")).id)));
        for (Future<?> f : fs) f.get();
        System.out.println("distinct txn ids=" + ids.size() + " A=" + Money.fmt(s2.wallet("A").balance()) + " B=" + Money.fmt(s2.wallet("B").balance()));

        System.out.println("== stress: random opposite-direction transfers across 16 threads (deadlock + conservation check) ==");
        Ledger l3 = new Ledger(clock);
        WalletService s3 = new WalletService(List.of(), l3, clock);
        s3.createWallet("A", "a"); s3.createWallet("B", "b"); s3.createWallet("C", "c");
        for (String w : List.of("A", "B", "C")) s3.topUp("init-" + w, w, Money.of("10000"));
        String[] ws = {"A", "B", "C"};
        AtomicInteger ok = new AtomicInteger(), failed = new AtomicInteger();
        List<Future<?>> fs3 = new ArrayList<>();
        for (int t = 0; t < 16; t++) {
            final int seed = t;
            fs3.add(pool.submit(() -> {
                Random r = new Random(seed);
                for (int i = 0; i < 1250; i++) {
                    String x = ws[r.nextInt(3)], y = ws[r.nextInt(3)];
                    if (x.equals(y)) continue;
                    WalletTransaction tx = s3.transfer("k-" + seed + "-" + i, x, y, Money.of(String.valueOf(1 + r.nextInt(900))));
                    (tx.status == TxnStatus.SUCCESS ? ok : failed).incrementAndGet();
                }
            }));
        }
        for (Future<?> f : fs3) f.get();
        pool.shutdown();
        long total = 0, min = Long.MAX_VALUE;
        for (String w : ws) { long b = s3.wallet(w).balance(); total += b; min = Math.min(min, b); }
        System.out.println("total=" + Money.fmt(total) + " (expected Rs 30000.00) noNegativeBalance=" + (min >= 0)
                + " ledgerBalanced=" + l3.isBalanced() + " (some transfers legitimately FAILED: insufficient funds)");
    }
}

Real output (from java Main)

== basic flow ==
    [SMS] TOP_UP of Rs 50000.00 SYS-SETTLEMENT -> W-ALICE ok (TXN-1)
    [SMS] TRANSFER of Rs 1200.50 W-ALICE -> W-BOB ok (TXN-2)
TXN-2 TRANSFER W-ALICE->W-BOB Rs 1200.50 SUCCESS
alice=Rs 48799.50 bob=Rs 1200.50
== idempotent retry (same key) ==
returned same txn: TXN-2; alice=Rs 48799.50
conflict: Idempotency key reused with different request: pay-1
== rule failures (recorded as FAILED, no ledger entries) ==
    [SMS] TRANSFER of Rs 5000.00 failed: Insufficient balance
TXN-3 TRANSFER W-BOB->W-ALICE Rs 5000.00 FAILED [Insufficient balance]
    [SMS] TRANSFER of Rs 30000.00 failed: Per-transaction limit Rs 25000.00 exceeded
TXN-4 TRANSFER W-ALICE->W-BOB Rs 30000.00 FAILED [Per-transaction limit Rs 25000.00 exceeded]
    [SMS] TRANSFER of Rs 25000.00 W-ALICE -> W-BOB ok (TXN-5)
TXN-5 TRANSFER W-ALICE->W-BOB Rs 25000.00 SUCCESS
    [SMS] TRANSFER of Rs 20000.00 failed: Daily limit Rs 40000.00 exceeded (spent Rs 26200.50)
TXN-6 TRANSFER W-ALICE->W-BOB Rs 20000.00 FAILED [Daily limit Rs 40000.00 exceeded (spent Rs 26200.50)]
== ledger for alice ==
  #2 TXN-1 CREDIT W-ALICE Rs 50000.00 balanceAfter=Rs 50000.00
  #3 TXN-2 DEBIT  W-ALICE Rs 1200.50 balanceAfter=Rs 48799.50
  #5 TXN-5 DEBIT  W-ALICE Rs 25000.00 balanceAfter=Rs 23799.50
== stress: same key from 50 threads ==
distinct txn ids=1 A=Rs 900.00 B=Rs 100.00
== stress: random opposite-direction transfers across 16 threads (deadlock + conservation check) ==
total=Rs 30000.00 (expected Rs 30000.00) noNegativeBalance=true ledgerBalanced=true (some transfers legitimately FAILED: insufficient funds)

6. Walkthrough of key flows

transfer(key="pay-1", A -> B, 1200.50)
Client        WalletService              IdempotencyMap     Wallet A / B (locks)    Ledger   Listeners
 |--transfer-->|                              |                    |                   |          |
 |             |-validate args (amt>0, A!=B)  |                    |                   |          |
 |             |--putIfAbsent(key, entry)---->|                    |                   |          |
 |             |<-- null (I am the owner)-----|                    |                   |          |
 |             |-lock(min(A,B).id) then lock(max)------------------>|                  |          |
 |             |-check status, balance, LimitRules (consistent view under lock)         |          |
 |             |-debit A, credit B, recordSpend                    |                   |          |
 |             |--append DEBIT A, CREDIT B (same txnId)------------------------------>|          |
 |             |-unlock both                                       |                   |          |
 |             |-entry.future.complete(txn)   |                    |                   |          |
 |             |--------------------------------------------- onTransaction(txn) ---------------->| SMS
 |<--txn-------|

Duplicate (same key, same payload):  putIfAbsent returns existing -> future.join() -> same txn, NO debit
Duplicate with different payload:    fingerprint mismatch -> IdempotencyConflictException

Why FAILED business outcomes are returned (not thrown) and stored in the idempotency map: a retry of a request that failed for "insufficient balance" must return the same answer; the client should use a new key to re-attempt after topping up. Programming/validation errors (negative amount, unknown wallet) throw before the key is registered. Unexpected runtime failure removes the key so the client can retry.

7. Extensibility

  • New limit (velocity: max 5 txns/min, merchant-category cap): implement LimitRule, pass in the constructor list. WalletService unchanged.
  • New notification channel / analytics / fraud scoring: implement TransactionListener, addListener.
  • New transaction type (REFUND, WITHDRAW_TO_BANK, FEE): add TxnType; they are all "debit one wallet, credit another" so they reuse process(). Fees = a second transfer to a SYSTEM fee wallet inside the same lock scope.
  • Different storage: Ledger/wallet repositories behind interfaces (WalletRepository, LedgerRepository); service keeps the same flow.
  • Per-tier limits: LimitRule reads the wallet's KYC tier; limits chosen by a LimitPolicyProvider.

8. Concurrency and edge cases

  • Lock granularity: one ReentrantLock per wallet, held only for check + debit + credit + ledger append (microseconds). Unrelated transfers run fully in parallel. A global lock would serialise the whole system; optimistic CAS on a single AtomicLong works for one wallet but cannot atomically update two.
  • Deadlock avoidance: always acquire in ascending wallet id. A->B and B->A both lock min first. (Stress test: 16 threads, random directions, finishes.)
  • Limit checks inside the lock: otherwise two parallel transfers each see "spent 0" and both pass the daily limit (TOCTOU).
  • Listeners outside the lock: foreign code under a lock risks latency and deadlock.
  • Idempotency race: 50 threads, same key -> one transfer, 50 identical results (demo).
  • Edge cases: self-transfer, zero/negative amounts, overflow (long is fine for paise up to 9e16), frozen wallet on either side, SYSTEM wallet allowed negative, receiver max-balance, key reuse with different payload, crash between debit and credit (impossible in memory because both mutate under the lock; in a DB it is one transaction), clock rollover for daily limits (uses LocalDate of injected clock; real systems use user timezone and a rolling window).
  • Memory growth of idempotency map: needs TTL (24-48h) in production.

9. Follow-up questions and model answers

  1. How would you persist this? Tables: wallet(id, owner, balance, version, status), ledger_entry(id, txn_id, wallet_id, type, amount, balance_after, created_at), txn(id, idempotency_key UNIQUE, type, from, to, amount, status, reason). One DB transaction: INSERT txn (unique key = idempotency guard; duplicate key exception => read existing), UPDATE wallet SET balance = balance - ?, version=version+1 WHERE id=? AND balance >= ? (rowcount 1), credit the other, insert 2 ledger rows. To avoid deadlocks order the UPDATEs by wallet id (same as our lock ordering) or SELECT ... FOR UPDATE in id order.
  2. Make it distributed / what if wallets live on different shards? Single-shard txns stay ACID. Cross-shard transfer: avoid 2PC; use a saga: debit source + write outbox event atomically -> consumer credits destination idempotently (txn id as key) -> on permanent failure, compensating credit-back. Or keep a per-user "pending" ledger. Exactly-once effect = at-least-once delivery + idempotent consumers.
  3. Where does the idempotency key live in a distributed system? In the DB with a unique constraint (or Redis SET NX with TTL as a fast pre-check, DB as source of truth). Store request hash to detect payload mismatch; store the response for replay.
  4. Hot wallet (merchant receiving thousands of credits/s)? Row lock becomes a bottleneck. Options: sub-wallets/sharded balance (N buckets, sum on read), or append-only credits with periodic compaction (balance = base + SUM(pending credits)). Debits still need the check.
  5. Refund / reversal? Never edit entries. A refund is a new transaction with reverses_txn_id creating opposite entries; guard against double refund with a unique constraint on reverses_txn_id.
  6. Why double-entry? Self-checking: SUM(debits) = SUM(credits) globally and per txn; reconciliation with the bank settlement file becomes a simple diff.
  7. How do you reconcile balance with ledger? Periodic job: balance == SUM(credits) - SUM(debits) per wallet; alert on drift.
  8. Why long paise rather than double/BigDecimal? Exact, cheap, easy to persist as BIGINT. BigDecimal is fine at the edges; never double.
  9. What if the notification service is down? Listeners are best-effort; for reliability use an outbox table written in the same DB transaction, relayed to a queue with retries. Notifications must not affect payment success.
  10. How to scale reads (history)? CQRS: ledger is the write model; project to a read store (per-user time-ordered) via events; paginate by cursor.

10. Common mistakes, red flags, rubric

Red flags

  • synchronized on the whole service, or no locking; check-then-act on balance.
  • Locking from then to without ordering -> deadlock.
  • double money.
  • No idempotency, or if (!map.containsKey(k)) map.put(k, ...) race.
  • Debit succeeds and credit separately (not atomic).
  • Calling listeners/SMS while holding locks.
  • Only a balance field - no transaction history; editing past entries.
  • Limits checked outside the lock.

Mini rubric (20 pts)

AreaPoints
Requirements: limits, idempotency, ledger clarified3
Per-wallet locks with ordering, atomic transfer5
Idempotency design (race-free, payload mismatch)4
Double-entry ledger + FAILED records3
Strategy for limits + Observer for notifications3
Persistence/saga follow-ups2