Demo: protecting the balance invariant
On This Lesson 11 topics
Lesson Overview
This lesson is a guided build. You will take the Account class from the first module on classes, with public fields and no checks, and lock it down in six steps, using every idea from this module: private fields, careful visibility, invariants in the constructor and methods, behavior instead of setters, and an immutable Money class.
Along the way you will make one planned mistake on purpose: you will add a generated setBalance and watch it undo your work. Seeing the rule break is the best way to remember why it is not there in the finished class.
The module lab, "Bank Accounts v2", asks you to do the same to your own code. Treat this lesson as a model solution to read, type, and then adapt: type the code yourself rather than pasting it, and run it after every step.
Why this matters: the goal is the acceptance rule of the lab, no way to reach an invalid state from outside the class. Practicing the steps in order is how you make that a habit.
This lesson builds on all previous lessons in the module. Compile and run commands differ by setup (IDE or terminal), so use whichever you already use.
Learning Objectives
By the end of this lesson you will be able to:
- Turn a class with public fields into a protected class in a sequence of safe steps.
- Verify at each step that the compiler or runtime now refuses what it used to allow.
- Recognize a generated setter as a bypass and remove it.
- Use an immutable
Moneyclass for amounts and balances. - Check a finished class against the "no invalid state from outside" rule.
- Write a short test
mainthat tries to break your own class.
Written Explanation
The plan
| Step | Change | Evidence |
|---|---|---|
| 1 | Start from the unprotected Account | acc.balance = -1000; works |
| 2 | Make all fields private | the same line no longer compiles |
| 3 | Add read-only accessors | callers can look, not touch |
| 4 | Enforce rules in constructor, deposit, withdraw | bad calls throw exceptions |
| 5 | Planned mistake: add setBalance; then remove it | the rule breaks, then is restored |
| 6 | Introduce immutable Money for amounts | no double surprises, no negative money |
Step 1: the starting point
class Account {
String owner;
String number;
double balance;
Account(String owner, String number, double balance) {
this.owner = owner;
this.number = number;
this.balance = balance;
}
}
public class Main {
public static void main(String[] args) {
Account acc = new Account("Sara", "A-100", 100);
acc.balance = -1000;
System.out.println(acc.balance);
}
}-1000.0Every field is open to the whole package, and a one-line assignment destroys the rule. This is the "before" picture.
Step 2: make the fields private
Add private to all three fields. Compile again:
Main.java:16: error: balance has private access in Account
acc.balance = -1000;
^
Main.java:17: error: balance has private access in Account
System.out.println(acc.balance);
^
2 errorsBoth lines of main that touched the field fail. That second error is useful information: the program also read the field directly, so after this step we need a way to look at the balance.
Step 3: read-only accessors, no setters
owner and number never change after the account is opened, so they become private final with read accessors only. The balance changes over time, so it is private (not final) and gets a read accessor, but no setter. Lesson 4 gave the reasoning: expose a value only if some caller truly needs to see it, and change it only through behavior methods.
Step 4: enforce the invariants
Put the rules where lesson 3 said: the constructor and every method that changes the balance.
class Account {
private final String owner;
private final String number;
private double balance;
Account(String owner, String number, double balance) {
if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Owner is required");
if (number == null || number.isBlank()) throw new IllegalArgumentException("Number is required");
if (balance < 0) throw new IllegalArgumentException("Opening balance cannot be negative: " + balance);
this.owner = owner;
this.number = number;
this.balance = balance;
}
String owner() { return owner; }
String number() { return number; }
double balance() { return balance; }
void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive: " + amount);
balance += amount;
}
void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Withdrawal must be positive: " + amount);
if (amount > balance) throw new IllegalStateException("Insufficient funds: balance " + balance + ", requested " + amount);
balance -= amount;
}
}At this point, from outside the class, there is no way to make the balance negative: the constructor refuses it, deposit only adds positive amounts, and withdraw refuses overdrafts. Take a moment to try each route yourself.
Step 5: the planned mistake
Your IDE offers "generate setters". Imagine a teammate uses it (the exact menu varies by IDE) and adds:
void setBalance(double balance) { // generated by reflex
this.balance = balance;
}Now the test main:
Account acc = new Account("Sara", "A-100", 100);
acc.deposit(50);
acc.withdraw(30);
System.out.println(acc.owner() + " " + acc.balance());
acc.setBalance(-1000);
System.out.println(acc.balance());Sara 120.0
-1000.0Everything you built in step 4 is bypassed by one method. The invariant is broken again from outside, and the code compiles without a warning. Delete setBalance. If a legitimate need appears later (for example "correct a posting error"), add a named behavior method with its own rules, such as adjust with a reason, rather than an open setter.
Step 6: an immutable Money
Using double for money invites rounding questions, and every method has to re-check "positive" and "non-negative". Move the amount rules into a small immutable value class, as in lesson 5:
final class Money {
private final long cents;
Money(long cents) {
if (cents < 0) throw new IllegalArgumentException("Money cannot be negative: " + cents);
this.cents = cents;
}
Money plus(Money other) { return new Money(cents + other.cents); }
boolean isAtLeast(Money other) { return cents >= other.cents; }
Money minus(Money other) {
if (other.cents > cents) throw new IllegalArgumentException("Result would be negative");
return new Money(cents - other.cents);
}
boolean isZero() { return cents == 0; }
@Override
public String toString() {
return (cents / 100) + "." + String.format("%02d", cents % 100);
}
}Account now holds a Money balance. Because Money is immutable, balance() can safely return it: the caller cannot change the account through it.
class Account {
private final String owner;
private final String number;
private Money balance;
Account(String owner, String number, Money opening) {
if (owner == null || owner.isBlank()) throw new IllegalArgumentException("Owner is required");
if (number == null || number.isBlank()) throw new IllegalArgumentException("Number is required");
if (opening == null) throw new IllegalArgumentException("Opening balance is required");
this.owner = owner;
this.number = number;
this.balance = opening;
}
String owner() { return owner; }
String number() { return number; }
Money balance() { return balance; }
void deposit(Money amount) {
if (amount == null || amount.isZero()) throw new IllegalArgumentException("Deposit must be positive");
balance = balance.plus(amount);
}
void withdraw(Money amount) {
if (amount == null || amount.isZero()) throw new IllegalArgumentException("Withdrawal must be positive");
if (!balance.isAtLeast(amount)) {
throw new IllegalStateException("Insufficient funds: balance " + balance + ", requested " + amount);
}
balance = balance.minus(amount);
}
}Account acc = new Account("Sara", "A-100", new Money(10000));
acc.deposit(new Money(5000));
acc.withdraw(new Money(3050));
System.out.println(acc.owner() + " " + acc.balance());
acc.withdraw(new Money(99999));Sara 119.50
Exception in thread "main" java.lang.IllegalStateException: Insufficient funds: balance 119.50, requested 999.99
at Account.withdraw(Main.java:52)
at Main.main(Main.java:64)Negative money cannot even be constructed, so no method can receive it. The null checks are new: with objects as parameters, a caller can pass null, which would otherwise surface as a confusing crash deeper inside.
Final check against the acceptance rule
Read the finished class and ask, for every way into it: can any call from outside create an invalid state?
- Constructor: rejects blank owner, blank number, null balance, and (through
Money) negative balance. deposit,withdraw: reject null, zero, and overdrafts.- Fields: all private;
ownerandnumberarefinal. - No setters.
balance()returns an immutable value.
If the answer is "no" everywhere, the lab's acceptance rule is met.
Examples
Example 1: the "break it" test (simple)
After each step, write a few lines that try to break the class and see what happens: assign the field, pass a negative amount, overdraw. The step-2 compile error and the step-4 exceptions above are exactly this. A class is protected when every such attempt fails loudly.
Example 2: reviewing a class for violations (practical)
A reviewer scanning a class for encapsulation problems looks for: public or package-visible fields, setters that skip rules, getters generated for every field, a method that returns an internal array or list, a constructor with no validation. The finished Account has none. The Parcel class in the exercises below has several.
Example 3: where a rule should live (realistic)
When a new feature arrives, say "freeze an account", the rule "a frozen account refuses withdrawals" belongs in Account.withdraw, so that every path (a mobile app, a batch job, an admin tool) is covered. Add a private boolean frozen field and freeze() behavior; do not add setFrozen.
Backend Insight
In a backend service, the same Account is touched by many entry points: a REST endpoint, a scheduled job, an import. Only the class itself is on the path of all of them. This is why code reviewers in backend teams comment so often on "where does this rule live?". Put it in the domain class, and every present and future caller gets the protection for free.
Common Mistakes
Mistake 1: Leaving a generated setter behind
Generated code is easy to forget. Review your class for any setX that you did not deliberately write.
Mistake 2: Validating in the constructor but not in methods (or the reverse)
Walk through every route that sets or changes a field and check each one.
Mistake 3: Making balance() return something mutable
Returning a Money is safe because it is immutable. If the balance were a mutable object, you would return a copy.
Mistake 4: Testing only the good path
A test main that only deposits and withdraws proves nothing about protection. Add the attempts that should fail.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. In the step table, which step proves that outside code can no longer assign the field?
Show solution A1
A1. Step 2: after adding private, the compiler reports balance has private access in Account.
A2. Why does the demo remove setBalance instead of making it validate?
Show solution A2
A2. A setter, even a validating one, says nothing about why the balance changes, and invites callers to compute the new value outside. Deposits and withdrawals already cover every legitimate change, each with its own rules. A named behavior method can be added later if a real need arises.
A3. Why are owner and number final?
Show solution A3
A3. They never change after the account is opened, so final lets the compiler guarantee that, and no setter is needed.
Level B: Application
B1. Review this class and list every encapsulation violation (there are at least four):
class Parcel {
public String id;
private double weightKg;
private String[] stamps = new String[2];
Parcel(String id, double weightKg) {
this.id = id;
this.weightKg = weightKg;
}
public double getWeightKg() { return weightKg; }
public void setWeightKg(double weightKg) { this.weightKg = weightKg; }
public String[] getStamps() { return stamps; }
}Show solution B1
B1. (1) public String id is a public field anyone can overwrite. (2) The constructor does no validation (blank id, negative weight). (3) setWeightKg is an unchecked setter that can make the weight negative. (4) getStamps() returns the live internal array, so callers can change it. (5) The stamps array is created internally, but nothing prevents later leaks if it is ever passed in. Any four are enough.
B2. Add a freeze() behavior and the rule "a frozen account refuses withdraw" to the step-4 Account (the double version). Name the exception you would throw.
Show solution B2
B2.
private boolean frozen;
void freeze() { frozen = true; }
void withdraw(double amount) {
if (frozen) throw new IllegalStateException("Account is frozen");
// ...the existing checks and update...
}The argument may be fine, but the current state forbids the operation, so IllegalStateException.
B3. Write three lines of main that try to break the step-6 Account and say what each produces.
Show solution B3
B3. Examples: new Account("", "A-1", new Money(100)) throws IllegalArgumentException: Owner is required; new Money(-5) throws IllegalArgumentException: Money cannot be negative: -5; acc.withdraw(new Money(99999)) throws IllegalStateException with the insufficient-funds message.
Level C: Challenge
C1. Rewrite Parcel so it is immutable, validates its inputs (id not blank, weight positive), copies its stamps array in and out, and offers withWeight(double) that returns a new parcel. Show that changing the original array and the returned array no longer affects the parcel, and that a negative weight is refused.
Show solution C1
C1.
import java.util.Arrays;
final class Parcel {
private final String id;
private final double weightKg;
private final String[] stamps;
Parcel(String id, double weightKg, String[] stamps) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("Id is required");
if (weightKg <= 0) throw new IllegalArgumentException("Weight must be positive: " + weightKg);
if (stamps == null) throw new IllegalArgumentException("Stamps are required");
this.id = id;
this.weightKg = weightKg;
this.stamps = stamps.clone();
}
String id() { return id; }
double weightKg() { return weightKg; }
String[] stamps() { return stamps.clone(); }
Parcel withWeight(double newWeightKg) {
return new Parcel(id, newWeightKg, stamps);
}
}Testing with String[] s = {"A"}; Parcel p = new Parcel("P-1", 2.5, s); s[0] = "X"; p.stamps()[0] = "Y"; and printing Arrays.toString(p.stamps()) gives [A], and p.withWeight(3.0).weightKg() gives 3.0. Then new Parcel("P-2", -1, s) stops with IllegalArgumentException: Weight must be positive: -1.0.
C2. Explain why null checks were added in step 6 but not in step 4.
Show solution C2
C2. In step 4 the parameters were double values, which can never be null. In step 6 the parameters are Money objects, and any object reference can be null. Without the checks, balance.plus(null) would fail with a confusing error from the middle of the class instead of a clear message at the door.
Mini Challenge
Apply the six steps to your own Account from the previous module (this is the start of the lab). Keep a short log: for each step, one line of evidence (compiler error, exception message, or output). End with a list of every way into the class and the rule that protects each.
Check your work: no public fields; no setters; every state-changing path has a check; the log shows at least one failed attempt per rule.
Summary & Next Lesson
Key takeaways
- Protect a class in order: private fields, read-only accessors, checks in the constructor and methods, no setters, immutable values for amounts.
- After each step, run code that tries to break the class and watch it fail.
- A generated setter silently undoes all the checks; remove it or replace it with a named behavior method.
- An immutable
Moneyremoves a whole set of invalid states and is safe to return. - The finished test is: from outside, can any call create an invalid state? It should be no.
Next lesson
This was the last lesson of the module. The next step is the lab, "Bank Accounts v2", where you apply these steps to your own code. The lessons after this module are listed in the course roadmap.
Self-check
- Which step shows that the fields are no longer reachable?
- What does the planned mistake demonstrate?
- Why can
balance()safely return aMoney? - Why are there no setters on the finished
Account? - What is the acceptance rule for the lab?
Show answers
- Step 2: making the fields private produces a compile-time error for outside access.
- That one generated setter bypasses every check and reopens the invariant.
Moneyis immutable, so the caller cannot change the account through it.- Every legitimate change is covered by
depositandwithdraw, which apply the rules. - No way to reach an invalid state from outside the class.
References
- Oracle, Controlling Access to Members of a Class. It supports the use of
privateand the advice to avoid public fields. - Oracle, A Strategy for Defining Immutable Objects. It supports the immutable
MoneyandParceldesign: private final fields, no setters, a final class, and copying mutable references. - Oracle, IllegalArgumentException and IllegalStateException. They support using the first for a bad argument and the second for a call made at an inappropriate time.
- All code and outputs were produced by compiling and running with JDK 21.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.