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.

August 11, 2026·15 min read

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#

EntityResponsibility
IBankAccountRead-only contract — getAccountNumber, getOwner, getBalance, getTransactionHistory
BankAccountAbstract base — manages balance and transaction list; exposes protected depositInternal / withdrawInternal
SavingBankAccountExtends BankAccount; adds applyInterest() at a fixed 3% rate
CheckingBankAccountExtends BankAccount; adds canWithdraw() to enforce an overdraft limit
TransactionImmutable record — type string, amount, and UTC timestamp
BankServiceSingleton 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.

java
import java.util.List;

interface IBankAccount {
    String getAccountNumber();
    String getOwner();
    double getBalance();
    List<Transaction> getTransactionHistory();
}

Account Hierarchy#

BankAccount is the abstract base that owns the balance and transaction list.
SavingBankAccount adds interest; CheckingBankAccount adds overdraft logic.

java
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; }
}

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.

java
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#

java
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.