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
Your fintech startup processes 50,000 transactions per day. Last week, two customers reported negative balances on savings accounts that should never go below zero. The root cause: a race condition in the withdrawal logic. One thread checked the balance, another thread processed a debit in between, and the first thread completed its withdrawal against a stale balance. The post-mortem also revealed that adding a new account type (fixed deposit) required changes in 14 different files because account rules were 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.
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
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.
I'll often see candidates skip the TransactionLog and go straight to coding. That is a red flag in a banking interview. Interviewers expect you to mention immutability and audit trails unprompted.
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:
| Need from requirements | Method |
|---|---|
| "Process deposits" | deposit(amount): Transaction |
| "Process withdrawals" | withdraw(amount): Transaction |
| "Different withdrawal rules" | validateWithdrawal(amount): boolean (abstract) |
| "Daily limits differ by type" | getDailyLimit(): double (abstract) |
| "Calculate interest" | applyInterest(): Transaction |
| "Query balance" | getBalance(): double |
The deposit method is concrete in the base class because the deposit flow is identical across account types: validate positive amount, add to balance, log it. Withdrawal is also concrete in the base class (the Template Method), but it calls validateWithdrawal() which each subclass overrides.
SavingsAccount vs CheckingAccount
The key difference is one line: how validateWithdrawal checks the floor.
- SavingsAccount:
balance - amount >= 0(hard floor at zero) - CheckingAccount:
balance - amount >= -overdraftLimit(floor at negative overdraft)
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.