What You'll Build
In this exercise you will build a small banking system that puts every object-oriented concept from Module 2 to work in one coherent design: classes and constructors, encapsulation with private fields and validating behaviour, inheritance for account types, and polymorphism through an abstract base and an interface. The system models accounts that can be deposited to and withdrawn from, with different account types, a savings account that accrues interest and a current account that permits an overdraft, sharing common behaviour while specialising what differs.
Rather than practising each OOP feature in isolation, you will see how they combine into a realistic design: an abstract Account holds the shared state and operations, concrete subclasses override what is account-type-specific, encapsulation guarantees no account ever reaches an invalid balance, and a bank processes a mixed list of accounts polymorphically. By the end you will have designed and built a layered object model, the everyday craft of object-oriented Java, consolidating Module 2 before the course moves into collections and generics.
Prerequisites
- Completion of lessons 06 through 09, or equivalent familiarity with classes, constructors, encapsulation, inheritance, polymorphism, interfaces, and abstract classes.
- A JDK installed (17 or 21), with javac and java available; verify with java -version.
- Comfort writing multiple classes across files (or as nested classes) and compiling them together.
- Understanding of access modifiers and validating constructors from lesson 07, since the account balance must be protected from invalid changes.
- Familiarity with abstract classes, the @Override annotation, and programming to a general type from lessons 08 and 09.
Setup & Project Structure
You will create a small set of related classes that form the object model: an abstract Account base, two concrete subclasses, an interface for accounts that earn interest, and a Bank that holds and processes accounts. Organising the system this way, a shared abstract base, specialised subclasses, a capability interface, and a coordinating class, mirrors how real object models are layered.
Keep each public class in its own file following the rule from lesson 01, or nest them for a single-file exercise. Lay out the structure first so the relationships between the types are clear before you write behaviour, and confirm your toolchain compiles all the files together.
# Create the banking-system files: a layered object model.
mkdir banking-system && cd banking-system
# The type relationships you will build:
# Account (abstract) -- shared state + operations, abstract type() method
# |-- SavingsAccount extends Account, implements InterestBearing
# |-- CurrentAccount extends Account (allows an overdraft)
# InterestBearing (interface) -- the capability to accrue interest
# Bank -- holds a List<Account>, processes them polymorphically
# Files (one public type each):
# Account.java SavingsAccount.java CurrentAccount.java
# InterestBearing.java Bank.java
# Compile everything together, then run the Bank demo:
# javac *.java
# java Bank
echo 'Banking-system object model laid out.'
Step 1 — Foundation
Step 1 builds the encapsulated foundation: the abstract Account class with a private balance and validating deposit and withdraw operations. The concept is combining encapsulation with an abstract base, the balance is private so no code can corrupt it, every change flows through validating methods, and the class is abstract because 'a generic account' is not a real thing you should instantiate, only specific types are.
Account also declares an abstract method, accountType, that each subclass must define, and a withdraw that subclasses can refine. Getting this foundation right means the core invariant, an account's balance is only ever changed through validated operations, holds for every account type built on top, the encapsulation guarantee from lesson 07 applied as the bedrock of the whole system.
// Account.java -- Step 1: abstract base with encapsulated, validated balance.
public abstract class Account {
private final String id; // immutable identity
private long balanceCents; // PRIVATE: changed only via validated methods
protected Account(String id, long openingCents) {
if (openingCents < 0)
throw new IllegalArgumentException("opening balance cannot be negative");
this.id = id;
this.balanceCents = openingCents;
}
public long balanceCents() { return balanceCents; } // read-only view
public String id() { return id; }
public void deposit(long cents) {
if (cents <= 0) throw new IllegalArgumentException("deposit must be positive");
balanceCents += cents;
}
// Subclasses may refine HOW MUCH can be withdrawn; this enforces the basic rules.
public void withdraw(long cents) {
if (cents <= 0) throw new IllegalArgumentException("withdrawal must be positive");
if (cents > maxWithdrawable())
throw new IllegalStateException("exceeds available funds");
balanceCents -= cents;
}
// Default: cannot withdraw below zero. CurrentAccount will override this.
protected long maxWithdrawable() { return balanceCents; }
// Protected helper so subclasses can adjust balance through a controlled path.
protected void adjust(long cents) { balanceCents += cents; }
public abstract String accountType(); // THE GAP every subclass must fill
}
Step 2 — Core Logic
Step 2 builds the concrete account types, where inheritance and overriding do their work. SavingsAccount extends Account and implements an InterestBearing interface, adding interest accrual while reusing all the inherited deposit and withdraw logic. CurrentAccount extends Account but overrides maxWithdrawable to permit an overdraft down to a negative limit, specialising exactly the one rule that differs.
This is the core because it demonstrates the central OOP payoff: each subclass writes only what is genuinely different, savings adds interest, current changes the withdrawal limit, while everything common is inherited unchanged. The InterestBearing interface is a capability that only some accounts have, exactly the interface-versus-inheritance distinction from lesson 09, since not every account earns interest but any that does promises the same accrue contract.
// InterestBearing.java + SavingsAccount.java + CurrentAccount.java -- Step 2.
// A CAPABILITY only some accounts have (interface, not inheritance).
interface InterestBearing {
void accrueInterest();
}
// SAVINGS: reuses all of Account, ADDS interest via the interface.
class SavingsAccount extends Account implements InterestBearing {
private final double annualRate; // e.g. 0.04 for 4%
public SavingsAccount(String id, long openingCents, double annualRate) {
super(id, openingCents); // initialise the inherited part first
this.annualRate = annualRate;
}
@Override public String accountType() { return "Savings"; }
@Override public void accrueInterest() {
long interest = Math.round(balanceCents() * annualRate / 12); // monthly
adjust(interest); // controlled balance change via the base
}
}
// CURRENT: reuses Account but OVERRIDES one rule -- it allows an overdraft.
class CurrentAccount extends Account {
private final long overdraftLimitCents; // e.g. 50_000 -> may go to -500.00
public CurrentAccount(String id, long openingCents, long overdraftLimitCents) {
super(id, openingCents);
this.overdraftLimitCents = overdraftLimitCents;
}
@Override public String accountType() { return "Current"; }
// The ONLY behavioural difference: withdrawals may dip into the overdraft.
@Override protected long maxWithdrawable() {
return balanceCents() + overdraftLimitCents;
}
}
Step 3 — Integration & Enhancement
Step 3 brings it together with a Bank that holds a mixed list of accounts and processes them polymorphically. The Bank stores a List of the general Account type and can iterate it, printing each account's type and balance, with each object answering accountType in its own way, the polymorphism payoff from lesson 09. For the interest run, the Bank checks which accounts implement InterestBearing and accrues interest only on those.
This integration demonstrates programming to the general type: the Bank knows about Account and InterestBearing, not about SavingsAccount or CurrentAccount specifically, so a new account type added later would slot into the Bank with no changes. The instanceof check for the interface capability shows the clean way to apply behaviour only to objects that support it, the practical face of mixing inheritance and interfaces in one polymorphic collection.
// Bank.java -- Step 3: holds a mixed List<Account>, processes polymorphically.
import java.util.*;
public class Bank {
private final List<Account> accounts = new ArrayList<>();
public void open(Account a) { accounts.add(a); } // accepts ANY Account subtype
public void printStatement() {
for (Account a : accounts) { // polymorphic iteration
System.out.printf("%s [%s]: %.2f%n",
a.id(), a.accountType(), // each answers accountType() itself
a.balanceCents() / 100.0);
}
}
// Apply interest ONLY to accounts that have the capability.
public void runMonthlyInterest() {
for (Account a : accounts) {
if (a instanceof InterestBearing ib) { // pattern check for the capability
ib.accrueInterest();
}
}
}
public static void main(String[] args) {
Bank bank = new Bank();
bank.open(new SavingsAccount("SAV-1", 100_000, 0.04)); // earns interest
bank.open(new CurrentAccount("CUR-1", 20_000, 50_000)); // has an overdraft
bank.runMonthlyInterest(); // only savings accrues
bank.printStatement();
// A future FixedDepositAccount would work here with NO changes to Bank.
}
}
Step 4 — Testing & Verification
Verify the system honours its rules across every account type. Confirm that encapsulation holds, no code outside Account can set the balance directly and negative or zero deposits and withdrawals are rejected; that savings accrues interest but current does not; that a current account can overdraw down to its limit but not beyond, while a savings account cannot go negative; and that the Bank processes both types polymorphically. Compile all files together and run the checks below, comparing output and exceptions to the expected behaviour.
# Compile the whole object model together and verify the rules.
javac *.java
java Bank
# Expected statement (after monthly interest on savings only):
# SAV-1 [Savings]: 1003.33 (100000 cents + ~333 interest @ 4%/12)
# CUR-1 [Current]: 200.00 (unchanged -- current does not accrue interest)
# Targeted behaviour checks (add temporarily to a main, or a test harness):
# SavingsAccount s = new SavingsAccount("S", 5000, 0.04);
# s.withdraw(6000); -> IllegalStateException (savings cannot go negative)
# s.deposit(-10); -> IllegalArgumentException (deposit must be positive)
#
# CurrentAccount c = new CurrentAccount("C", 5000, 50000);
# c.withdraw(50000); -> OK (dips into overdraft; balance -> -45000 cents)
# c.withdraw(20000); -> IllegalStateException (beyond the overdraft limit)
#
# // Encapsulation: there is NO way to write the balance directly --
# // 'c.balanceCents = 0;' does not compile (field is private).
# If a CurrentAccount refuses a valid overdraft, your maxWithdrawable() override
# (Step 2) is wrong; if savings accrues nothing, check the InterestBearing check (Step 3).
Warning: A subtle design trap in inheritance hierarchies like this is letting subclasses change the balance through anything other than the controlled, validated paths. If a subclass could write the balance field directly, it could bypass the invariants the base class enforces, reintroducing exactly the invalid states encapsulation prevents. Keep the balance private in the base and expose only protected, controlled adjustment methods (like adjust here), so even subclasses must respect the rules. Encapsulation must hold against your own subclasses, not just outside code.
Extension Challenge: Add a FixedDepositAccount that implements InterestBearing at a higher rate but overrides withdraw to forbid withdrawals before a maturity date, demonstrating that a new type drops into the existing Bank with no changes. For a harder stretch, introduce a Transaction record of each deposit and withdrawal, give each Account an immutable transaction history exposed as an unmodifiable list, and add a transfer operation on the Bank that moves money between two accounts atomically, validating both sides before committing either.
- A layered banking object model combines all of Module 2: an abstract Account base, concrete subclasses, a capability interface, and a polymorphic Bank.
- Encapsulation keeps the balance private and changed only through validating deposit/withdraw, so no account can reach an invalid state, even via subclasses.
- The abstract Account provides shared state and operations plus an abstract accountType the subclasses must implement; it cannot be instantiated itself.
- Subclasses override only what differs, SavingsAccount adds interest, CurrentAccount changes the withdrawal limit, inheriting everything common.
- InterestBearing is a capability interface only some accounts implement, distinct from the inheritance hierarchy, applied via an instanceof pattern check.
- The Bank programs to the general Account type, so it processes any account type polymorphically and accepts future types with no changes.