Project: Console Bank App

Java · Lesson 32 of 32 10 min read Updated

Time to put everything together. In this project you will build a small console bank for a co-operative society in Rohtak. Members can open savings accounts, deposit and withdraw money, transfer between accounts, print a statement and see a summary. The accounts are saved to a file, so they are still there the next time the program starts, and the core logic is covered by JUnit tests.

The app is small enough to finish in an afternoon, but it is built the way real Java software is built: small classes with one job each, BigDecimal for money, exceptions for errors, and a clock you can control in tests. Type the code yourself rather than copying it; you will understand it far better.

The plan

Before writing code, decide which classes you need and what each one is responsible for:

ClassJobTopics used
Transactionone line of an account statementrecords and enums, date and time
InsufficientFundsExceptionsignals that a withdrawal is too largeexceptions
Accountholds a balance and its history, enforces the rulesencapsulation, BigDecimal
Bankmanages all accounts, numbers, transfers and the summarycollections, streams
BankStoresaves and loads accounts as CSVfile I/O
BankAppthe menu: reads input and prints resultsuser input, switch

Notice that only BankApp talks to the user. The other classes know nothing about the keyboard or the screen. This separation makes the logic easy to test and easy to reuse later, for example behind a web page.

Create a folder bank-app and put each class in its own file with the same name.

Step 1: transactions and the exception

A transaction never changes after it happens, so a record is perfect. The kinds of transaction form a fixed set, so they are an enum nested inside the record.

Java
import java.math.BigDecimal;
import java.time.LocalDate;

public record Transaction(LocalDate date, Type type, BigDecimal amount,
                          BigDecimal balanceAfter, String note) {

    public enum Type { OPENING, DEPOSIT, WITHDRAWAL }
}

Trying to withdraw more than the balance is a normal business situation that the caller must handle, so it gets its own checked exception:

Java
public class InsufficientFundsException extends Exception {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

Step 2: the Account class

Account is the heart of the app. Its fields are private, the balance can change only through deposit and withdraw, and every change is recorded in the history. All money is BigDecimal with exactly two decimal places.

Java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class Account {
    private final String number;
    private final String holder;
    private BigDecimal balance = BigDecimal.ZERO.setScale(2);
    private final List<Transaction> history = new ArrayList<>();

    public Account(String number, String holder, BigDecimal opening, LocalDate date) {
        if (holder == null || holder.isBlank()) {
            throw new IllegalArgumentException("holder name is required");
        }
        if (holder.contains(",")) {
            throw new IllegalArgumentException("holder name cannot contain commas");
        }
        if (opening.signum() < 0) {
            throw new IllegalArgumentException("opening balance cannot be negative");
        }
        this.number = number;
        this.holder = holder.strip();
        apply(date, Transaction.Type.OPENING, money(opening), "Account opened");
    }

    public void deposit(BigDecimal amount, LocalDate date, String note) {
        apply(date, Transaction.Type.DEPOSIT, positive(amount), note);
    }

    public void withdraw(BigDecimal amount, LocalDate date, String note)
            throws InsufficientFundsException {
        BigDecimal value = positive(amount);
        if (value.compareTo(balance) > 0) {
            throw new InsufficientFundsException(
                    "%s has only Rs %s, cannot withdraw Rs %s".formatted(number, balance, value));
        }
        apply(date, Transaction.Type.WITHDRAWAL, value.negate(), note);
    }

    private void apply(LocalDate date, Transaction.Type type, BigDecimal change, String note) {
        balance = balance.add(change);
        history.add(new Transaction(date, type, change.abs(), balance, note));
    }

    private static BigDecimal money(BigDecimal amount) {
        return amount.setScale(2, RoundingMode.HALF_UP);
    }

    private static BigDecimal positive(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("amount must be more than zero");
        }
        return money(amount);
    }

    public String getNumber() { return number; }
    public String getHolder() { return holder; }
    public BigDecimal getBalance() { return balance; }
    public List<Transaction> getHistory() { return List.copyOf(history); }
}

Some design decisions worth noticing:

  • One private apply method changes the balance and writes the history together, so the two can never get out of step.
  • BigDecimal comparisons use compareTo and signum() (which returns -1, 0 or 1), not > or ==. setScale(2, RoundingMode.HALF_UP) keeps every amount at paise precision, so 5000 is stored as 5000.00.
  • getHistory returns List.copyOf(history), an unmodifiable copy. If it returned the real list, any caller could add fake transactions.
  • Commas are rejected in names because the save file uses commas to separate fields.
  • The date is passed in, not read from the system clock inside Account. You will see why in Step 3.

Step 3: the Bank class

Bank keeps all accounts in a LinkedHashMap (so they stay in the order they were opened), hands out account numbers, and offers the operations the menu needs.

Java
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class Bank {
    private final Map<String, Account> accounts = new LinkedHashMap<>();
    private final Clock clock;
    private int nextNumber = 1001;

    public Bank(Clock clock) {
        this.clock = clock;
    }

    private LocalDate today() {
        return LocalDate.now(clock);
    }

    public Account open(String holder, BigDecimal opening) {
        String number = "SB" + nextNumber;
        Account account = new Account(number, holder, opening, today());
        nextNumber++;
        accounts.put(number, account);
        return account;
    }

    void restore(String number, String holder, BigDecimal balance) {
        accounts.put(number, new Account(number, holder, balance, today()));
        int n = Integer.parseInt(number.substring(2));
        nextNumber = Math.max(nextNumber, n + 1);
    }

    public Optional<Account> find(String number) {
        return Optional.ofNullable(accounts.get(number.strip().toUpperCase()));
    }

    public Account get(String number) {
        return find(number).orElseThrow(
                () -> new IllegalArgumentException("no account " + number));
    }

    public void deposit(String number, BigDecimal amount) {
        get(number).deposit(amount, today(), "Cash deposit");
    }

    public void withdraw(String number, BigDecimal amount) throws InsufficientFundsException {
        get(number).withdraw(amount, today(), "Cash withdrawal");
    }

    public void transfer(String fromNumber, String toNumber, BigDecimal amount)
            throws InsufficientFundsException {
        Account from = get(fromNumber);
        Account to = get(toNumber);
        if (from == to) {
            throw new IllegalArgumentException("cannot transfer to the same account");
        }
        from.withdraw(amount, today(), "Transfer to " + to.getNumber());
        to.deposit(amount, today(), "Transfer from " + from.getNumber());
    }

    public Collection<Account> all() {
        return List.copyOf(accounts.values());
    }

    public String summary() {
        BigDecimal total = accounts.values().stream()
                .map(Account::getBalance)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        String top = accounts.values().stream()
                .max(Comparator.comparing(Account::getBalance))
                .map(a -> a.getHolder() + " (" + a.getNumber() + ")")
                .orElse("none");
        return "Accounts: %d | Total held: Rs %s | Highest balance: %s"
                .formatted(accounts.size(), total, top);
    }
}

The Clock is the trick that makes the bank testable. The real app passes Clock.systemDefaultZone(), so dates are today's. A test passes a fixed clock, so every date is predictable. This technique is called dependency injection: instead of a class reaching out for something (the system time), it receives it from outside.

find returns an Optional and accepts lower-case input such as sb1001. In transfer, the withdrawal runs first; if it throws, the deposit never happens and neither account changes. The account number counter increases only after the Account constructor succeeds, so a rejected name does not waste a number. restore is package-private: only BankStore, in the same package, needs it.

Step 4: saving and loading

Each account is saved as one CSV line. When the program starts, the lines are read back with a stream. Restored accounts start a fresh history with their saved balance.

Java
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class BankStore {
    private static final String HEADER = "number,holder,balance";
    private final Path file;

    public BankStore(Path file) {
        this.file = file;
    }

    public void save(Bank bank) throws IOException {
        List<String> lines = new ArrayList<>();
        lines.add(HEADER);
        for (Account a : bank.all()) {
            lines.add(a.getNumber() + "," + a.getHolder() + "," + a.getBalance().toPlainString());
        }
        Files.write(file, lines);
    }

    public int load(Bank bank) throws IOException {
        if (Files.notExists(file)) {
            return 0;
        }
        try (Stream<String> lines = Files.lines(file)) {
            List<String[]> rows = lines.skip(1)
                    .filter(line -> !line.isBlank())
                    .map(line -> line.split(","))
                    .toList();
            for (String[] p : rows) {
                bank.restore(p[0], p[1], new BigDecimal(p[2]));
            }
            return rows.size();
        }
    }
}

toPlainString() writes 6000.50, never scientific notation, and new BigDecimal("6000.50") reads it back exactly. On the first run there is no file yet, so load simply returns 0.

Step 5: the menu

BankApp is the only class that uses Scanner and System.out. It reads every input as a whole line (avoiding the nextInt trap from User Input), uses an arrow-form switch, and catches both exception types in one multi-catch so a mistake never crashes the program.

Java
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.util.Scanner;

public class BankApp {
    private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu");

    private final Bank bank;
    private final Scanner in = new Scanner(System.in);

    BankApp(Bank bank) {
        this.bank = bank;
    }

    public static void main(String[] args) {
        Bank bank = new Bank(Clock.systemDefaultZone());
        BankStore store = new BankStore(Path.of("accounts.csv"));
        try {
            int loaded = store.load(bank);
            System.out.println("Loaded " + loaded + " saved account(s).");
        } catch (IOException | RuntimeException e) {
            System.out.println("Could not read accounts.csv: " + e.getMessage());
        }

        new BankApp(bank).run();

        try {
            store.save(bank);
            System.out.println("Saved " + bank.all().size() + " account(s). Goodbye!");
        } catch (IOException e) {
            System.out.println("Could not save: " + e.getMessage());
        }
    }

    void run() {
        while (true) {
            System.out.println();
            System.out.println("1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit");
            String choice = ask("Choose");
            if (choice == null || choice.equals("0")) {
                return;
            }
            try {
                switch (choice) {
                    case "1" -> openAccount();
                    case "2" -> {
                        String number = ask("Account number");
                        bank.deposit(number, askAmount("Amount"));
                        System.out.println("New balance: Rs " + bank.get(number).getBalance());
                    }
                    case "3" -> {
                        String number = ask("Account number");
                        bank.withdraw(number, askAmount("Amount"));
                        System.out.println("New balance: Rs " + bank.get(number).getBalance());
                    }
                    case "4" -> {
                        String from = ask("From account");
                        String to = ask("To account");
                        bank.transfer(from, to, askAmount("Amount"));
                        System.out.println("Transfer done.");
                    }
                    case "5" -> printStatement(ask("Account number"));
                    case "6" -> System.out.println(bank.summary());
                    default -> System.out.println("Please choose a number from 0 to 6.");
                }
            } catch (IllegalArgumentException | InsufficientFundsException e) {
                System.out.println("Error: " + e.getMessage());
            }
        }
    }

    private void openAccount() {
        String name = ask("Holder name");
        BigDecimal opening = askAmount("Opening deposit");
        Account account = bank.open(name, opening);
        System.out.println("Opened " + account.getNumber() + " for " + account.getHolder());
    }

    private void printStatement(String number) {
        Account account = bank.get(number);
        System.out.println("Statement for " + account.getNumber() + " (" + account.getHolder() + ")");
        for (Transaction t : account.getHistory()) {
            System.out.printf("%s  %-10s %10s %10s  %s%n",
                    t.date().format(DATE), t.type(), t.amount(), t.balanceAfter(), t.note());
        }
    }

    private String ask(String prompt) {
        System.out.print(prompt + ": ");
        return in.hasNextLine() ? in.nextLine().strip() : null;
    }

    private BigDecimal askAmount(String prompt) {
        String text = ask(prompt);
        if (text == null) {
            throw new IllegalArgumentException("no amount entered");
        }
        try {
            return new BigDecimal(text);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("'" + text + "' is not a valid amount");
        }
    }
}

ask returns null when the input ends (for example when you press Ctrl+D, or Ctrl+Z then Enter on Windows), and the loop treats that like choosing 0, so the accounts are still saved. Converting NumberFormatException into an IllegalArgumentException with a friendly message means the menu needs only one catch.

Step 6: compile and run

From the bank-app folder, compile all files and run the main class:

Terminal
javac *.java
java BankApp

Here is a session (the text after each prompt is what the user typed; your dates will show the day you run it):

Output
Loaded 0 saved account(s).

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 1
Holder name: Meera Nair
Opening deposit: 5000
Opened SB1001 for Meera Nair

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 1
Holder name: Kabir Singh
Opening deposit: 1200
Opened SB1002 for Kabir Singh

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 2
Account number: sb1001
Amount: 2500.50
New balance: Rs 7500.50

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 3
Account number: SB1002
Amount: 2000
Error: SB1002 has only Rs 1200.00, cannot withdraw Rs 2000.00

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 4
From account: SB1001
To account: SB1002
Amount: 1500
Transfer done.

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 5
Account number: SB1001
Statement for SB1001 (Meera Nair)
30-10-2026  OPENING       5000.00    5000.00  Account opened
30-10-2026  DEPOSIT       2500.50    7500.50  Cash deposit
30-10-2026  WITHDRAWAL    1500.00    6000.50  Transfer to SB1002

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 6
Accounts: 2 | Total held: Rs 8700.50 | Highest balance: Meera Nair (SB1001)

1 Open  2 Deposit  3 Withdraw  4 Transfer  5 Statement  6 Summary  0 Exit
Choose: 0
Saved 2 account(s). Goodbye!

The failed withdrawal printed an error and the program carried on. After exiting, accounts.csv contains:

Text
number,holder,balance
SB1001,Meera Nair,6000.50
SB1002,Kabir Singh,2700.00

Run the program again and choose 6: the summary shows the same two accounts, and the next account opened becomes SB1003.

Step 7: test the bank with JUnit

Because Bank does not touch the keyboard and takes its clock from outside, it is easy to test. Put this class in your test folder (for example src/test/java in a Maven project, see Unit Testing with JUnit):

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class BankTest {
    private Bank bank;

    @BeforeEach
    void setUp() {
        Clock fixed = Clock.fixed(Instant.parse("2026-10-30T05:00:00Z"), ZoneId.of("Asia/Kolkata"));
        bank = new Bank(fixed);
        bank.open("Meera Nair", new BigDecimal("5000"));
        bank.open("Kabir Singh", new BigDecimal("1200"));
    }

    @Test
    void transferMovesMoneyBetweenAccounts() throws Exception {
        bank.transfer("SB1001", "SB1002", new BigDecimal("1500"));
        assertEquals(new BigDecimal("3500.00"), bank.get("SB1001").getBalance());
        assertEquals(new BigDecimal("2700.00"), bank.get("SB1002").getBalance());
    }

    @Test
    void failedTransferChangesNothing() {
        assertThrows(InsufficientFundsException.class,
                () -> bank.transfer("SB1002", "SB1001", new BigDecimal("9000")));
        assertEquals(new BigDecimal("1200.00"), bank.get("SB1002").getBalance());
        assertEquals(new BigDecimal("5000.00"), bank.get("SB1001").getBalance());
    }

    @Test
    void historyUsesTheClock() {
        bank.deposit("sb1001", new BigDecimal("250.50"));
        Transaction last = bank.get("SB1001").getHistory().getLast();
        assertEquals(LocalDate.of(2026, 10, 30), last.date());
        assertEquals(new BigDecimal("5250.50"), last.balanceAfter());
    }

    @Test
    void rejectsZeroDeposit() {
        assertThrows(IllegalArgumentException.class,
                () -> bank.deposit("SB1001", BigDecimal.ZERO));
    }
}

All four tests pass. Note that BigDecimal.equals also compares the scale, so the expected values are written as "3500.00", matching the two decimal places the account always uses.

Ideas to extend the project

  • Save the transaction history too, in a second CSV file, so statements survive a restart.
  • Replace the CSV file with a MySQL table using the DAO pattern and transactions from JDBC with MySQL.
  • Model transactions as a sealed interface with one record per kind, and print statements with a pattern-matching switch (Sealed Classes & Pattern Matching).
  • Add a PIN per account, stored as a hash, never as plain text.
  • Package the app with Maven and run the tests on every build (Maven & Gradle).

Key points

  • Split an app into small classes with one job each; keep input and output in one class only.
  • Use records for unchanging data, enums for fixed choices and BigDecimal with setScale(2) for money.
  • Protect state with private fields, validation and unmodifiable copies such as List.copyOf.
  • Use checked exceptions for expected business errors and catch them at the user interface.
  • Inject a Clock so code that depends on the date can be tested with fixed values.
  • Save data with java.nio.file and verify the core logic with JUnit tests.

Exercise

Add a method int addMonthlyInterest(BigDecimal annualRatePercent) to Bank that credits one month of interest to every account (balance × rate ÷ 100 ÷ 12, rounded to paise), skips accounts whose interest would be zero, and returns how many accounts received interest. Each credit should appear in the statement with a note such as Interest @ 3% p.a.. Then add menu option 7 to call it. Test it with balances of Rs 6000.50, Rs 2700 and Rs 0 at 3 percent.

Show solution

Add to Bank (and import java.math.RoundingMode;):

Java
public int addMonthlyInterest(BigDecimal annualRatePercent) {
    int credited = 0;
    for (Account a : accounts.values()) {
        BigDecimal interest = a.getBalance()
                .multiply(annualRatePercent)
                .divide(BigDecimal.valueOf(1200), 2, RoundingMode.HALF_UP);
        if (interest.signum() > 0) {
            a.deposit(interest, today(), "Interest @ " + annualRatePercent + "% p.a.");
            credited++;
        }
    }
    return credited;
}

Add a case to the menu switch (and mention 7 Interest in the menu line):

Java
case "7" -> {
    int n = bank.addMonthlyInterest(askAmount("Annual rate %"));
    System.out.println("Interest added to " + n + " account(s).");
}

Opening three accounts with 6000.50, 2700 and 0, then calling addMonthlyInterest(new BigDecimal("3")) and printing each balance gives:

Output
Interest added to 2 account(s).
SB1001 6015.50
SB1002 2706.75
SB1003 0.00

Dividing by 1200 combines "÷ 100" and "÷ 12" in one step. divide needs a scale and a rounding mode, because a result such as 15.00125 cannot be stored exactly at two decimals. The zero-balance account is skipped, since deposit rightly refuses an amount of zero.

Quick Quiz

1. Why does Account.getHistory() return List.copyOf(history) instead of history?

2. Why does Bank receive a Clock in its constructor?

3. A transfer's withdrawal throws InsufficientFundsException. What happens to the receiving account?

4. Why are names with commas rejected?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call