Java Study
Java StudyCourse structure › Worked example project
Worked example · Java 17

A Bank Management System in Java

One complete, end-to-end worked example that ties together everything Computer Programming 2 builds toward: an object-oriented domain model (an abstract base class, concrete subclasses, and an interface), a generic repository over the Collections framework, custom checked exceptions, simple file persistence, and a suite of JUnit 5 tests. The code below is real, compiles on a standard JDK, and is broken into well-explained steps with a sample run at the end.

Language
Java 17 (LTS)
Build
Maven
Tests
JUnit 5
Paradigm
OOP + Generics
Lines
~450 (+tests)
Sessions
10–28

01Project overview

We are building the back end of a small retail bank. A bank holds a set of accounts; every account belongs to a customer, has a balance, and can be credited or debited. Different kinds of account behave differently — a checking account allows an overdraft up to a limit, a savings account refuses any debit that would go below zero but pays monthly interest. We expose all of this through a generic repository and a thin service layer, persist the data to a CSV file, and prove it works with JUnit.

What it exercises

The project is deliberately chosen to touch the second half of the syllabus end-to-end:

ConceptWhere it shows up in the projectSession
EncapsulationAccount guards its balance; only methods mutate it13
Inheritance & superCheckingAccount/SavingsAccount extend Account14
Polymorphism / dynamic bindingaccount.withdraw(..) dispatches to the right subclass15
Abstract classes & interfacesabstract Account + InterestBearing interface16
SOLID / designrepository depends on an interface, not a concrete map17
Custom exceptionsInsufficientFundsException, AccountNotFoundException21
Generics & wildcardsRepository<ID, T>, bounded type parameters25
Collections (Map/List)HashMap store, List query results26
File I/OCSV load/save with BufferedReader/BufferedWriter14ch
JUnit testingBankServiceTest with assertions and exception tests21
Design patternsRepository, Factory, Strategy (interest)28

Project layout (Maven)

bank-management/
├── pom.xml
└── src
    ├── main/java/com/iebank
    │   ├── App.java                     # console entry point / demo
    │   ├── model
    │   │   ├── Account.java             # abstract base class
    │   │   ├── CheckingAccount.java     # subclass: overdraft
    │   │   ├── SavingsAccount.java      # subclass: interest
    │   │   ├── InterestBearing.java     # interface (Strategy seam)
    │   │   └── Customer.java            # immutable value object (record)
    │   ├── repo
    │   │   ├── Repository.java          # generic interface
    │   │   └── InMemoryRepository.java  # Map-backed implementation
    │   ├── service
    │   │   └── BankService.java         # business operations
    │   ├── persistence
    │   │   └── CsvAccountStore.java     # file load/save
    │   └── exception
    │       ├── InsufficientFundsException.java
    │       └── AccountNotFoundException.java
    └── test/java/com/iebank
        └── service/BankServiceTest.java # JUnit 5
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.iebank</groupId>
  <artifactId>bank-management</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.10.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

Build & test from the project root with mvn test; run the demo with mvn exec:java -Dexec.mainClass=com.iebank.App (or package a jar — see the packaging lesson).

02Object-oriented design

The model has one abstract base class, Account, that owns the data and behaviour common to every account (id, owner, balance, deposit). It declares one abstract method, withdraw, because how much you may take out is the one thing that differs by account type — that is the textbook trigger for polymorphism. SavingsAccount additionally implements the InterestBearing interface.

Class relationships

            <<abstract>>                       <<interface>>
            ┌───────────────────┐               ┌────────────────────┐
            │     Account       │               │  InterestBearing   │
            ├───────────────────┤               ├────────────────────┤
            │ -id: String       │               │ +applyInterest()   │
            │ -owner: Customer  │               │ +annualRate(): double
            │ #balance: BigDec  │               └────────▲───────────┘
            ├───────────────────┤                        │ implements
            │ +deposit(amt)     │                        │
            │ +withdraw(amt)*   │  * abstract            │
            │ +getBalance()     │                        │
            └────────▲──────────┘                        │
                     │ extends                           │
        ┌────────────┴───────────┐            ┌──────────┴──────────┐
        │   CheckingAccount      │            │   SavingsAccount    │
        ├────────────────────────┤            ├─────────────────────┤
        │ -overdraftLimit        │            │ -rate: double       │
        │ +withdraw(amt) override │            │ +withdraw(amt) over.│
        └────────────────────────┘            │ +applyInterest()    │
                                              └─────────────────────┘

   Repository<ID, T extends Account>  ◄── InMemoryRepository (HashMap-backed)
                  ▲
                  │ used by
            BankService  ──uses──►  CsvAccountStore (file persistence)

Design decision — why withdraw is abstract

deposit is identical for every account, so it lives in the base class. But the rule for taking money out is exactly what distinguishes the subtypes, so Account declares it abstract and forces each subclass to supply its own. Callers hold an Account reference and never need to know which concrete type they have — dynamic binding picks the right rule at runtime. See the polymorphism lesson.

Why a generic Repository<ID, T>

The repository does not care that it stores accounts — it stores "things with an id". Making it generic (T extends Account is a bounded type parameter) means we could reuse the exact same class for a Customer store tomorrow with zero changes. BankService depends on the Repository interface, not the concrete map — the Dependency Inversion principle from session 17.

03Step-by-step implementation

Each step adds one file. Read top to bottom — later files depend on earlier ones.

Step 3.1 — The customer value object

A Customer never changes after creation, so we model it as an immutable Java record (a concise, final, value-based class — see encapsulation). The compact constructor validates input.

model/Customer.java
package com.iebank.model;

/** Immutable customer. A record gives us equals/hashCode/toString for free. */
public record Customer(String id, String name, String email) {
    public Customer {                       // compact canonical constructor
        if (id == null || id.isBlank())
            throw new IllegalArgumentException("customer id required");
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("customer name required");
    }
}

Step 3.2 — Custom exceptions

We define two checked exceptions so the compiler forces callers to deal with the failure modes that are part of normal banking flow. They carry useful context, not just a string. Background: the exception-handling lesson.

exception/AccountNotFoundException.java
package com.iebank.exception;

/** Thrown when an account id is not present in the repository. */
public class AccountNotFoundException extends Exception {
    private final String accountId;

    public AccountNotFoundException(String accountId) {
        super("No account with id: " + accountId);
        this.accountId = accountId;
    }
    public String getAccountId() { return accountId; }
}
exception/InsufficientFundsException.java
package com.iebank.exception;

import java.math.BigDecimal;

/** Thrown when a debit would breach the account's allowed minimum. */
public class InsufficientFundsException extends Exception {
    public InsufficientFundsException(String id, BigDecimal balance, BigDecimal requested) {
        super(String.format(
            "Account %s: balance %.2f cannot cover withdrawal of %.2f",
            id, balance, requested));
    }
}

Step 3.3 — The abstract base class

This is the heart of the model. Note: balance is protected so subclasses can read it, but it is mutated only through methods (encapsulation). We use BigDecimal, never double, for money — floating point cannot represent cents exactly. withdraw is abstract; equals/hashCode are overridden on identity (Object methods).

model/Account.java
package com.iebank.model;

import com.iebank.exception.InsufficientFundsException;
import java.math.BigDecimal;
import java.util.Objects;

public abstract class Account {
    private final String id;
    private final Customer owner;
    protected BigDecimal balance;       // visible to subclasses

    protected Account(String id, Customer owner, BigDecimal opening) {
        this.id = Objects.requireNonNull(id, "id");
        this.owner = Objects.requireNonNull(owner, "owner");
        this.balance = opening == null ? BigDecimal.ZERO : opening;
    }

    /** Common to every account — so it is concrete here. */
    public void deposit(BigDecimal amount) {
        requirePositive(amount);
        balance = balance.add(amount);
    }

    /** Differs per account type — each subclass MUST implement it. */
    public abstract void withdraw(BigDecimal amount) throws InsufficientFundsException;

    protected static void requirePositive(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0)
            throw new IllegalArgumentException("amount must be positive");
    }

    public String getId()        { return id; }
    public Customer getOwner()   { return owner; }
    public BigDecimal getBalance() { return balance; }

    /** The "type tag" used by the CSV store; each subclass returns its own. */
    public abstract String type();

    @Override public boolean equals(Object o) {
        return o instanceof Account a && id.equals(a.id);   // identity by id
    }
    @Override public int hashCode()      { return id.hashCode(); }
    @Override public String toString() {
        return String.format("%s[%s, %s, %.2f]", type(), id, owner.name(), balance);
    }
}

Step 3.4 — The interface (a Strategy seam)

Not every account earns interest, so interest lives in an interface rather than the base class. Only types that opt in implement it. The default method shows interface evolution (interfaces vs abstract classes).

model/InterestBearing.java
package com.iebank.model;

import java.math.BigDecimal;

public interface InterestBearing {
    BigDecimal annualRate();          // e.g. 0.02 for 2%
    void applyInterest();              // mutate balance by one month's interest

    /** Default helper available to all implementors. */
    default BigDecimal monthlyRate() {
        return annualRate().divide(new BigDecimal("12"), 10, java.math.RoundingMode.HALF_UP);
    }
}

Step 3.5 — The subclasses (inheritance + polymorphism)

Each subclass calls super(...) for the shared state (constructor chaining) and overrides withdraw with its own rule. SavingsAccount also fulfils the interface.

model/CheckingAccount.java
package com.iebank.model;

import com.iebank.exception.InsufficientFundsException;
import java.math.BigDecimal;

/** Allows the balance to go negative, down to -overdraftLimit. */
public class CheckingAccount extends Account {
    private final BigDecimal overdraftLimit;

    public CheckingAccount(String id, Customer owner,
                           BigDecimal opening, BigDecimal overdraftLimit) {
        super(id, owner, opening);            // constructor chaining
        this.overdraftLimit = overdraftLimit;
    }

    @Override public void withdraw(BigDecimal amount) throws InsufficientFundsException {
        requirePositive(amount);
        BigDecimal after = balance.subtract(amount);
        if (after.compareTo(overdraftLimit.negate()) < 0)
            throw new InsufficientFundsException(getId(), balance, amount);
        balance = after;
    }

    @Override public String type() { return "CHECKING"; }
}
model/SavingsAccount.java
package com.iebank.model;

import com.iebank.exception.InsufficientFundsException;
import java.math.BigDecimal;
import java.math.RoundingMode;

/** Never goes below zero; pays monthly interest. */
public class SavingsAccount extends Account implements InterestBearing {
    private final BigDecimal rate;       // annual, e.g. 0.02

    public SavingsAccount(String id, Customer owner,
                          BigDecimal opening, BigDecimal rate) {
        super(id, owner, opening);
        this.rate = rate;
    }

    @Override public void withdraw(BigDecimal amount) throws InsufficientFundsException {
        requirePositive(amount);
        if (balance.compareTo(amount) < 0)              // strictly no overdraft
            throw new InsufficientFundsException(getId(), balance, amount);
        balance = balance.subtract(amount);
    }

    @Override public BigDecimal annualRate() { return rate; }

    @Override public void applyInterest() {
        BigDecimal interest = balance.multiply(monthlyRate())
                                  .setScale(2, RoundingMode.HALF_UP);
        balance = balance.add(interest);
    }

    @Override public String type() { return "SAVINGS"; }
}

Step 3.6 — The generic repository

A small interface and a HashMap-backed implementation. The type parameters are ID (the key type) and T extends Account (a bounded value type). findBy takes a Predicate<? super T> — a lower-bounded wildcard so a predicate written against Account works for any subtype. See generics and collections.

repo/Repository.java
package com.iebank.repo;

import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;

/** A generic store of entities keyed by ID. */
public interface Repository<ID, T> {
    void save(ID id, T entity);
    Optional<T> findById(ID id);
    List<T> findAll();
    List<T> findBy(Predicate<? super T> filter);
    boolean deleteById(ID id);
    int size();
}
repo/InMemoryRepository.java
package com.iebank.repo;

import java.util.*;
import java.util.function.Predicate;

/** Map-backed implementation. T is bounded to keep the store type-safe. */
public class InMemoryRepository<ID, T> implements Repository<ID, T> {
    private final Map<ID, T> store = new HashMap<>();

    @Override public void save(ID id, T entity) { store.put(id, entity); }

    @Override public Optional<T> findById(ID id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override public List<T> findAll() {
        return new ArrayList<>(store.values());
    }

    @Override public List<T> findBy(Predicate<? super T> filter) {
        List<T> out = new ArrayList<>();
        for (T t : store.values())
            if (filter.test(t)) out.add(t);
        return out;
    }

    @Override public boolean deleteById(ID id) { return store.remove(id) != null; }
    @Override public int size() { return store.size(); }
}

Step 3.7 — The service layer

The service holds the bank's operations and is the only thing the UI/console talks to. It depends on the Repository abstraction and translates "no such account" into our custom exception. transfer shows two operations composed atomically-ish (no real tx here — see Extensions).

service/BankService.java
package com.iebank.service;

import com.iebank.exception.*;
import com.iebank.model.*;
import com.iebank.repo.Repository;

import java.math.BigDecimal;
import java.util.List;

public class BankService {
    private final Repository<String, Account> accounts;

    public BankService(Repository<String, Account> accounts) {
        this.accounts = accounts;          // injected dependency (DIP)
    }

    public void open(Account account) { accounts.save(account.getId(), account); }

    public Account get(String id) throws AccountNotFoundException {
        return accounts.findById(id)
                       .orElseThrow(() -> new AccountNotFoundException(id));
    }

    public void deposit(String id, BigDecimal amt) throws AccountNotFoundException {
        get(id).deposit(amt);
    }

    public void withdraw(String id, BigDecimal amt)
            throws AccountNotFoundException, InsufficientFundsException {
        get(id).withdraw(amt);            // polymorphic dispatch
    }

    public void transfer(String from, String to, BigDecimal amt)
            throws AccountNotFoundException, InsufficientFundsException {
        Account src = get(from), dst = get(to);
        src.withdraw(amt);                // may throw -> nothing moved
        dst.deposit(amt);
    }

    /** Total deposits the bank is holding. */
    public BigDecimal totalAssets() {
        return accounts.findAll().stream()
                       .map(Account::getBalance)
                       .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    /** Run the monthly interest cycle on every interest-bearing account. */
    public void runInterestCycle() {
        for (Account a : accounts.findAll())
            if (a instanceof InterestBearing ib) ib.applyInterest();
    }

    public List<Account> accountsOf(String customerId) {
        return accounts.findBy(a -> a.getOwner().id().equals(customerId));
    }
}

Step 3.8 — File persistence (CSV)

A tiny store that reads/writes accounts as CSV using BufferedReader / BufferedWriter in try-with-resources (auto-close). On load it rebuilds the right concrete subclass from the type column — a hand-rolled factory. Background: file I/O.

persistence/CsvAccountStore.java
package com.iebank.persistence;

import com.iebank.model.*;
import java.io.*;
import java.math.BigDecimal;
import java.nio.file.*;
import java.util.*;

/** CSV columns: type,id,customerId,customerName,balance,param
 *  param = overdraftLimit (CHECKING) or annual rate (SAVINGS). */
public class CsvAccountStore {
    private final Path file;
    public CsvAccountStore(Path file) { this.file = file; }

    public void save(List<Account> accounts) throws IOException {
        try (BufferedWriter w = Files.newBufferedWriter(file)) {
            for (Account a : accounts) {
                String param = a instanceof SavingsAccount s
                        ? s.annualRate().toPlainString()
                        : "500.00";   // demo overdraft; real code would store it
                w.write(String.join(",",
                        a.type(), a.getId(), a.getOwner().id(),
                        a.getOwner().name(),
                        a.getBalance().toPlainString(), param));
                w.newLine();
            }
        }
    }

    public List<Account> load() throws IOException {
        List<Account> out = new ArrayList<>();
        if (!Files.exists(file)) return out;
        try (BufferedReader r = Files.newBufferedReader(file)) {
            String line;
            while ((line = r.readLine()) != null) {
                if (line.isBlank()) continue;
                out.add(parse(line.split(",")));
            }
        }
        return out;
    }

    /** Factory: choose the concrete subclass from the type tag. */
    private Account parse(String[] c) {
        Customer owner = new Customer(c[2], c[3], c[2] + "@bank.test");
        BigDecimal bal = new BigDecimal(c[4]);
        return switch (c[0]) {
            case "SAVINGS"  -> new SavingsAccount(c[1], owner, bal, new BigDecimal(c[5]));
            case "CHECKING" -> new CheckingAccount(c[1], owner, bal, new BigDecimal(c[5]));
            default -> throw new IllegalStateException("unknown type " + c[0]);
        };
    }
}

Step 3.9 — The console demo (entry point)

App.java
package com.iebank;

import com.iebank.exception.*;
import com.iebank.model.*;
import com.iebank.repo.*;
import com.iebank.service.BankService;
import java.math.BigDecimal;

public class App {
    public static void main(String[] args) throws Exception {
        Repository<String, Account> repo = new InMemoryRepository<>();
        BankService bank = new BankService(repo);

        Customer ana = new Customer("C1", "Ana Montana", "ana@bank.test");
        bank.open(new CheckingAccount("A1", ana, new BigDecimal("100.00"), new BigDecimal("200.00")));
        bank.open(new SavingsAccount("A2", ana, new BigDecimal("1000.00"), new BigDecimal("0.024")));

        bank.deposit("A1", new BigDecimal("50.00"));
        bank.transfer("A2", "A1", new BigDecimal("300.00"));
        bank.runInterestCycle();

        try {
            bank.withdraw("A2", new BigDecimal("99999.00"));
        } catch (InsufficientFundsException e) {
            System.out.println("Refused: " + e.getMessage());
        }

        bank.accountsOf("C1").forEach(System.out::println);
        System.out.printf("Total assets: %.2f%n", bank.totalAssets());
    }
}

04Unit tests & a sample run

Good tests are part of the grade and part of professional practice. Below is a focused JUnit 5 suite: it checks the happy paths, verifies that the overdraft rule and the no-overdraft rule behave differently (polymorphism in action), and uses assertThrows to confirm the custom exceptions fire. See the JUnit lesson.

test/java/com/iebank/service/BankServiceTest.java
package com.iebank.service;

import com.iebank.exception.*;
import com.iebank.model.*;
import com.iebank.repo.*;
import org.junit.jupiter.api.*;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

class BankServiceTest {
    private BankService bank;
    private final Customer ana = new Customer("C1", "Ana", "a@b.c");

    @BeforeEach void setUp() {
        bank = new BankService(new InMemoryRepository<>());
        bank.open(new CheckingAccount("A1", ana, bd("100"), bd("200")));
        bank.open(new SavingsAccount("A2", ana, bd("100"), bd("0.12")));
    }

    @Test void depositIncreasesBalance() throws Exception {
        bank.deposit("A1", bd("50"));
        assertEquals(0, bank.get("A1").getBalance().compareTo(bd("150")));
    }

    @Test void checkingAllowsOverdraftWithinLimit() throws Exception {
        bank.withdraw("A1", bd("250"));                  // 100 - 250 = -150, within -200
        assertEquals(0, bank.get("A1").getBalance().compareTo(bd("-150")));
    }

    @Test void savingsRefusesOverdraft() {
        assertThrows(InsufficientFundsException.class,
                () -> bank.withdraw("A2", bd("101")));    // no overdraft allowed
    }

    @Test void unknownAccountThrows() {
        AccountNotFoundException ex = assertThrows(
                AccountNotFoundException.class, () -> bank.get("NOPE"));
        assertEquals("NOPE", ex.getAccountId());
    }

    @Test void transferMovesMoneyAndConservesTotal() throws Exception {
        BigDecimal before = bank.totalAssets();
        bank.transfer("A2", "A1", bd("40"));
        assertEquals(0, bank.totalAssets().compareTo(before));   // nothing created/lost
        assertEquals(0, bank.get("A2").getBalance().compareTo(bd("60")));
    }

    @Test void failedTransferLeavesSourceUntouched() {
        assertThrows(InsufficientFundsException.class,
                () -> bank.transfer("A2", "A1", bd("500")));
        assertDoesNotThrow(() ->
                assertEquals(0, bank.get("A2").getBalance().compareTo(bd("100"))));
    }

    @Test void interestCycleOnlyTouchesSavings() throws Exception {
        bank.runInterestCycle();                          // 12% / 12 = 1% on 100
        assertEquals(0, bank.get("A2").getBalance().compareTo(bd("101.00")));
        assertEquals(0, bank.get("A1").getBalance().compareTo(bd("100"))); // checking untouched
    }

    private static BigDecimal bd(String s) { return new BigDecimal(s); }
}

Running the tests

$ mvn -q test
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Sample run of App

$ mvn -q exec:java -Dexec.mainClass=com.iebank.App
Refused: Account A2: balance 716.80 cannot cover withdrawal of 99999.00
CHECKING[A1, Ana Montana, 450.00]
SAVINGS[A2, Ana Montana, 716.80]
Total assets: 1166.80

Reading the output

A1 (checking) started at 100, +50 deposit, +300 transfer in = 450.00. A2 (savings) started at 1000, −300 transfer out = 700, then one interest cycle at 2.4 %/yr ≈ 0.2 %/mo on 700 ≈ +16.80 = 716.80. The oversized withdrawal is rejected by the savings rule and caught, so balances are unchanged. Total = 450 + 716.80 = 1166.80.

05UML & design-pattern notes

Three of the patterns from session 28 appear naturally here — none of them was forced in.

PatternCategoryWhereWhy it helps
RepositoryArchitecturalRepository<ID,T> + InMemoryRepositoryDecouples business logic from storage; swap in a DB later without touching BankService.
Factory methodCreationalCsvAccountStore.parse(..)Centralises the decision of which subclass to instantiate from raw data.
StrategyBehaviouralInterestBearing interfaceInterest behaviour is pluggable per account type; the service treats them uniformly.
Template method (lite)BehaviouralAccount.deposit fixed, withdraw abstractThe base fixes the invariant skeleton; subclasses fill the varying step.

SOLID quick audit

  • Single responsibility — model, repo, service, persistence are separate classes.
  • Open/closed — add a new account type by subclassing Account; no existing class changes.
  • Liskov — any Account subtype is safely usable through the base reference.
  • Interface segregation — only savings opts into InterestBearing.
  • Dependency inversion — BankService depends on the Repository interface.

06Mapping to learning outcomes

How this single project satisfies the CP2 syllabus learning objectives and which course sessions it draws on.

Syllabus learning objectiveEvidence in this projectSessions
Utilize OOP (encapsulation, inheritance, polymorphism, abstraction) Abstract Account, two subclasses, dynamic withdraw dispatch, private state 10-16
Practice Software Engineering Principles (SOLID, patterns, modular architecture) Layered packages, DIP via repository interface, three GoF patterns 1728
Handle exceptions effectively (custom exceptions, best practice) Two checked exceptions carrying context; caught at the boundary 21
Generics (methods/classes, bounded types, wildcards) Repository<ID,T extends Account>, Predicate<? super T> 25
Collections (Sets, Lists, Maps) HashMap store, List query results, streams reduce 26
Build & deliver (Maven, jars) pom.xml, mvn test / exec:java 20
Unit testing & debugging 7-test JUnit 5 suite incl. assertThrows 21
Modularize & reuse code Methods with clear params/returns; generic repo reusable for other entities 07

Where to study each piece in the companion app

07Extensions

If you want to push the project toward the final-project bar, here are three natural directions, each tied to a syllabus topic.

A. A JavaFX UI sketch

Wrap the same BankService in a tiny JavaFX window — a ListView of accounts and buttons for deposit/withdraw. The service and model do not change; only a new view layer is added (separation of concerns). See the JavaFX lesson / sessions 22–23.

ui/BankApp.java (sketch)
public class BankApp extends Application {
    private final BankService bank = new BankService(new InMemoryRepository<>());

    @Override public void start(Stage stage) {
        ListView<String> list = new ListView<>();
        Button refresh = new Button("Refresh");
        refresh.setOnAction(e -> {
            list.getItems().setAll(
                bank.accountsOf("C1").stream().map(Object::toString).toList());
        });
        stage.setScene(new Scene(new VBox(8, list, refresh), 360, 280));
        stage.setTitle("IE Bank");
        stage.show();
    }
    public static void main(String[] a) { launch(a); }
}

B. Streams & richer queries

Replace the manual loop in findBy with the Stream API, and add reports like "richest customer" or "accounts grouped by type" using Collectors.groupingBy.

Map<String, List<Account>> byType =
    repo.findAll().stream()
        .collect(Collectors.groupingBy(Account::type));

C. Concurrency

Make the store thread-safe with ConcurrentHashMap and guard transfer with per-account locking (or move to an event queue) so two threads cannot corrupt a balance. This is the bridge to the multi-threading objective in the course's learning outcomes.

Known simplifications (be honest in your report)

  • transfer is not a real atomic transaction — a crash between debit and credit could lose money. A production system would wrap it in a transaction or compensating action.
  • The CSV store hard-codes a demo overdraft limit; persisting it properly is a one-line fix left as an exercise.
  • No authentication / audit log — out of scope for a CP2 worked example.

08References