Design a Banking System
Accounts, transactions, a double-entry ledger, interest, and statements with strong consistency.
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
Sequence Diagram
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.
Account
Abstract aggregate for balance, ledger, and locking. It exposes safe reads and package-level mutation methods used only by service commands.
SavingsAccount
Account product that rejects overdrafts and delegates interest calculation to the injected 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.
Transaction
Immutable ledger entry for one account. A transfer creates two entries that share intent but show each account's own resulting balance.
InterestStrategy
Strategy interface for monthly interest. It keeps rate formulas out of account mutation and enables product-specific calculations.
BankService
Application facade and transaction boundary. It creates accounts, executes commands, coordinates locks, stores aggregates, and publishes ledger notifications.
Design Patterns Used
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.
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.
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.
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
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(); }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"); } }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()); }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.
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.
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
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.
| Step | Command | Locks held | Savings balance | Current balance | Ledger effect |
|---|---|---|---|---|---|
| 1 | deposit(S, 250.00) | S | 1250.00 | 500.00 | DEPOSIT entry on S |
| 2 | transfer(S, C, 300.00) | C then S by id order | 950.00 | 800.00 | TRANSFER_OUT on S and TRANSFER_IN on C |
| 3 | withdraw(C, 700.00) | C | 950.00 | 100.00 | WITHDRAWAL entry on C |
| 4 | applyMonthlyInterest(S) | S | 952.85 | 100.00 | INTEREST entry on S |
| 5 | statement(S, today) | S read lock | 952.85 | 100.00 | Returns 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
| Operation | Time | Space | Note |
|---|---|---|---|
| createCustomer / openAccount | O(1) | O(1) | Hash-map insert plus object construction. |
| deposit / withdraw | O(1) | O(1) | One lock, one balance mutation, one ledger append. |
| transfer | O(1) | O(1) | Two locks acquired in deterministic order, two balance mutations, two ledger appends. |
| applyMonthlyInterest | O(1) | O(1) | One strategy call and an optional ledger append. |
| statement | O(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.What is the main reason transfer locks both accounts before changing either balance?
2.Why does the design use **InterestStrategy**?
3.Which bug is most likely if transfers lock source first and target second without sorting?
4.Why should observers be notified after the command commits ledger entries?
5.Where should current-account overdraft validation live?
Practice Variants
Add idempotent transfer requests
AdvancedAdd 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
IntermediateCreate 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
ExpertReplace 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
- BookPatterns of Enterprise Application Architecture — Martin Fowler
- BookEffective Java — Item 60: Avoid float and double if exact answers are required — Joshua Bloch
- DocsRefactoring Guru — Command Pattern
- DocsOracle Java Docs — ReentrantLock