Compile Ready
All low level design problems
Low Level Design/Expert/Expert Systems

Design a Banking System

Accounts, transactions, a double-entry ledger, interest, and statements with strong consistency.

Expert 70m interview 17m read Medium frequency Popularity 82
State Command Strategy Repository Observer PayPal Amazon Oracle

Problem Statement

Design the object model for a core banking system that supports customers, savings and current accounts, deposits, withdrawals, account-to-account transfers, a transaction ledger, interest accrual, overdraft rules, and account statements.

The focus is a clean domain model, not a full internet banking product. A strong answer shows how money-moving operations remain atomic, how account types vary without scattered conditionals, and how a ledger provides an auditable history of every balance change.

Business context

Banking LLD questions are expert-level because the obvious API hides hard constraints: money cannot disappear during a transfer, overdraft rules differ by account type, interest calculation changes by product, and every mutation needs an audit trail.

Interviewers use this problem to test whether you can combine object-oriented modeling with concurrency boundaries. The model should make simple operations easy, but it must also make illegal states hard: no negative savings balance, no half-completed transfer, no statement reading a corrupted ledger, and no new account type requiring edits across the service.

Functional Requirements

  • Create customers and attach multiple accounts to each customer.

  • Support savings and current accounts through a shared account abstraction.

  • Allow deposits and withdrawals with account-specific validation rules.

  • Transfer money between two accounts atomically, with mirrored ledger entries.

  • Keep a transaction ledger per account for deposits, withdrawals, transfers, and interest credits.

  • Calculate interest through a pluggable strategy so products can use different formulas.

  • Enforce overdraft rules for current accounts while savings accounts cannot go below zero.

  • Generate account statements for a requested time window.

  • Notify interested observers whenever a transaction is committed.

Non-Functional Requirements

Atomicity

A transfer must either debit the source and credit the destination, or do neither. The command holds both account locks during the mutation and records both ledger entries in the same critical section.

Thread-safety

Multiple tellers, ATMs, and scheduled jobs may operate at once. Mutable account state is protected by per-account locks, and transfers acquire locks in deterministic account-id order to avoid deadlocks.

Auditability

Every balance-changing operation appends immutable transaction entries with timestamp, amount, type, resulting balance, and counterparty when applicable.

Extensibility

New account products should arrive as new subclasses and strategies. The service flow should not grow account-type switch statements for every rule.

Precision

Use decimal money values and explicit rounding for interest. Avoid floating point for balance and interest calculations.

Requirement Clarification

QDo we need double-entry accounting across general ledger accounts?

For the interview model, keep per-account ledger entries and mirror transfer entries for both accounts. A production core banking system would add a full general ledger as a separate bounded context.

QAre deposits and withdrawals cash-only or can they come from external systems?

Treat them as domain commands that change an account balance. External payment rails, cash machines, and teller terminals are adapters that call these commands.

QCan a savings account be overdrawn?

No. Savings accounts reject withdrawals that would make the balance negative. Current accounts may go negative up to a configured overdraft limit.

QHow is interest calculated?

Interest is delegated to an InterestStrategy. The sample uses an annual-rate strategy that computes monthly interest, but tiered, promotional, or zero-interest strategies can be injected.

QDo statements have to include pending transactions?

No. The statement reads the committed ledger only. Pending authorization, reconciliation, and reversal workflows are advanced extensions.

UML Class Diagram

Rendering diagram…
The shared **Account** abstraction owns balance and ledger state, subclasses enforce product rules, **InterestStrategy** handles interest variability, and **BankService** is the facade that executes commands under locks.

Sequence Diagram

Rendering diagram…
The transfer command owns the critical section. Observers are notified after the ledger entries are committed so observer failures cannot create a half-transfer.

Entity Identification

Customer

Represents the account holder and stores the set of account ids owned by the customer. It does not own balances or ledger entries.

idnameaccountIds

Account

Abstract aggregate for balance, ledger, and locking. It exposes safe reads and package-level mutation methods used only by service commands.

idcustomerIdbalanceledgerlock

SavingsAccount

Account product that rejects overdrafts and delegates interest calculation to the injected InterestStrategy.

interestStrategy

CurrentAccount

Account product that allows the balance to go negative up to a configured overdraft limit, and returns zero interest in the sample implementation.

overdraftLimit

Transaction

Immutable ledger entry for one account. A transfer creates two entries that share intent but show each account's own resulting balance.

idtypeaccountIdcounterpartyAccountIdamountresultingBalancetimestamp

InterestStrategy

Strategy interface for monthly interest. It keeps rate formulas out of account mutation and enables product-specific calculations.

calculateMonthlyInterest(balance)

BankService

Application facade and transaction boundary. It creates accounts, executes commands, coordinates locks, stores aggregates, and publishes ledger notifications.

customersaccountsobserverssequences

Design Patterns Used

Strategy

InterestStrategy isolates interest formulas from account mutation. A new promotional or slab-based formula is a new strategy, not a rewrite of SavingsAccount or BankService.

Factory Method

BankService.createAccount is the factory method behind openAccount. It centralises construction for savings and current products and is the only place that knows which concrete account class to instantiate.

Command

Deposits, withdrawals, transfers, and interest accrual are modeled as command objects. Each command owns validation, locking, mutation, and ledger entry creation for one atomic operation.

Observer

Registered TransactionObserver instances receive committed transactions for notifications, fraud scoring, analytics, or audit streaming without coupling those systems to the core mutation flow.

Step-by-Step Design

  1. 1Start with account as the concurrency aggregate

    The account owns balance and ledger state, so it also owns the lock protecting them. Single-account commands take one lock; transfer commands take two locks in account-id order to avoid deadlock.

    Account first = source.getId().compareTo(target.getId()) < 0 ? source : target;
    Account second = first == source ? target : source;
    first.lock().lock();
    second.lock().lock();
    try {
        source.debit(amount);
        target.credit(amount);
    } finally {
        second.lock().unlock();
        first.lock().unlock();
    }
  2. 2Use a hierarchy for account product rules

    Common behavior lives in Account. Savings and current accounts only override the withdrawal rule and interest behavior, so new products stay localized.

    protected abstract void validateWithdrawal(BigDecimal amount);
    
    @Override
    protected void validateWithdrawal(BigDecimal amount) {
        if (unsafeBalance().compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient funds for savings account");
        }
    }
  3. 3Make interest a strategy, not an account switch

    Interest formulas vary more frequently than account identity. The account delegates to an injected strategy, and the service can choose the strategy during account creation.

    public interface InterestStrategy {
        BigDecimal calculateMonthlyInterest(BigDecimal balance);
    }
    
    public BigDecimal calculateInterest() {
        return interestStrategy.calculateMonthlyInterest(getBalance());
    }
  4. 4Represent money movement as commands

    Each operation becomes a command with one execute method. This keeps validation, mutation, and ledger creation together and makes it easier to add reversal or retry metadata later.

  5. 5Record the ledger inside the critical section

    The balance change and ledger append belong together. For a transfer, both account entries are appended while both locks are still held, so statements never see only half of a transfer.

  6. 6Publish observers only after commit

    Observers are useful for email alerts and fraud systems, but they are outside the balance invariant. Notify them after the command returns committed entries so a slow observer does not hold account locks.

Complete Java Implementation

Loading…

Explanation of Every Class

Customer

Customer is a small identity object with synchronized account-id membership. It keeps ownership visible without mixing customer data into balance mutation.

Account

Account is the abstract aggregate for balance, ledger, and lock. Its package-level credit, debit, and append methods keep all mutation inside command execution.

SavingsAccount

SavingsAccount rejects withdrawals beyond available balance and delegates monthly interest to InterestStrategy, making new rate formulas additive.

CurrentAccount

CurrentAccount adds an overdraft limit. Its withdrawal rule allows projected balance down to negative overdraft limit and rejects anything beyond that boundary.

Transaction

Transaction is immutable ledger data: type, amount, account, optional counterparty, timestamp, resulting balance, and description. Statements are filtered views over these entries.

InterestStrategy

InterestStrategy is the pluggable rate seam. The file includes annual-rate and no-interest implementations to demonstrate product-specific interest without service changes.

BankService

BankService is the facade, factory, command dispatcher, and observer publisher. It stores customers/accounts, creates account subclasses, executes atomic operations, and publishes committed ledger entries.

Main

Main wires the demo: creates a customer, opens savings and current accounts, performs deposit, transfer, withdrawal, interest accrual, and prints a statement.

Dry Run

Sample input

Customer Asha has Savings S with 1000.00 at 3.6% annual interest and Current C with 500.00 plus 2000.00 overdraft. Actions: deposit 250.00 into S, transfer 300.00 from S to C, withdraw 700.00 from C, apply monthly interest to S.

StepCommandLocks heldSavings balanceCurrent balanceLedger effect
1deposit(S, 250.00)S1250.00500.00DEPOSIT entry on S
2transfer(S, C, 300.00)C then S by id order950.00800.00TRANSFER_OUT on S and TRANSFER_IN on C
3withdraw(C, 700.00)C950.00100.00WITHDRAWAL entry on C
4applyMonthlyInterest(S)S952.85100.00INTEREST entry on S
5statement(S, today)S read lock952.85100.00Returns S deposit, transfer out, interest

Step 2 is the important correctness point: both accounts are locked before either balance changes, so no reader can observe a debit without the matching credit. Step 4 computes 950.00 × 0.036 ÷ 12 = 2.85.

Complexity Analysis

OperationTimeSpaceNote
createCustomer / openAccountO(1)O(1)Hash-map insert plus object construction.
deposit / withdrawO(1)O(1)One lock, one balance mutation, one ledger append.
transferO(1)O(1)Two locks acquired in deterministic order, two balance mutations, two ledger appends.
applyMonthlyInterestO(1)O(1)One strategy call and an optional ledger append.
statementO(T)O(R)T ledger entries scanned for the account, R entries returned in the requested window.

The in-memory model keeps mutations constant-time because each account owns its own ledger list. At production scale, statement queries usually move to an indexed ledger repository while the same command boundary remains.

Extensibility

New account product

Add a subclass of Account and extend the factory method. For example, a salary account can override withdrawal limits and inject a different interest strategy.

New interest formula

Implement InterestStrategy for slab rates, daily balance interest, senior-citizen rates, or promotional periods. Existing account commands stay unchanged.

Ledger persistence

Replace the in-memory ledger list with a repository that appends transaction rows inside a database transaction. The command remains the domain-level transaction boundary.

Alerts and fraud checks

Add observers for SMS, email, suspicious-transfer detection, and analytics. Observers subscribe to committed ledger entries without changing account code.

Reversals and holds

Introduce reversal commands and pending-hold entries. The Command pattern gives a natural place to store reversal metadata and idempotency keys.

Alternative Designs

Central ledger as the source of truth

Store all transactions in a central append-only ledger and derive balances by folding ledger entries or reading a materialized balance table.

Tradeoffs

Excellent auditability and reconciliation, but more infrastructure and careful snapshotting are needed to keep reads fast.

Strategy-only account type

Use one concrete Account class with injected withdrawal, interest, and fee strategies instead of subclasses.

Tradeoffs

More composable for many product variations, but can become harder to reason about when too many policies combine at runtime.

Database transaction boundary

Move atomicity to row-level locks and database transactions over account and ledger tables.

Tradeoffs

Required for multi-instance production deployments, but it makes the interview model heavier and hides useful object responsibilities.

Common Mistakes

  • ×

    Implementing transfer as withdraw followed by deposit without a shared atomic boundary.

  • ×

    Using floating-point values for balances and interest calculations.

  • ×

    Putting savings/current rules in scattered if accountType checks instead of account subclasses or strategies.

  • ×

    Appending ledger entries after releasing locks, allowing statements to miss committed balance changes.

  • ×

    Locking source then target without a deterministic order, creating deadlock risk for opposite-direction transfers.

  • ×

    Letting observers run inside account locks, making notifications part of the critical path.

  • ×

    Returning the mutable ledger list directly from the account.

Follow-up Interview Questions

QHow would you make the transfer safe across multiple application instances?

Use a database transaction with row locks on both accounts, acquired in deterministic order, and append both ledger entries in the same transaction. The in-memory locks then become local optimization, not the source of truth.

QHow do you prevent duplicate retries from applying the same transfer twice?

Add an idempotency key to commands and store it with the ledger transaction group. A retry checks the key and returns the prior result instead of mutating balances again.

QWhere would fees fit?

Fees can be another strategy or command decoration. A withdrawal command could append a WITHDRAWAL entry and a FEE entry in the same critical section if the policy says fees apply.

QHow would you support account freeze or closed states?

Add a state field or State pattern to Account so commands ask whether a mutation is allowed before debit or credit. Closed accounts can reject all balance-changing commands.

QWhy not notify observers before committing?

Observers are side effects. If they run before commit, users or fraud systems can see transactions that later fail. Publish after ledger append and make observer delivery retryable.

Production Considerations

Durable ledger

Use an append-only ledger table with transaction group id, account id, direction, amount, resulting balance, and idempotency key. Never update historical entries.

Database isolation

For transfers, acquire account rows in sorted order and commit both balance updates plus ledger rows in one serializable or repeatable-read transaction.

Money representation

Use minor units or decimal types with currency codes and rounding policies. Do not mix currencies in one account unless an FX workflow is modeled explicitly.

Observability and audit

Emit metrics for failed withdrawals, overdraft usage, transfer latency, observer failures, and ledger append errors. Audit every administrative policy change.

Security

Authorization belongs before command execution. The service should verify the actor may operate on the customer/account before any lock or mutation happens.

What Interviewers Look For

  • Did the candidate identify transfer atomicity as the core risk?

  • Are account-specific rules localized in subclasses or strategies rather than conditionals everywhere?

  • Is thread-safety explicit, including deterministic lock ordering for transfers?

  • Does every money movement create an auditable transaction entry?

  • Can new account products, interest formulas, and observers be added without rewriting the service flow?

  • Does the design separate committed ledger state from external notifications and adapters?

Quiz

0/5 answered

  1. 1.What is the main reason transfer locks both accounts before changing either balance?

  2. 2.Why does the design use **InterestStrategy**?

  3. 3.Which bug is most likely if transfers lock source first and target second without sorting?

  4. 4.Why should observers be notified after the command commits ledger entries?

  5. 5.Where should current-account overdraft validation live?

Practice Variants

Add idempotent transfer requests

Advanced

Add a request id to every command and store it with a transaction group so retries return the original ledger entries instead of applying twice.

Add fixed deposit accounts

Intermediate

Create a new account product with maturity date, early-withdrawal penalty, and a different interest strategy. Keep service changes limited to the factory method.

Persist the ledger

Expert

Replace account-local ledger lists with a repository and implement transfer using a database transaction. Preserve the same domain commands.

Flashcards

Cheat Sheet

Entities: Customer, Account, SavingsAccount, CurrentAccount, Transaction, InterestStrategy, BankService.

Patterns: Strategy for interest, Factory Method for account creation, Command for money movement, Observer for transaction notifications.

Flows: deposit = lock account → credit → append ledger; withdraw = lock account → validate product rule → debit → append ledger; transfer = lock both accounts in sorted order → debit source → credit target → append mirrored entries.

Invariants: no negative savings balance; current balance cannot go below negative overdraft limit; transfer never half-commits; statements read committed ledger entries only.

Thread-safety: per-account locks for mutable balance and ledger; deterministic lock ordering for two-account operations; observers run after locks are released.

Extend: add account subclass for a new product, add interest strategy for a new formula, add command for reversals, add observer for alerts and analytics.

References