Design a Bank Account System
OOP design for a banking system covering account types, transaction processing, overdraft protection, and thread-safe balance operations.
The Problem
Suppose a fintech service processes 50,000 transactions per day. Two customers report negative balances on savings accounts that should never go below zero. The root cause is a race condition in withdrawal logic: one thread checks the balance, another debits it, and the first completes against a stale value. A later review also finds that adding a fixed-deposit account requires changes in 14 files because account rules are scattered across switch statements.
A bank account system is a classic OOP problem that tests your ability to model entities with shared behavior but different rules. Savings accounts earn compound interest but prohibit overdrafts. Checking accounts allow overdrafts up to a limit but earn no interest. Transfers between accounts must be atomic: debit one and credit the other, or neither.
Design the core classes for a banking system that handles multiple account types, transaction processing with overdraft protection, interest calculation, and thread-safe balance operations.
Scope
In scope: savings and checking accounts, deposits and withdrawals, interest policies, append-only transaction records, thread-safe transfers, and a bank-level orchestration boundary.
Out of scope: authentication, persistence and database transactions, payment rails, tax reporting, and scheduled transfers. The LLD focuses on account invariants and atomic balance changes.
30-Second Design Summary
Bank and TransactionService orchestrate operations over an Account abstraction. The account template owns the shared validate β mutate β record flow, subclasses supply account-specific withdrawal rules, and an interest strategy supplies the calculation policy. Transfers lock both accounts in deterministic order so debit and credit commit together or neither happens.
5-Minute Walkthrough
- Account creation selects a concrete account policy while exposing the common
Accountcontract to callers. - A deposit or withdrawal enters the template method, validates amount and status, then mutates the balance under the account lock and appends an immutable transaction record.
- Interest calculation is delegated to the configured strategy, so account state does not contain a switch for every rate model.
- A transfer acquires source and destination locks in ID order, validates both sides, applies debit and credit, and records the paired operation.
- Reads expose consistent snapshots; failed validation leaves balances and the transaction log unchanged.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to turn the vague prompt into a concrete specification. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "What account types do we need to support?"
Interviewer: "Savings and checking for now. But we plan to add fixed deposit accounts soon."
That tells you account types need to be extensible. Hard-coded type checks will not survive.
You: "Can savings accounts go negative, or is that checking-only?"
Interviewer: "Savings accounts cannot go below zero. Checking accounts have a configurable overdraft limit."
Good. Different withdrawal rules per account type. This is a strong signal for polymorphism.
You: "How does interest work? Same formula for all accounts?"
Interviewer: "Savings uses compound interest. Checking earns nothing. Fixed deposit uses simple interest. The rate varies by account."
Three different interest strategies. This practically screams Strategy pattern.
You: "Should transfers between accounts be atomic? What if the debit succeeds but the credit fails?"
Interviewer: "Atomic. Either both succeed or neither. Never leave money in limbo."
That last question matters because atomicity in transfers is the hardest concurrency challenge in this problem. The interviewer wants to hear you think about failure modes.
You: "Are there daily transaction limits?"
Interviewer: "Yes. A configurable daily withdrawal limit per account. Savings has a stricter limit than checking."
Another rule that differs by account type. More evidence for the polymorphic approach.
You: "Do we need to maintain a transaction history?"
Interviewer: "Yes. Every transaction is logged immutably. You should be able to replay the log to recompute any balance."
Append-only transaction log. The balance on the account is essentially a cached projection.
You: "Is the system multi-threaded? Can two withdrawals happen on the same account concurrently?"
Interviewer: "Yes. Multiple tellers or ATMs can hit the same account. Thread safety is required."
Perfect. You have clarified scope and ruled out unnecessary complexity.
Final Requirements
Functional Requirements:
- Support multiple account types (savings, checking) with different withdrawal rules
- Process deposits, withdrawals, and account-to-account transfers
- Enforce overdraft protection: savings rejects negative balances, checking allows up to a configurable limit
- Calculate interest using different strategies per account type
- Maintain an immutable, append-only transaction log for every account
- Enforce configurable daily withdrawal limits per account type
Non-Functional Requirements:
- Thread safety for concurrent balance operations on the same account
- Adding a new account type requires a new class, not changes to existing code
- Transfers are atomic: both sides succeed or neither does
Out of Scope:
- UI or API layer
- Persistence or database
- Network/distributed transactions
- KYC or compliance checks
- Currency conversion
Example Inputs and Outputs
Scenario 1: Successful withdrawal from checking account
- Setup: Checking account with $500 balance, $200 overdraft limit
- Action: Withdraw $600
- Expected: Withdrawal succeeds. Balance becomes -$100 (within overdraft limit). Transaction logged.
- Why: Validates overdraft logic. $600 withdrawal from $500 balance leaves -$100, which is within the -$200 overdraft floor.
Scenario 2: Rejected withdrawal from savings account
- Setup: Savings account with $300 balance, no overdraft
- Action: Withdraw $400
- Expected: Withdrawal rejected with "insufficient funds." Balance unchanged. No transaction logged.
- Why: Validates that savings accounts enforce zero-floor policy.
Scenario 3: Atomic transfer between accounts
- Setup: Account A has $1,000, Account B has $200
- Action: Transfer $500 from A to B
- Expected: A's balance becomes $500, B's balance becomes $700. Two transaction entries created (one debit, one credit) linked by a reference ID.
- Why: Validates atomicity. Both accounts update together.
Try It Yourself
Try it yourself
Before reading the solution, spend 15 minutes sketching a class diagram. Focus on how you would handle different withdrawal rules per account type without if-else chains, and how you would make transfers atomic. Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Entities and Responsibilities
The responsibility map below separates account invariants, transaction history, interest policy, and transfer coordination so account subclasses do not become a second transaction service.
Start by asking: what are the main "things" in this problem? Look at the nouns in your requirements. You will find accounts, customers, transactions, and the bank itself. The tricky part is deciding what deserves its own class versus what is just a field.
A common mistake is putting everything in a single BankAccount class with type flags. Good design means each class has a single, clear job.
| Entity | Responsibility | Key Attributes |
|---|---|---|
| Bank | Orchestrator. Manages accounts, routes transactions, holds the account registry. | accounts, transactionService |
| Account (abstract) | Defines the shared deposit/withdraw contract. Each subclass enforces its own rules. | id, customerId, balance, transactionLog |
| SavingsAccount | Savings-specific rules: no overdraft, compound interest, lower daily limit. | interestRate, dailyLimit |
| CheckingAccount | Checking-specific rules: overdraft allowed, no interest, higher daily limit. | overdraftLimit, dailyLimit |
| Transaction | Immutable record of a single balance change. The source of truth. | id, accountId, type, amount, balanceBefore, balanceAfter, timestamp |
| TransactionLog | Append-only collection of transactions per account. Supports replay. | entries |
| Customer | Owner of one or more accounts. Data holder, no business logic. | id, name, accounts |
| InterestStrategy | Calculates interest differently per account type. Pluggable. | calculateInterest() |
Notice that Account is abstract. Savings and checking share the deposit/withdraw lifecycle but differ in validation rules. Merging them violates SRP because one class would need to check "am I savings or checking?" before every operation.
A common omission is skipping TransactionLog and going straight to balance mutation. That loses immutability and makes reversals or audits ambiguous, so the log belongs in the entity map from the start.
Step 2: Define Relationships and Class Design
Class Diagram
Class Interface Derivation
Account (the core abstraction)
Account is abstract because savings and checking share the same lifecycle (deposit, withdraw, get balance, apply interest) but differ in validation rules. The Template Method pattern fits perfectly: the base class defines the algorithm skeleton, and subclasses override the varying steps.
Deriving state from requirements:
| Requirement | What Account must track |
|---|---|
| "Process deposits and withdrawals" | Current balance |
| "Maintain immutable transaction log" | TransactionLog reference |
| "Thread safety for concurrent operations" | A lock per account |
| "Different rules per account type" | Abstract validation methods |
| "Daily withdrawal limits" | Access to today's withdrawal total (via log) |
Deriving methods from needs:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.