Designing a Payment Wallet System
Difficulty: Medium Patterns: Command, Observer, Singleton Asked at: PhonePe, Razorpay, PayTM, Google Pay, Amazon
Functional Requirements
- Create Wallet โ register a new wallet for a given user ID with an initial balance of zero
- Credit / Debit โ add money (credit) or withdraw money (debit) from a wallet with overdraft prevention
- P2P Transfer โ atomically debit sender and credit receiver in a single operation
- Transaction History โ maintain a full ledger of all operations with timestamp, type, amount, and balance-after
- Idempotent Operations โ given the same transaction ID, the system returns the same result without re-executing (no double-spend)
- Balance Inquiry โ retrieve current wallet balance in O(1)
Non-Functional Requirements
- Thread-safety โ concurrent credits, debits, and transfers must not corrupt balances or produce race conditions
- Atomicity for transfers โ a P2P transfer either completes fully (both debit and credit) or not at all
- 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:
- Client calls
walletService.transfer("alice", "bob", 100.0, "txn-abc-123") - WalletService checks the idempotency store โ if
txn-abc-123was already processed, return the cached result immediately - Service looks up both wallets. If either doesnโt exist, throws an exception
- A
TransferCommandis created with sender=Alice, receiver=Bob, amount=100 - The command acquires locks on both wallets in a consistent order (sorted by user ID) to prevent deadlocks
- Aliceโs balance is checked โ if
balance < 100, the transfer is rejected (overdraft prevention) - Alice is debited:
balance -= 100, a TRANSFER_OUT transaction is appended to her ledger - Bob is credited:
balance += 100, a TRANSFER_IN transaction is appended to his ledger - The txn ID is recorded in the idempotency store
- Locks are released, observers are notified of both balance changes
- 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;
}
Related Concepts
Scale this design past a single process and these are the concepts it runs into:
- Idempotency โ โ the page builds an idempotency store; this is what that looks like when the store is shared and distributed
- Transactions & Isolation Levels โ โ a P2P transfer is the canonical two-account atomic transaction
- Distributed Locking โ โ serialising concurrent debits against the same wallet once the service runs on many nodes
- Event Sourcing & CQRS โ โ the append-only ledger is the source of truth and the balance is a projection over it
- Saga Pattern โ โ a cross-bank transfer cannot hold one transaction, so compensating entries replace rollback
- Write-Ahead Log โ โ the ledger entry has to be durable before the new balance is acknowledged to the caller
Discussion
Newest first