Overview
A bank account is the textbook example of why encapsulation exists in the first place, and Java's exception handling is what turns "reject an invalid withdrawal" from a silent bug into an explicit, unmissable event. The `balance` field in `Account` is `private`, so the only way to change it from outside the class is by calling `deposit()` or `withdraw()` — and `withdraw()` does not simply return `false` on failure, it throws a custom `InsufficientFundsException` that the caller is forced by the compiler to either catch or declare, because it is a checked exception.
By the end of this tutorial you will have a console banking application built around an `Account` class with a per-account transaction history, a `Bank` class managing many accounts at once, and a menu loop that wraps every withdrawal in a `try`/`catch` block around your own exception type. You will also see the difference between Java's checked exceptions (which the compiler forces you to handle) and unchecked exceptions (which you are not required to catch), and why `InsufficientFundsException` is deliberately written as the former.
- A custom checked exception, `InsufficientFundsException`, thrown by an overdrawn withdrawal.
- An `Account` class with private `accountNumber`, `holderName`, and `balance` fields.
- `deposit()` and `withdraw()` methods that validate amounts before ever changing the balance.
- A per-account transaction history stored in an `ArrayList<String>`.
- A `Bank` class that owns multiple accounts and assigns unique account numbers automatically.
- A menu loop with a `try`/`catch` around every withdrawal to handle `InsufficientFundsException` gracefully.
Prerequisites
- Classes — `private` fields, constructors, and methods.
- Encapsulation — why data stays `private` and is only exposed through controlled `public` methods.
- Exception handling — `try`/`catch`, `throw`, and the difference between checked and unchecked exceptions.
- Collections basics — `java.util.ArrayList` and iterating it with an enhanced `for` loop.
- Method basics — declaring a method that can `throws` a checked exception.
Project Structure
The program lives in a single file, `BankAccountSimulator.java`, containing four top-level types: `InsufficientFundsException`, `Account`, `Bank`, and the `public class BankAccountSimulator` holding `main()`. `InsufficientFundsException` extends `Exception` (not `RuntimeException`), which is what makes it a checked exception — any method that can throw it must declare `throws InsufficientFundsException` in its signature, and any code that calls such a method must either catch it or propagate the same declaration upward. The compiler enforces this at compile time, which is exactly the point: an overdrawn withdrawal becomes a scenario the caller cannot simply forget to handle.
`Account` owns its own `balance` and `history`, and is solely responsible for keeping them consistent. `Bank` owns a collection of `Account` objects and layers account-number lookup and creation on top, but it never reaches into an `Account`'s private fields directly — it only ever calls `deposit()`, `withdraw()`, and the read-only getters, the same boundary any other caller would have to respect.
Step 1: Define a Custom Checked Exception
A custom exception in Java is just a class that extends `Exception` (for checked) or `RuntimeException` (for unchecked), typically adding nothing more than constructors that forward a message to the parent class. Extending `Exception` directly, rather than `RuntimeException`, is a deliberate design choice here: it forces every method that might overdraw an account to be explicit about that possibility in its signature.
// A checked exception: extending Exception (not RuntimeException) means the// compiler requires every method that can throw this to declare it with// "throws", and every caller to either catch it or declare it too.class InsufficientFundsException extends Exception { public InsufficientFundsException(String message) { super(message); // Forward the message to Exception's constructor so getMessage() returns it later }}Step 2: Define the Account Class
Every field is `private`, and `balance` in particular is never returned as a mutable reference — `double` is a primitive, so `getBalance()` always hands back a copy, meaning there is no way for a caller to change the real balance except through `deposit()` and `withdraw()`. `history` starts with one entry recorded by the constructor, so even an account that has never had a transaction shows when and with what balance it was opened.
import java.util.ArrayList; // Backs each account's transaction historyimport java.util.List;
class Account { private int accountNumber; // Unique id assigned by the Bank when the account is created private String holderName; // Account holder's name private double balance; // Current balance; private so it can only change via deposit()/withdraw() private List<String> history = new ArrayList<>(); // Log of every transaction made on this account
public Account(int accountNumber, String holderName, double startingBalance) { this.accountNumber = accountNumber; this.holderName = holderName; this.balance = startingBalance; history.add("Opened with balance " + String.format("%.2f", startingBalance)); // First history entry }
public int getAccountNumber() { return accountNumber; }
public String getHolderName() { return holderName; }
public double getBalance() { // Returns a copy of a primitive double; callers can never mutate the real field return balance; }}Step 3: Deposit and Withdraw With Exception Handling
`deposit()` validates its input and simply returns if the amount is invalid, since a bad deposit amount is a routine, expected situation that does not need an exception. `withdraw()` is different: an overdraft is the one failure mode this whole project is built around highlighting, so it is signaled with `throw new InsufficientFundsException(...)` instead of a silent return, and the method signature is marked `throws InsufficientFundsException` so the compiler enforces that every caller deals with it.
public void deposit(double amount) { if (amount <= 0) { // Reject zero or negative deposits up front System.out.println("Deposit amount must be positive."); return; } balance += amount; // Encapsulation: only this method may change balance history.add("Deposited " + String.format("%.2f", amount) + ", new balance " + String.format("%.2f", balance)); System.out.println("Deposited " + String.format("%.2f", amount) + ". New balance: " + String.format("%.2f", balance));}
public void withdraw(double amount) throws InsufficientFundsException { // "throws" is required for a checked exception if (amount <= 0) { // Reject zero or negative withdrawal amounts System.out.println("Withdrawal amount must be positive."); return; } if (amount > balance) { // Overdraft: this is the scenario the exception exists for throw new InsufficientFundsException( "Insufficient funds. Current balance: " + String.format("%.2f", balance)); } balance -= amount; // Both checks passed; safe to subtract history.add("Withdrew " + String.format("%.2f", amount) + ", new balance " + String.format("%.2f", balance)); System.out.println("Withdrew " + String.format("%.2f", amount) + ". New balance: " + String.format("%.2f", balance));}Click Run to see what this code prints.
Step 4: Track Transaction History
Both `deposit()` and `withdraw()` already append to `history` as part of their normal operation in Step 3; this step just adds the method that plays that history back. Because `withdraw()` only reaches the `history.add(...)` line after every validation check and the exception-throwing branch have already passed, `printHistory()` can trust that every entry in the list represents a transaction that genuinely happened.
public void printHistory() { System.out.println("Transaction history for account #" + accountNumber + ":"); for (String entry : history) { // Enhanced for-loop over every recorded entry, in chronological order System.out.println(" - " + entry); }}Step 5: Manage Multiple Accounts With a Bank Class
A single `Account` is not a bank — `Bank` is what turns it into one, owning a `List<Account>` and a counter that hands out the next unused account number, mirroring the id-assignment pattern from the Student Management System project. `findAccount()` returns the actual `Account` object (or `null`), which the caller can then call `deposit()`/`withdraw()` on directly.
class Bank { private List<Account> accounts = new ArrayList<>(); // Every account currently open at this bank private int nextAccountNumber = 1001; // First account created this run gets number 1001
public Account createAccount(String holderName, double startingBalance) { Account account = new Account(nextAccountNumber, holderName, startingBalance); accounts.add(account); System.out.println("Account created! Account number: " + nextAccountNumber); nextAccountNumber++; // Guarantees every future account gets a unique number return account; }
public Account findAccount(int accountNumber) { for (Account acc : accounts) { if (acc.getAccountNumber() == accountNumber) { return acc; // Found it: return the real object so callers can operate on it directly } } return null; // No match; callers must check for null before using the result }
public void listAccounts() { System.out.println("\nAccount#\tHolder\t\tBalance"); for (Account acc : accounts) { System.out.println(acc.getAccountNumber() + "\t\t" + acc.getHolderName() + "\t\t" + String.format("%.2f", acc.getBalance())); } }}Step 6: Build the Menu Loop
The withdrawal branch of the menu is the one place `InsufficientFundsException` actually gets caught: the `acc.withdraw(amount)` call sits inside a `try` block, and if it throws, the `catch (InsufficientFundsException e)` block prints `e.getMessage()` and the loop simply continues to the next menu prompt — the program never crashes from an overdraft, because the exception was handled exactly where it was expected to occur.
import java.util.Scanner;
public class BankAccountSimulator { public static void main(String[] args) { Bank bank = new Bank(); Scanner scanner = new Scanner(System.in); int choice;
do { System.out.println("\n===== BANK ACCOUNT SIMULATOR ====="); System.out.println("1. Create Account"); System.out.println("2. Deposit"); System.out.println("3. Withdraw"); System.out.println("4. Check Balance"); System.out.println("5. View Transaction History"); System.out.println("6. List All Accounts"); System.out.println("7. Exit"); System.out.print("Enter your choice: "); choice = scanner.nextInt(); scanner.nextLine(); // Discard the leftover newline left behind by nextInt()
if (choice == 1) { System.out.print("Enter account holder name: "); String name = scanner.nextLine(); System.out.print("Enter starting balance: "); double startingBalance = scanner.nextDouble(); scanner.nextLine(); if (startingBalance < 0) { // Defend against a negative opening balance System.out.println("Starting balance cannot be negative. Setting balance to 0."); startingBalance = 0; } bank.createAccount(name, startingBalance); } else if (choice == 2) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.print("Enter deposit amount: "); double amount = scanner.nextDouble(); scanner.nextLine(); acc.deposit(amount); } } else if (choice == 3) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.print("Enter withdrawal amount: "); double amount = scanner.nextDouble(); scanner.nextLine(); try { acc.withdraw(amount); // May throw InsufficientFundsException } catch (InsufficientFundsException e) { System.out.println("Withdrawal failed: " + e.getMessage()); // Handled here; program keeps running } } } else if (choice == 4) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.println("Account #" + acc.getAccountNumber() + " (" + acc.getHolderName() + ") - Balance: " + String.format("%.2f", acc.getBalance())); } } else if (choice == 5) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { acc.printHistory(); } } else if (choice == 6) { bank.listAccounts(); } else if (choice == 7) { System.out.println("Goodbye!"); } else { System.out.println("Invalid choice, try again."); // Catches anything outside 1-7 } } while (choice != 7); // Keep looping until the user explicitly picks Exit
scanner.close(); }}Complete Code
Here is the full program with every class assembled in the correct order, ready to save as `BankAccountSimulator.java` and run with `javac BankAccountSimulator.java && java BankAccountSimulator`.
import java.util.ArrayList;import java.util.List;import java.util.Scanner;
class InsufficientFundsException extends Exception { public InsufficientFundsException(String message) { super(message); }}
class Account { private int accountNumber; private String holderName; private double balance; private List<String> history = new ArrayList<>();
public Account(int accountNumber, String holderName, double startingBalance) { this.accountNumber = accountNumber; this.holderName = holderName; this.balance = startingBalance; history.add("Opened with balance " + String.format("%.2f", startingBalance)); }
public int getAccountNumber() { return accountNumber; }
public String getHolderName() { return holderName; }
public double getBalance() { return balance; }
public void deposit(double amount) { if (amount <= 0) { System.out.println("Deposit amount must be positive."); return; } balance += amount; history.add("Deposited " + String.format("%.2f", amount) + ", new balance " + String.format("%.2f", balance)); System.out.println("Deposited " + String.format("%.2f", amount) + ". New balance: " + String.format("%.2f", balance)); }
public void withdraw(double amount) throws InsufficientFundsException { if (amount <= 0) { System.out.println("Withdrawal amount must be positive."); return; } if (amount > balance) { throw new InsufficientFundsException( "Insufficient funds. Current balance: " + String.format("%.2f", balance)); } balance -= amount; history.add("Withdrew " + String.format("%.2f", amount) + ", new balance " + String.format("%.2f", balance)); System.out.println("Withdrew " + String.format("%.2f", amount) + ". New balance: " + String.format("%.2f", balance)); }
public void printHistory() { System.out.println("Transaction history for account #" + accountNumber + ":"); for (String entry : history) { System.out.println(" - " + entry); } }}
class Bank { private List<Account> accounts = new ArrayList<>(); private int nextAccountNumber = 1001;
public Account createAccount(String holderName, double startingBalance) { Account account = new Account(nextAccountNumber, holderName, startingBalance); accounts.add(account); System.out.println("Account created! Account number: " + nextAccountNumber); nextAccountNumber++; return account; }
public Account findAccount(int accountNumber) { for (Account acc : accounts) { if (acc.getAccountNumber() == accountNumber) { return acc; } } return null; }
public void listAccounts() { System.out.println("\nAccount#\tHolder\t\tBalance"); for (Account acc : accounts) { System.out.println(acc.getAccountNumber() + "\t\t" + acc.getHolderName() + "\t\t" + String.format("%.2f", acc.getBalance())); } }}
public class BankAccountSimulator { public static void main(String[] args) { Bank bank = new Bank(); Scanner scanner = new Scanner(System.in); int choice;
do { System.out.println("\n===== BANK ACCOUNT SIMULATOR ====="); System.out.println("1. Create Account"); System.out.println("2. Deposit"); System.out.println("3. Withdraw"); System.out.println("4. Check Balance"); System.out.println("5. View Transaction History"); System.out.println("6. List All Accounts"); System.out.println("7. Exit"); System.out.print("Enter your choice: "); choice = scanner.nextInt(); scanner.nextLine();
if (choice == 1) { System.out.print("Enter account holder name: "); String name = scanner.nextLine(); System.out.print("Enter starting balance: "); double startingBalance = scanner.nextDouble(); scanner.nextLine(); if (startingBalance < 0) { System.out.println("Starting balance cannot be negative. Setting balance to 0."); startingBalance = 0; } bank.createAccount(name, startingBalance); } else if (choice == 2) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.print("Enter deposit amount: "); double amount = scanner.nextDouble(); scanner.nextLine(); acc.deposit(amount); } } else if (choice == 3) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.print("Enter withdrawal amount: "); double amount = scanner.nextDouble(); scanner.nextLine(); try { acc.withdraw(amount); } catch (InsufficientFundsException e) { System.out.println("Withdrawal failed: " + e.getMessage()); } } } else if (choice == 4) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { System.out.println("Account #" + acc.getAccountNumber() + " (" + acc.getHolderName() + ") - Balance: " + String.format("%.2f", acc.getBalance())); } } else if (choice == 5) { System.out.print("Enter account number: "); int accNum = scanner.nextInt(); scanner.nextLine(); Account acc = bank.findAccount(accNum); if (acc == null) { System.out.println("Account not found."); } else { acc.printHistory(); } } else if (choice == 6) { bank.listAccounts(); } else if (choice == 7) { System.out.println("Goodbye!"); } else { System.out.println("Invalid choice, try again."); } } while (choice != 7);
scanner.close(); }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a PIN field to `Account` and throw a new checked `InvalidPinException` when a deposit, withdrawal, or history lookup is attempted with the wrong PIN.
- Write a `transferFunds(Account from, Account to, double amount)` method on `Bank` that withdraws from one account and deposits into another as one operation, propagating `InsufficientFundsException` if the source account cannot cover it.
- Switch `Bank`'s storage from `List<Account>` to `HashMap<Integer, Account>` keyed by account number for O(1) lookups.
- Persist accounts and their transaction history to a file using `java.io.FileWriter`/`BufferedReader` so data survives between runs.
- Add an interest calculation feature that applies a fixed annual rate to every account's balance when the user selects a "Run Interest Cycle" menu option.
Summary
You built a working bank account simulator that uses encapsulation the way real Java programs do: `Account` protects its own balance behind `private` fields and validated public methods, and a custom checked `InsufficientFundsException` turns an overdraft from a silently ignored edge case into something the compiler forces every caller to acknowledge. The `try`/`catch` pattern around `withdraw()` you wrote in the menu loop is the same pattern you will use anywhere a Java method needs to signal "this specific, expected failure just happened" without crashing the whole program.