Limited time: AI code review, hints, mock interviews, whiteboard analysis, and all Pro features are unlocked. Enroll
โฑ๏ธ 24 min read

Designing a Payment Wallet System

Difficulty: Medium Patterns: Command, Observer, Singleton Asked at: PhonePe, Razorpay, PayTM, Google Pay, Amazon


Functional Requirements

  1. Create Wallet โ€” register a new wallet for a given user ID with an initial balance of zero
  2. Credit / Debit โ€” add money (credit) or withdraw money (debit) from a wallet with overdraft prevention
  3. P2P Transfer โ€” atomically debit sender and credit receiver in a single operation
  4. Transaction History โ€” maintain a full ledger of all operations with timestamp, type, amount, and balance-after
  5. Idempotent Operations โ€” given the same transaction ID, the system returns the same result without re-executing (no double-spend)
  6. Balance Inquiry โ€” retrieve current wallet balance in O(1)

Non-Functional Requirements

  1. Thread-safety โ€” concurrent credits, debits, and transfers must not corrupt balances or produce race conditions
  2. Atomicity for transfers โ€” a P2P transfer either completes fully (both debit and credit) or not at all
  3. Audit trail โ€” every balance mutation is recorded immutably in the ledger for reconciliation

Core Entities

Entity Description
Wallet Holds user ID, current balance, transaction ledger, and a lock for thread-safety
Transaction Immutable record: txn ID, type, amount, balance after, timestamp, counterparty
TransactionType Enum: CREDIT, DEBIT, TRANSFER_IN, TRANSFER_OUT
WalletService Singleton orchestrator โ€” manages all wallets, enforces idempotency, executes transfers
TransferCommand Encapsulates a P2P transfer as a command object (Command pattern)
WalletObserver Interface notified on balance changes (Observer pattern)
IdempotencyStore Tracks processed transaction IDs to prevent duplicate execution

Class Diagram

classDiagram
    class TransactionType {
        <<enumeration>>
        CREDIT
        DEBIT
        TRANSFER_IN
        TRANSFER_OUT
    }

    class Transaction {
        -String txnId
        -TransactionType type
        -double amount
        -double balanceAfter
        -LocalDateTime timestamp
        -String counterpartyUserId
        +getTxnId() String
        +getType() TransactionType
        +getAmount() double
        +getBalanceAfter() double
        +getTimestamp() LocalDateTime
    }

    class Wallet {
        -String userId
        -double balance
        -List~Transaction~ ledger
        -ReentrantLock lock
        +credit(double amount, String txnId) Transaction
        +debit(double amount, String txnId) Transaction
        +getBalance() double
        +getStatement() List~Transaction~
    }

    class WalletObserver {
        <<interface>>
        +onBalanceChange(String userId, double oldBalance, double newBalance)
    }

    class TransferCommand {
        -String txnId
        -Wallet sender
        -Wallet receiver
        -double amount
        +execute() boolean
    }

    class WalletService {
        -Map~String, Wallet~ wallets
        -Set~String~ processedTxnIds
        -List~WalletObserver~ observers
        -ReentrantLock globalLock
        +createWallet(String userId) Wallet
        +credit(String userId, double amount, String txnId) Transaction
        +debit(String userId, double amount, String txnId) Transaction
        +transfer(String fromId, String toId, double amount, String txnId) boolean
        +getBalance(String userId) double
        +getStatement(String userId) List~Transaction~
        +addObserver(WalletObserver)
    }

    WalletService --> Wallet
    WalletService --> WalletObserver
    WalletService --> TransferCommand
    Wallet --> Transaction
    Transaction --> TransactionType
    TransferCommand --> Wallet

Design Patterns

Pattern Where Why
Command TransferCommand encapsulates P2P transfer logic Decouples transfer execution from the service. Enables undo/retry, logging of operations as first-class objects.
Observer WalletObserver notified on balance changes Decouples notifications (SMS, push, audit log) from core wallet logic. Adding a new notification channel = one new observer.
Singleton WalletService is the single entry point Ensures one authoritative source of truth for wallet state, idempotency store, and observer registry.

Data Structures

Component Structure Why
Wallets registry ConcurrentHashMap<String, Wallet> O(1) lookup by user ID, thread-safe reads
Idempotency store ConcurrentHashMap<String, Transaction> O(1) duplicate detection by txn ID
Wallet ledger ArrayList<Transaction> Append-only, preserves insertion order for statement
Observer list CopyOnWriteArrayList<WalletObserver> Safe iteration during concurrent notifications
Per-wallet lock ReentrantLock Fine-grained locking โ€” two unrelated wallets donโ€™t block each other

How It All Fits Together

Hereโ€™s what happens during a P2P transfer from Alice to Bob:

  1. Client calls walletService.transfer("alice", "bob", 100.0, "txn-abc-123")
  2. WalletService checks the idempotency store โ€” if txn-abc-123 was already processed, return the cached result immediately
  3. Service looks up both wallets. If either doesnโ€™t exist, throws an exception
  4. A TransferCommand is created with sender=Alice, receiver=Bob, amount=100
  5. The command acquires locks on both wallets in a consistent order (sorted by user ID) to prevent deadlocks
  6. Aliceโ€™s balance is checked โ€” if balance < 100, the transfer is rejected (overdraft prevention)
  7. Alice is debited: balance -= 100, a TRANSFER_OUT transaction is appended to her ledger
  8. Bob is credited: balance += 100, a TRANSFER_IN transaction is appended to his ledger
  9. The txn ID is recorded in the idempotency store
  10. Locks are released, observers are notified of both balance changes
  11. The result is returned to the caller

Complete Code

TransactionType.java

This enum defines the four mutation types a wallet can experience. Separating TRANSFER_IN from CREDIT (and TRANSFER_OUT from DEBIT) allows the ledger to distinguish between self-initiated operations and peer-to-peer flows โ€” critical for reconciliation and dispute resolution.

package wallet.model;

public enum TransactionType {
    CREDIT,
    DEBIT,
    TRANSFER_IN,
    TRANSFER_OUT
}
from enum import Enum

class TransactionType(Enum):
    CREDIT = "CREDIT"
    DEBIT = "DEBIT"
    TRANSFER_IN = "TRANSFER_IN"
    TRANSFER_OUT = "TRANSFER_OUT"
#pragma once

enum class TransactionType {
    CREDIT,
    DEBIT,
    TRANSFER_IN,
    TRANSFER_OUT
};

Transaction.java

An immutable ledger entry. Once created, a Transaction cannot be modified โ€” this guarantees the audit trail is tamper-proof. Each entry captures the txn ID (for idempotency), what happened, how much, the resulting balance, when, and who the counterparty was (if applicable).

package wallet.model;

import java.time.LocalDateTime;

public class Transaction {
    private final String txnId;
    private final TransactionType type;
    private final double amount;
    private final double balanceAfter;
    private final LocalDateTime timestamp;
    private final String counterpartyUserId; // null for credit/debit

    public Transaction(String txnId, TransactionType type, double amount,
                       double balanceAfter, String counterpartyUserId) {
        this.txnId = txnId;
        this.type = type;
        this.amount = amount;
        this.balanceAfter = balanceAfter;
        this.timestamp = LocalDateTime.now();
        this.counterpartyUserId = counterpartyUserId;
    }

    public String getTxnId() { return txnId; }
    public TransactionType getType() { return type; }
    public double getAmount() { return amount; }
    public double getBalanceAfter() { return balanceAfter; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public String getCounterpartyUserId() { return counterpartyUserId; }

    @Override
    public String toString() {
        return String.format("[%s] %s %.2f | Balance: %.2f | %s",
            timestamp, type, amount, balanceAfter,
            counterpartyUserId != null ? "with " + counterpartyUserId : "");
    }
}
from datetime import datetime


class Transaction:
    def __init__(self, txn_id: str, txn_type: TransactionType,
                 amount: float, balance_after: float,
                 counterparty_user_id: str = None):
        self._txn_id = txn_id
        self._type = txn_type
        self._amount = amount
        self._balance_after = balance_after
        self._timestamp = datetime.now()
        self._counterparty_user_id = counterparty_user_id

    @property
    def txn_id(self) -> str:
        return self._txn_id

    @property
    def type(self) -> TransactionType:
        return self._type

    @property
    def amount(self) -> float:
        return self._amount

    @property
    def balance_after(self) -> float:
        return self._balance_after

    @property
    def timestamp(self) -> datetime:
        return self._timestamp

    @property
    def counterparty_user_id(self) -> str:
        return self._counterparty_user_id

    def __str__(self) -> str:
        counterparty = f" with {self._counterparty_user_id}" if self._counterparty_user_id else ""
        return (f"[{self._timestamp}] {self._type.value} {self._amount:.2f} "
                f"| Balance: {self._balance_after:.2f}{counterparty}")
#pragma once
#include <string>
#include <chrono>
#include <sstream>
#include <iomanip>
#include "TransactionType.hpp"

class Transaction {
private:
    std::string txnId;
    TransactionType type;
    double amount;
    double balanceAfter;
    std::chrono::system_clock::time_point timestamp;
    std::string counterpartyUserId;

public:
    Transaction(std::string txnId, TransactionType type, double amount,
                double balanceAfter, std::string counterpartyUserId = "")
        : txnId(std::move(txnId)), type(type), amount(amount),
          balanceAfter(balanceAfter),
          timestamp(std::chrono::system_clock::now()),
          counterpartyUserId(std::move(counterpartyUserId)) {}

    const std::string& getTxnId() const { return txnId; }
    TransactionType getType() const { return type; }
    double getAmount() const { return amount; }
    double getBalanceAfter() const { return balanceAfter; }
    const std::string& getCounterpartyUserId() const { return counterpartyUserId; }

    std::string toString() const {
        std::ostringstream oss;
        oss << std::fixed << std::setprecision(2);
        oss << "[" << txnId << "] ";
        switch (type) {
            case TransactionType::CREDIT: oss << "CREDIT"; break;
            case TransactionType::DEBIT: oss << "DEBIT"; break;
            case TransactionType::TRANSFER_IN: oss << "TRANSFER_IN"; break;
            case TransactionType::TRANSFER_OUT: oss << "TRANSFER_OUT"; break;
        }
        oss << " " << amount << " | Balance: " << balanceAfter;
        if (!counterpartyUserId.empty()) {
            oss << " | with " << counterpartyUserId;
        }
        return oss.str();
    }
};

Wallet.java

The core entity. Each wallet owns its balance, its ledger (append-only list of Transactions), and a ReentrantLock for thread-safety. The lock is per-wallet, so operations on unrelated wallets never contend. Credit/debit methods validate preconditions (non-negative amount, sufficient balance) before mutating state.

package wallet.model;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

public class Wallet {
    private final String userId;
    private double balance;
    private final List<Transaction> ledger;
    private final ReentrantLock lock;

    public Wallet(String userId) {
        this.userId = userId;
        this.balance = 0.0;
        this.ledger = new ArrayList<>();
        this.lock = new ReentrantLock();
    }

    public Transaction credit(double amount, String txnId, String counterparty) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Credit amount must be positive");
        }
        lock.lock();
        try {
            balance += amount;
            TransactionType type = (counterparty != null)
                ? TransactionType.TRANSFER_IN : TransactionType.CREDIT;
            Transaction txn = new Transaction(txnId, type, amount, balance, counterparty);
            ledger.add(txn);
            return txn;
        } finally {
            lock.unlock();
        }
    }

    public Transaction debit(double amount, String txnId, String counterparty) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Debit amount must be positive");
        }
        lock.lock();
        try {
            if (balance < amount) {
                throw new IllegalStateException(
                    "Insufficient balance. Available: " + balance + ", Requested: " + amount);
            }
            balance -= amount;
            TransactionType type = (counterparty != null)
                ? TransactionType.TRANSFER_OUT : TransactionType.DEBIT;
            Transaction txn = new Transaction(txnId, type, amount, balance, counterparty);
            ledger.add(txn);
            return txn;
        } finally {
            lock.unlock();
        }
    }

    public double getBalance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }

    public List<Transaction> getStatement() {
        lock.lock();
        try {
            return Collections.unmodifiableList(new ArrayList<>(ledger));
        } finally {
            lock.unlock();
        }
    }

    public String getUserId() { return userId; }
    public ReentrantLock getLock() { return lock; }

    @Override
    public String toString() {
        return "Wallet[" + userId + "] Balance: " + balance;
    }
}
import threading
from typing import List, Optional


class Wallet:
    def __init__(self, user_id: str):
        self._user_id = user_id
        self._balance = 0.0
        self._ledger: List[Transaction] = []
        self._lock = threading.Lock()

    def credit(self, amount: float, txn_id: str,
               counterparty: Optional[str] = None) -> Transaction:
        if amount <= 0:
            raise ValueError("Credit amount must be positive")
        with self._lock:
            self._balance += amount
            txn_type = (TransactionType.TRANSFER_IN
                        if counterparty else TransactionType.CREDIT)
            txn = Transaction(txn_id, txn_type, amount,
                              self._balance, counterparty)
            self._ledger.append(txn)
            return txn

    def debit(self, amount: float, txn_id: str,
              counterparty: Optional[str] = None) -> Transaction:
        if amount <= 0:
            raise ValueError("Debit amount must be positive")
        with self._lock:
            if self._balance < amount:
                raise RuntimeError(
                    f"Insufficient balance. Available: {self._balance}, "
                    f"Requested: {amount}")
            self._balance -= amount
            txn_type = (TransactionType.TRANSFER_OUT
                        if counterparty else TransactionType.DEBIT)
            txn = Transaction(txn_id, txn_type, amount,
                              self._balance, counterparty)
            self._ledger.append(txn)
            return txn

    @property
    def balance(self) -> float:
        with self._lock:
            return self._balance

    @property
    def user_id(self) -> str:
        return self._user_id

    @property
    def lock(self) -> threading.Lock:
        return self._lock

    def get_statement(self) -> List[Transaction]:
        with self._lock:
            return list(self._ledger)

    def __str__(self) -> str:
        return f"Wallet[{self._user_id}] Balance: {self._balance:.2f}"
#pragma once
#include <string>
#include <vector>
#include <mutex>
#include <stdexcept>
#include "Transaction.hpp"

class Wallet {
private:
    std::string userId;
    double balance;
    std::vector<Transaction> ledger;
    mutable std::mutex mtx;

public:
    Wallet(std::string userId) : userId(std::move(userId)), balance(0.0) {}

    // Non-copyable due to mutex
    Wallet(const Wallet&) = delete;
    Wallet& operator=(const Wallet&) = delete;
    Wallet(Wallet&&) = default;

    Transaction credit(double amount, const std::string& txnId,
                       const std::string& counterparty = "") {
        if (amount <= 0) {
            throw std::invalid_argument("Credit amount must be positive");
        }
        std::lock_guard<std::mutex> lock(mtx);
        balance += amount;
        TransactionType type = counterparty.empty()
            ? TransactionType::CREDIT : TransactionType::TRANSFER_IN;
        Transaction txn(txnId, type, amount, balance, counterparty);
        ledger.push_back(txn);
        return txn;
    }

    Transaction debit(double amount, const std::string& txnId,
                      const std::string& counterparty = "") {
        if (amount <= 0) {
            throw std::invalid_argument("Debit amount must be positive");
        }
        std::lock_guard<std::mutex> lock(mtx);
        if (balance < amount) {
            throw std::runtime_error(
                "Insufficient balance. Available: " + std::to_string(balance) +
                ", Requested: " + std::to_string(amount));
        }
        balance -= amount;
        TransactionType type = counterparty.empty()
            ? TransactionType::DEBIT : TransactionType::TRANSFER_OUT;
        Transaction txn(txnId, type, amount, balance, counterparty);
        ledger.push_back(txn);
        return txn;
    }

    double getBalance() const {
        std::lock_guard<std::mutex> lock(mtx);
        return balance;
    }

    std::vector<Transaction> getStatement() const {
        std::lock_guard<std::mutex> lock(mtx);
        return ledger;
    }

    const std::string& getUserId() const { return userId; }
    std::mutex& getMutex() { return mtx; }
};

WalletObserver.java

The Observer interface allows external systems (notification service, analytics, fraud detection) to react to balance changes without coupling to the walletโ€™s internals. New observers can be registered at runtime.

package wallet.observer;

public interface WalletObserver {
    void onBalanceChange(String userId, double oldBalance, double newBalance);
}

// Example: Console logger observer
class ConsoleLogObserver implements WalletObserver {
    @Override
    public void onBalanceChange(String userId, double oldBalance, double newBalance) {
        System.out.printf("[NOTIFY] %s balance changed: %.2f -> %.2f%n",
            userId, oldBalance, newBalance);
    }
}
from abc import ABC, abstractmethod


class WalletObserver(ABC):
    @abstractmethod
    def on_balance_change(self, user_id: str, old_balance: float,
                          new_balance: float) -> None:
        pass


class ConsoleLogObserver(WalletObserver):
    def on_balance_change(self, user_id: str, old_balance: float,
                          new_balance: float) -> None:
        print(f"[NOTIFY] {user_id} balance changed: "
              f"{old_balance:.2f} -> {new_balance:.2f}")
#pragma once
#include <string>
#include <iostream>
#include <iomanip>

class WalletObserver {
public:
    virtual ~WalletObserver() = default;
    virtual void onBalanceChange(const std::string& userId,
                                 double oldBalance, double newBalance) = 0;
};

class ConsoleLogObserver : public WalletObserver {
public:
    void onBalanceChange(const std::string& userId,
                         double oldBalance, double newBalance) override {
        std::cout << std::fixed << std::setprecision(2);
        std::cout << "[NOTIFY] " << userId << " balance changed: "
                  << oldBalance << " -> " << newBalance << std::endl;
    }
};

TransferCommand.java

The Command pattern encapsulates the entire P2P transfer as a single executable object. The key insight is lock ordering: we always acquire locks in lexicographic order of user IDs to prevent deadlocks when two concurrent transfers go in opposite directions (Aliceโ†’Bob and Bobโ†’Alice simultaneously).

package wallet.command;

import wallet.model.Wallet;
import wallet.model.Transaction;

public class TransferCommand {
    private final String txnId;
    private final Wallet sender;
    private final Wallet receiver;
    private final double amount;

    public TransferCommand(String txnId, Wallet sender, Wallet receiver, double amount) {
        this.txnId = txnId;
        this.sender = sender;
        this.receiver = receiver;
        this.amount = amount;
    }

    /**
     * Execute the transfer atomically.
     * Acquires locks in consistent order (by userId) to prevent deadlocks.
     */
    public boolean execute() {
        // Determine lock order by comparing user IDs lexicographically
        Wallet first, second;
        if (sender.getUserId().compareTo(receiver.getUserId()) < 0) {
            first = sender;
            second = receiver;
        } else {
            first = receiver;
            second = sender;
        }

        first.getLock().lock();
        try {
            second.getLock().lock();
            try {
                // Check sender has sufficient balance
                if (sender.getBalance() < amount) {
                    return false;
                }

                // Atomic: debit sender, credit receiver
                sender.debit(amount, txnId + "-out", receiver.getUserId());
                receiver.credit(amount, txnId + "-in", sender.getUserId());
                return true;
            } finally {
                second.getLock().unlock();
            }
        } finally {
            first.getLock().unlock();
        }
    }

    public String getTxnId() { return txnId; }
    public double getAmount() { return amount; }
    public Wallet getSender() { return sender; }
    public Wallet getReceiver() { return receiver; }
}
class TransferCommand:
    """
    Encapsulates a P2P transfer as a command object.
    Acquires locks in consistent order to prevent deadlocks.
    """

    def __init__(self, txn_id: str, sender: 'Wallet',
                 receiver: 'Wallet', amount: float):
        self._txn_id = txn_id
        self._sender = sender
        self._receiver = receiver
        self._amount = amount

    def execute(self) -> bool:
        # Determine lock order by user ID to prevent deadlocks
        if self._sender.user_id < self._receiver.user_id:
            first, second = self._sender, self._receiver
        else:
            first, second = self._receiver, self._sender

        with first.lock:
            with second.lock:
                # Check sender has sufficient balance
                if self._sender._balance < self._amount:
                    return False

                # Atomic: debit sender, credit receiver
                self._sender._balance -= self._amount
                txn_out = Transaction(
                    f"{self._txn_id}-out",
                    TransactionType.TRANSFER_OUT,
                    self._amount,
                    self._sender._balance,
                    self._receiver.user_id
                )
                self._sender._ledger.append(txn_out)

                self._receiver._balance += self._amount
                txn_in = Transaction(
                    f"{self._txn_id}-in",
                    TransactionType.TRANSFER_IN,
                    self._amount,
                    self._receiver._balance,
                    self._sender.user_id
                )
                self._receiver._ledger.append(txn_in)
                return True

    @property
    def txn_id(self) -> str:
        return self._txn_id

    @property
    def amount(self) -> float:
        return self._amount
#pragma once
#include <string>
#include <mutex>
#include "Wallet.hpp"

class TransferCommand {
private:
    std::string txnId;
    Wallet& sender;
    Wallet& receiver;
    double amount;

public:
    TransferCommand(std::string txnId, Wallet& sender,
                    Wallet& receiver, double amount)
        : txnId(std::move(txnId)), sender(sender),
          receiver(receiver), amount(amount) {}

    /**
     * Execute the transfer atomically.
     * Acquires locks in consistent order (by userId) to prevent deadlocks.
     */
    bool execute() {
        // Determine lock order by comparing user IDs
        Wallet& first = (sender.getUserId() < receiver.getUserId())
                         ? sender : receiver;
        Wallet& second = (sender.getUserId() < receiver.getUserId())
                          ? receiver : sender;

        std::lock_guard<std::mutex> lock1(first.getMutex());
        std::lock_guard<std::mutex> lock2(second.getMutex());

        // Check sender has sufficient balance
        if (sender.getBalance() < amount) {
            return false;
        }

        // Atomic: debit sender, credit receiver
        sender.debit(amount, txnId + "-out", receiver.getUserId());
        receiver.credit(amount, txnId + "-in", sender.getUserId());
        return true;
    }

    const std::string& getTxnId() const { return txnId; }
    double getAmount() const { return amount; }
};

WalletService.java

The Singleton orchestrator. It owns the wallets registry, the idempotency store, and the observer list. All public operations go through this class, which checks idempotency first, delegates to Wallet or TransferCommand, then notifies observers. The idempotency store uses a ConcurrentHashMap โ€” putIfAbsent is atomic, so even under concurrency the same txn ID never executes twice.

package wallet.service;

import wallet.model.*;
import wallet.command.TransferCommand;
import wallet.observer.WalletObserver;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public class WalletService {
    private static WalletService instance;

    private final ConcurrentHashMap<String, Wallet> wallets;
    private final ConcurrentHashMap<String, Transaction> processedTxns;
    private final CopyOnWriteArrayList<WalletObserver> observers;

    private WalletService() {
        this.wallets = new ConcurrentHashMap<>();
        this.processedTxns = new ConcurrentHashMap<>();
        this.observers = new CopyOnWriteArrayList<>();
    }

    public static synchronized WalletService getInstance() {
        if (instance == null) {
            instance = new WalletService();
        }
        return instance;
    }

    // For testing - reset the singleton
    public static synchronized void resetInstance() {
        instance = null;
    }

    public Wallet createWallet(String userId) {
        if (wallets.containsKey(userId)) {
            throw new IllegalStateException("Wallet already exists for user: " + userId);
        }
        Wallet wallet = new Wallet(userId);
        wallets.put(userId, wallet);
        return wallet;
    }

    public Transaction credit(String userId, double amount, String txnId) {
        // Idempotency check
        Transaction existing = processedTxns.get(txnId);
        if (existing != null) {
            return existing; // Already processed - return same result
        }

        Wallet wallet = getWalletOrThrow(userId);
        double oldBalance = wallet.getBalance();

        Transaction txn = wallet.credit(amount, txnId, null);

        processedTxns.put(txnId, txn);
        notifyObservers(userId, oldBalance, wallet.getBalance());
        return txn;
    }

    public Transaction debit(String userId, double amount, String txnId) {
        // Idempotency check
        Transaction existing = processedTxns.get(txnId);
        if (existing != null) {
            return existing;
        }

        Wallet wallet = getWalletOrThrow(userId);
        double oldBalance = wallet.getBalance();

        Transaction txn = wallet.debit(amount, txnId, null);

        processedTxns.put(txnId, txn);
        notifyObservers(userId, oldBalance, wallet.getBalance());
        return txn;
    }

    public boolean transfer(String fromUserId, String toUserId,
                            double amount, String txnId) {
        // Idempotency check
        if (processedTxns.containsKey(txnId)) {
            return true; // Already processed
        }

        if (fromUserId.equals(toUserId)) {
            throw new IllegalArgumentException("Cannot transfer to self");
        }

        Wallet sender = getWalletOrThrow(fromUserId);
        Wallet receiver = getWalletOrThrow(toUserId);

        double senderOldBalance = sender.getBalance();
        double receiverOldBalance = receiver.getBalance();

        TransferCommand command = new TransferCommand(txnId, sender, receiver, amount);
        boolean success = command.execute();

        if (success) {
            // Mark as processed (use the out-txn as the reference)
            processedTxns.put(txnId, new Transaction(
                txnId, TransactionType.TRANSFER_OUT, amount,
                sender.getBalance(), toUserId));

            notifyObservers(fromUserId, senderOldBalance, sender.getBalance());
            notifyObservers(toUserId, receiverOldBalance, receiver.getBalance());
        }

        return success;
    }

    public double getBalance(String userId) {
        return getWalletOrThrow(userId).getBalance();
    }

    public List<Transaction> getStatement(String userId) {
        return getWalletOrThrow(userId).getStatement();
    }

    public void addObserver(WalletObserver observer) {
        observers.add(observer);
    }

    private Wallet getWalletOrThrow(String userId) {
        Wallet wallet = wallets.get(userId);
        if (wallet == null) {
            throw new IllegalArgumentException("Wallet not found for user: " + userId);
        }
        return wallet;
    }

    private void notifyObservers(String userId, double oldBalance, double newBalance) {
        for (WalletObserver observer : observers) {
            observer.onBalanceChange(userId, oldBalance, newBalance);
        }
    }
}
import threading
from typing import Dict, List, Optional


class WalletService:
    """
    Singleton service managing all wallets, idempotency, and observers.
    """
    _instance: Optional['WalletService'] = None
    _lock = threading.Lock()

    def __new__(cls):
        with cls._lock:
            if cls._instance is None:
                cls._instance = super().__new__(cls)
                cls._instance._initialized = False
            return cls._instance

    def __init__(self):
        if self._initialized:
            return
        self._wallets: Dict[str, Wallet] = {}
        self._processed_txns: Dict[str, Transaction] = {}
        self._observers: List[WalletObserver] = []
        self._service_lock = threading.Lock()
        self._initialized = True

    @classmethod
    def reset_instance(cls):
        """For testing - reset the singleton."""
        with cls._lock:
            cls._instance = None

    def create_wallet(self, user_id: str) -> Wallet:
        with self._service_lock:
            if user_id in self._wallets:
                raise RuntimeError(
                    f"Wallet already exists for user: {user_id}")
            wallet = Wallet(user_id)
            self._wallets[user_id] = wallet
            return wallet

    def credit(self, user_id: str, amount: float, txn_id: str) -> Transaction:
        # Idempotency check
        if txn_id in self._processed_txns:
            return self._processed_txns[txn_id]

        wallet = self._get_wallet_or_throw(user_id)
        old_balance = wallet.balance

        txn = wallet.credit(amount, txn_id)

        self._processed_txns[txn_id] = txn
        self._notify_observers(user_id, old_balance, wallet.balance)
        return txn

    def debit(self, user_id: str, amount: float, txn_id: str) -> Transaction:
        # Idempotency check
        if txn_id in self._processed_txns:
            return self._processed_txns[txn_id]

        wallet = self._get_wallet_or_throw(user_id)
        old_balance = wallet.balance

        txn = wallet.debit(amount, txn_id)

        self._processed_txns[txn_id] = txn
        self._notify_observers(user_id, old_balance, wallet.balance)
        return txn

    def transfer(self, from_user_id: str, to_user_id: str,
                 amount: float, txn_id: str) -> bool:
        # Idempotency check
        if txn_id in self._processed_txns:
            return True

        if from_user_id == to_user_id:
            raise ValueError("Cannot transfer to self")

        sender = self._get_wallet_or_throw(from_user_id)
        receiver = self._get_wallet_or_throw(to_user_id)

        sender_old = sender.balance
        receiver_old = receiver.balance

        command = TransferCommand(txn_id, sender, receiver, amount)
        success = command.execute()

        if success:
            self._processed_txns[txn_id] = Transaction(
                txn_id, TransactionType.TRANSFER_OUT, amount,
                sender.balance, to_user_id)
            self._notify_observers(from_user_id, sender_old, sender.balance)
            self._notify_observers(to_user_id, receiver_old, receiver.balance)

        return success

    def get_balance(self, user_id: str) -> float:
        return self._get_wallet_or_throw(user_id).balance

    def get_statement(self, user_id: str) -> List[Transaction]:
        return self._get_wallet_or_throw(user_id).get_statement()

    def add_observer(self, observer: WalletObserver) -> None:
        self._observers.append(observer)

    def _get_wallet_or_throw(self, user_id: str) -> Wallet:
        wallet = self._wallets.get(user_id)
        if wallet is None:
            raise ValueError(f"Wallet not found for user: {user_id}")
        return wallet

    def _notify_observers(self, user_id: str, old_balance: float,
                          new_balance: float) -> None:
        for observer in self._observers:
            observer.on_balance_change(user_id, old_balance, new_balance)
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include <mutex>
#include <memory>
#include <stdexcept>
#include "Wallet.hpp"
#include "TransferCommand.hpp"
#include "WalletObserver.hpp"

class WalletService {
private:
    std::unordered_map<std::string, std::unique_ptr<Wallet>> wallets;
    std::unordered_map<std::string, Transaction> processedTxns;
    std::vector<WalletObserver*> observers;
    mutable std::mutex serviceMutex;

    WalletService() = default;

public:
    // Singleton access
    static WalletService& getInstance() {
        static WalletService instance;
        return instance;
    }

    // Non-copyable
    WalletService(const WalletService&) = delete;
    WalletService& operator=(const WalletService&) = delete;

    Wallet& createWallet(const std::string& userId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        if (wallets.count(userId)) {
            throw std::runtime_error("Wallet already exists for user: " + userId);
        }
        wallets[userId] = std::make_unique<Wallet>(userId);
        return *wallets[userId];
    }

    Transaction credit(const std::string& userId, double amount,
                       const std::string& txnId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        // Idempotency check
        auto it = processedTxns.find(txnId);
        if (it != processedTxns.end()) {
            return it->second;
        }

        Wallet& wallet = getWalletOrThrow(userId);
        double oldBalance = wallet.getBalance();

        Transaction txn = wallet.credit(amount, txnId);

        processedTxns.emplace(txnId, txn);
        notifyObservers(userId, oldBalance, wallet.getBalance());
        return txn;
    }

    Transaction debit(const std::string& userId, double amount,
                      const std::string& txnId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        // Idempotency check
        auto it = processedTxns.find(txnId);
        if (it != processedTxns.end()) {
            return it->second;
        }

        Wallet& wallet = getWalletOrThrow(userId);
        double oldBalance = wallet.getBalance();

        Transaction txn = wallet.debit(amount, txnId);

        processedTxns.emplace(txnId, txn);
        notifyObservers(userId, oldBalance, wallet.getBalance());
        return txn;
    }

    bool transfer(const std::string& fromUserId, const std::string& toUserId,
                  double amount, const std::string& txnId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        // Idempotency check
        if (processedTxns.count(txnId)) {
            return true;
        }

        if (fromUserId == toUserId) {
            throw std::invalid_argument("Cannot transfer to self");
        }

        Wallet& sender = getWalletOrThrow(fromUserId);
        Wallet& receiver = getWalletOrThrow(toUserId);

        double senderOld = sender.getBalance();
        double receiverOld = receiver.getBalance();

        TransferCommand command(txnId, sender, receiver, amount);
        bool success = command.execute();

        if (success) {
            processedTxns.emplace(txnId, Transaction(
                txnId, TransactionType::TRANSFER_OUT, amount,
                sender.getBalance(), toUserId));
            notifyObservers(fromUserId, senderOld, sender.getBalance());
            notifyObservers(toUserId, receiverOld, receiver.getBalance());
        }

        return success;
    }

    double getBalance(const std::string& userId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        return getWalletOrThrow(userId).getBalance();
    }

    std::vector<Transaction> getStatement(const std::string& userId) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        return getWalletOrThrow(userId).getStatement();
    }

    void addObserver(WalletObserver* observer) {
        std::lock_guard<std::mutex> lock(serviceMutex);
        observers.push_back(observer);
    }

private:
    Wallet& getWalletOrThrow(const std::string& userId) {
        auto it = wallets.find(userId);
        if (it == wallets.end()) {
            throw std::invalid_argument("Wallet not found for user: " + userId);
        }
        return *(it->second);
    }

    void notifyObservers(const std::string& userId,
                         double oldBalance, double newBalance) {
        for (auto* observer : observers) {
            observer->onBalanceChange(userId, oldBalance, newBalance);
        }
    }
};

Main.java

A complete demo that exercises every feature: wallet creation, credits, debits, P2P transfers, idempotency verification, overdraft prevention, and transaction history. Run this to see the system in action.

package wallet;

import wallet.service.WalletService;
import wallet.model.Transaction;
import wallet.observer.WalletObserver;

import java.util.List;

public class Main {
    public static void main(String[] args) {
        WalletService service = WalletService.getInstance();

        // Register observer
        service.addObserver((userId, oldBal, newBal) ->
            System.out.printf("  [NOTIFY] %s: %.2f -> %.2f%n", userId, oldBal, newBal));

        // 1. Create wallets
        System.out.println("=== Creating Wallets ===");
        service.createWallet("alice");
        service.createWallet("bob");
        System.out.println("Created wallets for alice and bob");

        // 2. Credit money
        System.out.println("\n=== Credit Operations ===");
        service.credit("alice", 1000.0, "txn-001");
        service.credit("bob", 500.0, "txn-002");
        System.out.printf("Alice balance: %.2f%n", service.getBalance("alice"));
        System.out.printf("Bob balance: %.2f%n", service.getBalance("bob"));

        // 3. Idempotency test - same txn ID should not double-credit
        System.out.println("\n=== Idempotency Test ===");
        service.credit("alice", 1000.0, "txn-001"); // duplicate!
        System.out.printf("Alice balance (should still be 1000): %.2f%n",
            service.getBalance("alice"));

        // 4. P2P Transfer
        System.out.println("\n=== P2P Transfer ===");
        boolean success = service.transfer("alice", "bob", 250.0, "txn-003");
        System.out.printf("Transfer success: %b%n", success);
        System.out.printf("Alice balance: %.2f%n", service.getBalance("alice"));
        System.out.printf("Bob balance: %.2f%n", service.getBalance("bob"));

        // 5. Overdraft prevention
        System.out.println("\n=== Overdraft Prevention ===");
        boolean failed = service.transfer("alice", "bob", 5000.0, "txn-004");
        System.out.printf("Transfer of 5000 (should fail): %b%n", failed);
        System.out.printf("Alice balance (unchanged): %.2f%n", service.getBalance("alice"));

        // 6. Debit
        System.out.println("\n=== Debit Operation ===");
        service.debit("bob", 100.0, "txn-005");
        System.out.printf("Bob balance after debit: %.2f%n", service.getBalance("bob"));

        // 7. Transaction history
        System.out.println("\n=== Alice's Statement ===");
        List<Transaction> statement = service.getStatement("alice");
        for (Transaction txn : statement) {
            System.out.println("  " + txn);
        }

        System.out.println("\n=== Bob's Statement ===");
        statement = service.getStatement("bob");
        for (Transaction txn : statement) {
            System.out.println("  " + txn);
        }
    }
}
def main():
    # Reset for clean demo
    WalletService.reset_instance()
    service = WalletService()

    # Register observer
    service.add_observer(ConsoleLogObserver())

    # 1. Create wallets
    print("=== Creating Wallets ===")
    service.create_wallet("alice")
    service.create_wallet("bob")
    print("Created wallets for alice and bob")

    # 2. Credit money
    print("\n=== Credit Operations ===")
    service.credit("alice", 1000.0, "txn-001")
    service.credit("bob", 500.0, "txn-002")
    print(f"Alice balance: {service.get_balance('alice'):.2f}")
    print(f"Bob balance: {service.get_balance('bob'):.2f}")

    # 3. Idempotency test - same txn ID should not double-credit
    print("\n=== Idempotency Test ===")
    service.credit("alice", 1000.0, "txn-001")  # duplicate!
    print(f"Alice balance (should still be 1000): "
          f"{service.get_balance('alice'):.2f}")

    # 4. P2P Transfer
    print("\n=== P2P Transfer ===")
    success = service.transfer("alice", "bob", 250.0, "txn-003")
    print(f"Transfer success: {success}")
    print(f"Alice balance: {service.get_balance('alice'):.2f}")
    print(f"Bob balance: {service.get_balance('bob'):.2f}")

    # 5. Overdraft prevention
    print("\n=== Overdraft Prevention ===")
    failed = service.transfer("alice", "bob", 5000.0, "txn-004")
    print(f"Transfer of 5000 (should fail): {failed}")
    print(f"Alice balance (unchanged): {service.get_balance('alice'):.2f}")

    # 6. Debit
    print("\n=== Debit Operation ===")
    service.debit("bob", 100.0, "txn-005")
    print(f"Bob balance after debit: {service.get_balance('bob'):.2f}")

    # 7. Transaction history
    print("\n=== Alice's Statement ===")
    for txn in service.get_statement("alice"):
        print(f"  {txn}")

    print("\n=== Bob's Statement ===")
    for txn in service.get_statement("bob"):
        print(f"  {txn}")


if __name__ == "__main__":
    main()
#include <iostream>
#include <iomanip>
#include "WalletService.hpp"

int main() {
    WalletService& service = WalletService::getInstance();

    // Register observer
    ConsoleLogObserver logObserver;
    service.addObserver(&logObserver);

    // 1. Create wallets
    std::cout << "=== Creating Wallets ===" << std::endl;
    service.createWallet("alice");
    service.createWallet("bob");
    std::cout << "Created wallets for alice and bob" << std::endl;

    std::cout << std::fixed << std::setprecision(2);

    // 2. Credit money
    std::cout << "\n=== Credit Operations ===" << std::endl;
    service.credit("alice", 1000.0, "txn-001");
    service.credit("bob", 500.0, "txn-002");
    std::cout << "Alice balance: " << service.getBalance("alice") << std::endl;
    std::cout << "Bob balance: " << service.getBalance("bob") << std::endl;

    // 3. Idempotency test
    std::cout << "\n=== Idempotency Test ===" << std::endl;
    service.credit("alice", 1000.0, "txn-001"); // duplicate!
    std::cout << "Alice balance (should still be 1000): "
              << service.getBalance("alice") << std::endl;

    // 4. P2P Transfer
    std::cout << "\n=== P2P Transfer ===" << std::endl;
    bool success = service.transfer("alice", "bob", 250.0, "txn-003");
    std::cout << "Transfer success: " << (success ? "true" : "false") << std::endl;
    std::cout << "Alice balance: " << service.getBalance("alice") << std::endl;
    std::cout << "Bob balance: " << service.getBalance("bob") << std::endl;

    // 5. Overdraft prevention
    std::cout << "\n=== Overdraft Prevention ===" << std::endl;
    bool failed = service.transfer("alice", "bob", 5000.0, "txn-004");
    std::cout << "Transfer of 5000 (should fail): "
              << (failed ? "true" : "false") << std::endl;
    std::cout << "Alice balance (unchanged): "
              << service.getBalance("alice") << std::endl;

    // 6. Debit
    std::cout << "\n=== Debit Operation ===" << std::endl;
    service.debit("bob", 100.0, "txn-005");
    std::cout << "Bob balance after debit: "
              << service.getBalance("bob") << std::endl;

    // 7. Transaction history
    std::cout << "\n=== Alice's Statement ===" << std::endl;
    for (const auto& txn : service.getStatement("alice")) {
        std::cout << "  " << txn.toString() << std::endl;
    }

    std::cout << "\n=== Bob's Statement ===" << std::endl;
    for (const auto& txn : service.getStatement("bob")) {
        std::cout << "  " << txn.toString() << std::endl;
    }

    return 0;
}

Scale this design past a single process and these are the concepts it runs into:

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.

SensAI SensAI
Beta
Listening...
Tap mic to stop voice mode

Shape what we build next

Every piece of feedback is read by the team and directly influences our roadmap.

What type of feedback?

Install SystemCraft

Add to your home screen for instant access, offline reading, and a distraction-free experience.

Offline reading Faster loads No browser tabs App-like feel

Unlock AI Features

One click to activate - no payment, no credit card. Just sign in and you're in.

AI code review and hints
SensAI chat assistant
AI mock interviews
Whiteboard analysis
100% free during early access