Object-Oriented Programming Basics

Project: Modeling a Bank Account

15 min Lesson 10 of 14

Project: Modeling a Bank Account

This final lesson brings together everything you have learned in this tutorial: classes and objects, fields and state, constructors, encapsulation, access modifiers, getters and setters, and validation. Instead of isolated examples, you will build a single, realistic BankAccount class from the ground up — step by step — and understand exactly why every design decision is made.

Why a bank account?

A bank account is a classic OOP case study because it has natural rules that mirror real-world constraints:

  • The balance must never be exposed directly — the bank decides when and how it changes.
  • Deposits must be positive.
  • Withdrawals must not exceed the available balance.
  • Every account needs an owner and a starting state set at creation time.

These rules map perfectly onto encapsulation and validation — the two skills you refined in lessons 4 and 5.

Step 1 — Define the class and its private fields

Start with the data. A bank account needs an account number, an owner name, and a balance. All three are private because no outside code should touch them directly.

public class BankAccount { private final String accountNumber; // set once, never changed private final String ownerName; private double balance; }
Why final for accountNumber and ownerName? Once an account is opened, the account number and the owner name do not change. Marking those fields final makes that contract explicit and lets the compiler enforce it. balance is not final because it is meant to change with every transaction.

Step 2 — Write the constructor

The constructor is the only place that sets the initial state. It validates the inputs before accepting them, so a BankAccount object is always born in a valid state.

public BankAccount(String accountNumber, String ownerName, double initialBalance) { if (accountNumber == null || accountNumber.isBlank()) { throw new IllegalArgumentException("Account number cannot be blank."); } if (ownerName == null || ownerName.isBlank()) { throw new IllegalArgumentException("Owner name cannot be blank."); } if (initialBalance < 0) { throw new IllegalArgumentException("Initial balance cannot be negative."); } this.accountNumber = accountNumber; this.ownerName = ownerName; this.balance = initialBalance; }

Notice the this. prefix — it distinguishes the field from the parameter when they share the same name, exactly as you learned in lesson 3.

Step 3 — Add deposit with validation

A deposit increases the balance, but only if the amount is strictly positive.

public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit amount must be positive."); } balance += amount; System.out.println("Deposited " + amount + ". New balance: " + balance); }
Validate first, act second. Always check that the input makes sense before changing any state. If you update balance and then throw an exception, the object is left in a corrupt state. With validation at the top of the method, either everything succeeds or nothing changes.

Step 4 — Add withdraw with validation

A withdrawal decreases the balance, but two rules apply: the amount must be positive, and there must be enough funds.

public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Withdrawal amount must be positive."); } if (amount > balance) { throw new IllegalStateException( "Insufficient funds. Balance: " + balance + ", requested: " + amount ); } balance -= amount; System.out.println("Withdrew " + amount + ". New balance: " + balance); }
Use the right exception type. IllegalArgumentException signals that the caller passed bad data (a negative amount). IllegalStateException signals that the object is in the wrong state for the operation (not enough money). Choosing the correct exception type makes error messages easier to understand and debug.

Step 5 — Add read-only getters

External code needs to read the balance and account info, but never write it directly. Provide getters with no matching setters — that is intentional.

public String getAccountNumber() { return accountNumber; } public String getOwnerName() { return ownerName; } public double getBalance() { return balance; }

There is no setBalance(). The only way to change the balance is through deposit() or withdraw(), which both apply the business rules. This is encapsulation doing exactly what it is designed to do.

Step 6 — Override toString

A useful toString() lets you print the account cleanly during debugging or logging.

@Override public String toString() { return "BankAccount{accountNumber='" + accountNumber + "', owner='" + ownerName + "', balance=" + balance + "}"; }

Putting it all together

Here is the complete class followed by a small driver that exercises every method:

public class BankAccount { private final String accountNumber; private final String ownerName; private double balance; public BankAccount(String accountNumber, String ownerName, double initialBalance) { if (accountNumber == null || accountNumber.isBlank()) throw new IllegalArgumentException("Account number cannot be blank."); if (ownerName == null || ownerName.isBlank()) throw new IllegalArgumentException("Owner name cannot be blank."); if (initialBalance < 0) throw new IllegalArgumentException("Initial balance cannot be negative."); this.accountNumber = accountNumber; this.ownerName = ownerName; this.balance = initialBalance; } public void deposit(double amount) { if (amount <= 0) throw new IllegalArgumentException("Deposit amount must be positive."); balance += amount; System.out.println("Deposited " + amount + ". New balance: " + balance); } public void withdraw(double amount) { if (amount <= 0) throw new IllegalArgumentException("Withdrawal amount must be positive."); if (amount > balance) throw new IllegalStateException("Insufficient funds."); balance -= amount; System.out.println("Withdrew " + amount + ". New balance: " + balance); } public String getAccountNumber() { return accountNumber; } public String getOwnerName() { return ownerName; } public double getBalance() { return balance; } @Override public String toString() { return "BankAccount{accountNumber='" + accountNumber + "', owner='" + ownerName + "', balance=" + balance + "}"; } }
public class Main { public static void main(String[] args) { BankAccount account = new BankAccount("ACC-001", "Sara Ali", 500.0); System.out.println(account); account.deposit(200.0); // balance: 700.0 account.withdraw(150.0); // balance: 550.0 System.out.println("Final balance: " + account.getBalance()); // this will throw IllegalStateException try { account.withdraw(10000.0); } catch (IllegalStateException e) { System.out.println("Caught: " + e.getMessage()); } } }

What you built and why it matters

The BankAccount class demonstrates every concept from this tutorial working together:

  • Class and objectBankAccount is the blueprint; account is the instance.
  • Fields and statebalance represents the account's evolving state.
  • Constructor — guarantees a valid starting state with this assignments.
  • Encapsulationprivate fields protected behind public methods.
  • Validation — every mutating method checks its input before acting.
  • Read-only access — getters without setters enforce the rule that only transactions can change the balance.
  • toString — clean output for debugging and logging.

This pattern — private state, a validating constructor, behaviour-driven public methods, and read-only getters — is the foundation of professional Java code. Congratulations on completing the OOP Basics tutorial!