100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Java Mastery
50 minintermediate

OOP Practice: Building a Banking System

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.

Analogy🏏Cricket
🏏 Think of it like cricket: after drilling individual skills in the nets, batting, bowling, fielding, a player finally puts them together in a full practice match where the skills must work in combination under live conditions. Just as the practice match reveals whether the separately-drilled skills actually cohere into a performance, these two programs reveal whether your separately-learned constructs, types, operators, branches, loops, actually cohere into working software. Just as the match is where a player first feels how the pieces fit, the exercise is where you first feel how the language fits together. Just as a coach moves players from drills to matches once the basics are sound, the course moves you from concepts to construction once the fundamentals are in place. The insight is that fundamentals only become capability when you assemble them into something complete that runs.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: setting up these two programs is like preparing two separate match fixtures, each with its own scorecard and playing eleven. Just as each fixture is self-contained — its own teams, its own result, no shared state that could tangle the outcomes — each program lives in its own clearly-named Java file with a single public class and a main method, the simplest structure for a console app. Just as the Laws require a clear team sheet before play begins so everyone knows who is on the field, the one-public-class-per-file rule keeps each program's entry point unambiguous. And just as scheduling two independent matches means a problem in one never delays the other, keeping the calculator and the number game in separate files keeps the exercises independent. The payoff: clean, isolated structure that lets you build, run, and reason about each program without the other getting in the way.

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.

bash
# 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.

Analogy🏏Cricket
🏏 Think of it like cricket: before drilling specific shots, a batter establishes their basic routine at the crease, take guard, face the ball, decide, repeat, the rhythm that frames every delivery. Just as that repeating routine frames each shot before the shot itself is chosen, the menu loop frames each operation before the operation is computed. Just as the batter always takes guard at least once before deciding whether to continue the innings, the do-while shows the menu at least once before checking whether to quit. Just as a sound routine lets the batter focus on each delivery cleanly, a sound interaction loop lets you focus on each operation cleanly. The insight is that establishing the repeating interaction structure first gives every later piece a clean frame to slot into.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: the routine at the crease means nothing if the actual shots are mistimed, the core skill is making clean contact, and a batter who plays down the wrong line gets out no matter how good their routine. Just as clean contact is where runs are actually scored, correct arithmetic is where the calculator actually works. Just as misjudging the line of the ball, the equivalent of integer division truncating, produces a wrong result despite a good setup, forgetting to cast to double produces a wrong number despite a good menu. Just as a batter carefully watches for the ball that could dismiss them, you carefully guard against the zero denominator that would crash the calculation. The insight is that the core computation must be exactly right, because no amount of surrounding structure rescues a wrong result.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: a bowler searching for the right length to dismiss a batter does not know in advance how many balls it will take, they bowl, observe whether the ball was too short or too full, adjust, and repeat until they get it right. Just as the bowler loops an unknown number of times, adjusting from feedback each ball, the guessing game loops until correct, adjusting from higher/lower feedback each round. Just as 'too short, pitch it up' guides the bowler's next delivery, 'too low, guess higher' guides the player's next guess. Just as the bowler is guaranteed to eventually find the length, the game must guarantee the loop can end. The insight is that feedback-driven repetition of unknown length, the while loop's natural shape, models exactly this kind of iterative homing-in.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: verifying your programs is like checking a scorecard against the video before it's official. Just as a scorer confirms a strike rate is a true decimal — 150.00, not a truncated 150 — you check each calculator operation returns a real decimal result, not an integer that silently drops the fraction. Just as the Laws guard against computing an economy rate for a bowler who hasn't bowled a single over, your zero-denominator guards prevent a divide-by-zero crash. Just as you'd test average, strike rate, and run rate with a known innings you can total by hand, you verify with values you can check yourself. And just as an umpire confirms a decision at the exact edge — level scores, an exact target — you test the higher/lower game's boundary where the guess equals the target. The payoff: normal and edge cases both proven correct, so the result stands up to scrutiny.
bash
# 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.
Lesson 10 of 35
0% complete