Low Level Design
Design a Bank Account System
A complete low-level design walkthrough for a bank account system — from requirements clarification to SOLID principles, design patterns, and a clean Java implementation with Singleton service, abstract accounts, and transaction history.
Problem Description#
Design a Bank Account System that supports multiple account types, core banking operations, and transaction tracking.
At its core, a bank account system is an encapsulation problem:
- Accounts hold a balance that must only change through controlled operations
- Different account types have different rules (savings earns interest, checking has an overdraft limit)
- All transactions must be routed through a central service — no account should mutate itself directly
- A full history of every operation must be preserved
This is a classic LLD interview problem because it highlights encapsulation, inheritance hierarchies, SOLID principles, and the Singleton pattern — all in a familiar domain.
Clarify Requirements#
Before designing anything, ask these questions in an interview:
Functional
- What account types are needed? (Savings, Checking, Fixed Deposit?)
- Can any code create an account and call deposit/withdraw directly, or must it go through a service?
- Should savings accounts earn interest automatically or on demand?
- Does checking have an overdraft facility? If so, what's the limit?
- What should a transaction record capture — type, amount, timestamp?
- Is fund transfer between accounts supported?
Non-functional
- Do we need thread safety for concurrent transactions?
- Should transaction history be persisted or in-memory only?
- Is multi-bank / multi-branch support required?
Final Requirements#
After clarification, here's what we'll build:
- Support two account types: SavingBankAccount (earns interest) and CheckingBankAccount (allows overdraft up to a limit)
- Deposits and withdrawals are routed through BankService — accounts never modify themselves directly
- SavingBankAccount exposes applyInterest() which adds a fixed 3% interest
- CheckingBankAccount allows overdraft up to $500 via canWithdraw()
- Fund transfers between accounts go through BankService.transfer()
- Every operation appends a timestamped Transaction to the account's history
- BankService is a Singleton — one central controller for all transactions
Core Entities#
| Entity | Responsibility |
|---|---|
| IBankAccount | Read-only contract — getAccountNumber, getOwner, getBalance, getTransactionHistory |
| BankAccount | Abstract base — manages balance and transaction list; exposes protected depositInternal / withdrawInternal |
| SavingBankAccount | Extends BankAccount; adds applyInterest() at a fixed 3% rate |
| CheckingBankAccount | Extends BankAccount; adds canWithdraw() to enforce an overdraft limit |
| Transaction | Immutable record — type string, amount, and UTC timestamp |
| BankService | Singleton facade — deposit, withdraw, transfer; all validation lives here |
Patterns Used#
1. Singleton — BankService#
There should be exactly one BankService controlling all transactions. We use the Initialization-on-demand holder idiom: the inner static class BankServiceHolder is only loaded when getInstance() is first called, which the JVM guarantees is thread-safe without any synchronized keyword overhead.
BankService
└─ BankServiceHolder.INSTANCE ← created once, lazily, thread-safely
2. Abstract Class / Template Method — BankAccount#
BankAccount defines the internal mutation primitives (depositInternal, withdrawInternal) and the full public read contract. Subclasses inherit all the accounting mechanics and only add type-specific behavior (applyInterest, canWithdraw). The BankService calls the protected primitives — no code outside the package can mutate a balance directly.
This satisfies Open/Closed (new account types add behavior without changing BankService) and Liskov Substitution (any BankAccount subtype works wherever a BankAccount is expected).
3. Interface Segregation — IBankAccount#
IBankAccount exposes only the four read-only methods a consumer needs. BankService takes BankAccount (the concrete abstract class) as its parameter type because it needs to call the protected internal methods — but any reporting or display component would depend only on IBankAccount.
4. Encapsulation — balance is private#
balance is a private field on BankAccount. The only way to change it is through depositInternal / withdrawInternal, which are package-private and only called by BankService. This is the Data Hiding pillar of OOP — the balance cannot be set from outside the banking package.
Code#
Foundation — Interface & Transaction#
IBankAccount defines the read-only contract.
Transaction is an immutable record that captures every operation with a timestamp.
import java.util.List;
interface IBankAccount {
String getAccountNumber();
String getOwner();
double getBalance();
List<Transaction> getTransactionHistory();
}class Transaction {
private final String type;
private final double amount;
private final String timestamp;
Transaction(String type, double amount) {
this.type = type;
this.amount = amount;
this.timestamp = java.time.LocalDateTime.now().toString();
}
public String toString() {
return timestamp + " - " + type + ": " + amount;
}
}Account Hierarchy#
BankAccount is the abstract base that owns the balance and transaction list.
SavingBankAccount adds interest; CheckingBankAccount adds overdraft logic.
import java.util.ArrayList;
import java.util.List;
abstract class BankAccount implements IBankAccount {
private String accountNumber;
private String owner;
private double balance;
List<Transaction> transactionList;
public BankAccount(String accountNumber, String owner, double initialBalance) {
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = initialBalance;
this.transactionList = new ArrayList<>();
}
// Only BankService (same package) can call these
void depositInternal(double amount) {
balance += amount;
transactionList.add(new Transaction("Deposit", amount));
}
void withdrawInternal(double amount) {
balance -= amount;
transactionList.add(new Transaction("Withdraw", amount));
}
@Override public double getBalance() { return balance; }
@Override public List<Transaction> getTransactionHistory(){ return transactionList; }
@Override public String getAccountNumber() { return accountNumber; }
@Override public String getOwner() { return owner; }
}class SavingBankAccount extends BankAccount {
private static final double INTEREST_RATE = 0.03; // 3%
public SavingBankAccount(String accountNumber, String owner, double initialBalance) {
super(accountNumber, owner, initialBalance);
}
/** Applies 3% interest directly to the balance and records the transaction. */
public void applyInterest() {
double interest = getBalance() * INTEREST_RATE;
depositInternal(interest);
System.out.println("Interest applied: " + interest);
}
}class CheckingBankAccount extends BankAccount {
private static final double OVERDRAFT_LIMIT = 500;
public CheckingBankAccount(String accountNumber, String owner, double initialBalance) {
super(accountNumber, owner, initialBalance);
}
/** Returns true if the withdrawal is within the overdraft limit. */
public boolean canWithdraw(double amount) {
return (getBalance() + OVERDRAFT_LIMIT) >= amount;
}
}Service Layer — Singleton#
BankService is the single point of entry for all mutations.
It validates, enforces per-type rules, and delegates to the protected account methods.
class BankService {
// Initialization-on-demand holder — thread-safe lazy singleton
private BankService() {}
private static class BankServiceHolder {
private static final BankService INSTANCE = new BankService();
}
public static BankService getInstance() {
return BankServiceHolder.INSTANCE;
}
public void deposit(BankAccount account, double amount) {
if (amount <= 0)
throw new IllegalArgumentException("Deposit amount must be positive");
account.depositInternal(amount);
System.out.printf("%.2f deposited to %s. New balance: %.2f%n",
amount, account.getAccountNumber(), account.getBalance());
}
public void withdraw(BankAccount account, double amount) {
if (amount <= 0)
throw new IllegalArgumentException("Withdrawal amount must be positive");
if (account instanceof CheckingBankAccount
&& !((CheckingBankAccount) account).canWithdraw(amount))
throw new IllegalArgumentException("Overdraft limit exceeded");
if (account.getBalance() < amount)
throw new IllegalArgumentException("Insufficient funds");
account.withdrawInternal(amount);
System.out.printf("%.2f withdrawn from %s. New balance: %.2f%n",
amount, account.getAccountNumber(), account.getBalance());
}
public void transfer(BankAccount from, BankAccount to, double amount) {
if (from.equals(to))
throw new IllegalArgumentException("Source and destination accounts cannot be the same");
withdraw(from, amount);
deposit(to, amount);
from.transactionList.add(new Transaction("Transfer to " + to.getAccountNumber(), amount));
to.transactionList.add(new Transaction("Transfer from " + from.getAccountNumber(), amount));
System.out.printf("Transferred %.2f from %s to %s%n",
amount, from.getAccountNumber(), to.getAccountNumber());
}
}
Demo#
public class BankApplication {
public static void main(String[] args) {
BankService bank = BankService.getInstance();
SavingBankAccount savings = new SavingBankAccount("S1", "Divya", 1000);
CheckingBankAccount checking = new CheckingBankAccount("C1", "Divya", 500);
bank.deposit(savings, 100); // S1: 1100
savings.applyInterest(); // S1: 1133 (3% of 1100)
bank.withdraw(checking, 100); // C1: 400
bank.transfer(savings, checking, 50); // S1: 1083, C1: 450
System.out.println("Savings balance: " + savings.getBalance());
System.out.println("Checking balance: " + checking.getBalance());
System.out.println("\n-- Savings Transaction History --");
savings.getTransactionHistory().forEach(System.out::println);
}
}
Class Diagram#
Extendible — Follow Ups#
1. Fixed Deposit account#
Add FixedDepositBankAccount extends BankAccount with a lock-in period and a penalty for early withdrawal. BankService.withdraw() checks account instanceof FixedDepositBankAccount and delegates to a canWithdraw(amount, date) check — no changes to existing types.
2. Thread-safe transactions#
Wrap depositInternal / withdrawInternal with synchronized on the account instance, or use ReentrantLock for finer control. BankService.transfer should lock both accounts in a canonical order (by account number) to prevent deadlocks.
3. Pluggable interest strategy#
Extract InterestStrategy (a @FunctionalInterface with double calculate(double balance)). Pass it into SavingBankAccount's constructor so different accounts can use simple interest, compound interest, or tiered rates — swap at construction time without modifying the class.
4. Overdraft as a separate concern#
Move overdraft logic out of CheckingBankAccount into an OverdraftPolicy interface. BankService.withdraw calls policy.canWithdraw(account, amount). This makes the policy testable independently and swappable per-account.
5. Persistent transaction history#
Replace the in-memory List<Transaction> with a TransactionRepository interface. Swap in a JdbcTransactionRepository in production — the BankAccount and BankService classes need no changes.
6. Audit log / event sourcing#
Publish a TransactionEvent after every operation in BankService. An AuditLogger subscriber records every event to a separate store. This gives a full event trail without cluttering the core banking logic.