Skip to content
Ameen Abozaid
PublishedBEG-021-L03

Invariants: rules the object guarantees

  • Lesson 3 of 6
  • 11 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Private fields close the vault. This lesson is about what the teller does at the window: check every request against the bank's rules, and refuse the ones that break them. The rules a class promises to keep are called its invariants, and enforcing them inside the class is the real payoff of encapsulation.

By the end of the lesson an Account will be impossible to create or change into an invalid state, however carelessly the calling code is written.

Why this matters: in a backend service, bad data that gets inside an object travels. It is saved to the database, shown to customers, and used in later calculations. Stopping it at the door is cheaper than finding it later.

This lesson builds on "Why hide data" and "Access modifiers" (private fields, public methods), and on the Account class with a constructor and methods from the previous module. It uses throw to refuse bad input; exceptions get their own module later, so read the short explanation below and treat the rest as a preview.

Learning Objectives

By the end of this lesson you will be able to:

  • Define an invariant and give examples.
  • State the two places an invariant must be enforced: the constructor and every method that changes the data.
  • Reject invalid input with throw new IllegalArgumentException(...) or IllegalStateException, with a clear message.
  • Choose between those two exceptions.
  • Explain why "validate first, change second" matters.
  • Write a class whose invalid states cannot be reached from outside.

Written Explanation

What an invariant is

An invariant is a rule that is true for an object at all times when other code can see it. Examples:

  • An account's balance is never negative.
  • A person's age is between 0 and 150.
  • A star rating is between 1 and 5.
  • A shopping cart's item count is never below zero.

The word is "invariant" because the rule does not vary. If the rule holds when the object is created and every method that changes the object keeps it true, then it holds for the object's whole life. That is a strong guarantee, and it is what callers rely on.

Where to enforce it: two places

For the guarantee to work, check the rule in both of these places:

  1. The constructor. An object must be valid from its first moment. If the constructor accepts -20 as an opening balance, you have an invalid object before any method has run.
  2. Every method that changes the data (deposit, withdraw, update). Each such method either keeps the rule true or refuses to do its job.

A read-only method does not need checks, because it does not change anything.

Because the fields are private (lesson 1), there is no third way in. That is why hiding data and enforcing invariants belong together: hiding closes the side doors, and the checks guard the one door left.

How to refuse: throw an exception

When a request would break the rule, the method must not quietly carry on. Silently ignoring the request leaves the caller thinking it worked. Printing a message and continuing, as the earlier teaching example did, hides the problem in a place no program can react to. Setting the value to 0 invents data.

Instead, stop immediately with an exception. An exception is Java's way of saying "this cannot proceed", and it carries a message. The statement looks like this:

java
throw new IllegalArgumentException("Deposit must be positive: " + amount);

If nothing catches the exception, the program stops and prints a stack trace, which you learned to read in the debugging module. (Catching exceptions is taught in a later module, so for now we let them stop the program, which is a perfectly honest way to see what happens.)

Two exception types cover most cases:

ExceptionUse it whenExample
IllegalArgumentExceptionThe argument the caller passed is wrong, whatever state the object is indeposit of -5, age of 900
IllegalStateExceptionThe argument is acceptable, but the object's current state does not allow the operation nowwithdraw of 500 from a balance of 120

The Java API documentation describes IllegalArgumentException as signalling that a method was passed an illegal or inappropriate argument, and IllegalStateException as signalling that a method was called at an illegal or inappropriate time. Both are unchecked, which means Java does not force you to declare or catch them.

A good message names the rule and shows the bad value: "Deposit must be positive: -5.0", not "error". The person who reads the log at 2 a.m. will thank you.

Validate first, change second

Order matters inside a method. Check everything before you modify anything:

java
void withdraw(double amount) {
    if (amount <= 0) throw new IllegalArgumentException("...");
    if (amount > balance) throw new IllegalStateException("...");
    balance -= amount;   // only reached when all checks passed
}

If you changed balance first and then discovered a problem, the object could be left half-changed when the exception flies. Checking first guarantees that a refused request changes nothing.

Do not rely on callers

"The screen already checks the amount" is not a defence. The screen is one caller. Next year there will be an import job, a test, and a colleague who has not read the screen's code. The class that owns the rule is the only place that is guaranteed to be on the path of every change.

This does not forbid callers from checking too: a user interface may check early to show a friendly message. But the class must check regardless, because it is the last line of defence.

A word on shared rules

If several methods must check the same rule, avoid copying it. Put the check into a small private method and call it from the constructor and the other methods, as the Person example below does with checkAge. Now the rule exists once.

Examples

Example 1: guarding the constructor (simple)

java
class Account {
    private double balance;

    Account(double openingBalance) {
        if (openingBalance < 0) {
            throw new IllegalArgumentException("Opening balance cannot be negative: " + openingBalance);
        }
        this.balance = openingBalance;
    }
}

public class Main {
    public static void main(String[] args) {
        Account acc = new Account(-20);
        System.out.println("Created");
    }
}

Output:

text
Exception in thread "main" java.lang.IllegalArgumentException: Opening balance cannot be negative: -20.0
	at Account.<init>(Main.java:6)
	at Main.main(Main.java:14)

The line Created is never printed: no Account with a negative balance ever existed. (<init> is how a stack trace names a constructor.)

Example 2: guarding every method that changes the balance (practical)

java
class Account {
    private double balance;

    Account(double openingBalance) {
        if (openingBalance < 0) {
            throw new IllegalArgumentException("Opening balance cannot be negative: " + openingBalance);
        }
        this.balance = openingBalance;
    }

    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;
    }

    double balance() {
        return balance;
    }
}

Using it:

java
Account acc = new Account(100);
acc.deposit(50);
acc.withdraw(30);
System.out.println("Balance: " + acc.balance());
acc.withdraw(500);
System.out.println("Never printed");

Output:

text
Balance: 120.0
Exception in thread "main" java.lang.IllegalStateException: Insufficient funds: balance 120.0, requested 500.0
	at Account.withdraw(Main.java:23)
	at Main.main(Main.java:39)

The first three operations succeed. The fourth is refused with a message that says what happened, and the balance is still 120.0 because the check came before the change. Note the exception type: 500 is a fine amount in itself; it is the state (balance 120) that forbids it, so IllegalStateException.

Example 3: one rule shared by constructor and method (realistic)

java
class Person {
    private String name;
    private int age;

    Person(String name, int age) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name is required");
        }
        checkAge(age);
        this.name = name;
        this.age = age;
    }

    void haveBirthday() {
        checkAge(age + 1);
        age++;
    }

    private static void checkAge(int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Age must be between 0 and 150: " + age);
        }
    }

    String describe() {
        return name + " (" + age + ")";
    }
}

With new Person("Omar", 149), one haveBirthday() prints Omar (150), and a second one stops the program:

text
Omar (150)
Exception in thread "main" java.lang.IllegalArgumentException: Age must be between 0 and 150: 151
	at Person.checkAge(Main.java:21)
	at Person.haveBirthday(Main.java:15)
	at Main.main(Main.java:35)

The check is written once in the private checkAge. The constructor and the method both call it, so the rule cannot drift apart. haveBirthday checks age + 1 before doing age++.

Backend Insight

Backend code receives data from outside: JSON from browsers, files from partners, rows from databases. The first defence is checking at the boundary, but objects inside the system should still protect themselves. When a class guarantees its invariants, you can read code that uses it and trust the values: you do not need to wonder whether a balance might be negative or an email might be null. Frameworks you will use later often turn these exceptions into a "bad request" reply to the client, which only works if the exception is thrown early and carries a clear message.

Common Mistakes

Mistake 1: Validating in the constructor only

  • Incorrect: the constructor rejects negative balances, but withdraw has no check.
  • Correct: every method that can break the rule checks it.

Mistake 2: Ignoring bad input silently

if (amount > 0) balance += amount; with no else makes a wrong call look like a success. Throw instead, so the caller learns about the bug.

Mistake 3: Changing first, checking later

Modifying a field and then throwing leaves the object in a state you did not intend. Always check before you change.

Mistake 4: Vague messages

  • Incorrect: throw new IllegalArgumentException("Invalid");
  • Correct: throw new IllegalArgumentException("Deposit must be positive: " + amount);

Mistake 5: Forgetting special values

Comparisons with NaN are always false, so if (amount <= 0) does not reject Double.NaN. In a real money class you would use whole numbers (see the immutable-objects lesson) which have no such value. We do not cover NaN further in this course module; treat it as a known gap in the simple double examples.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. What is an invariant? Give one example not used in this lesson.

Show solution A1

A1. An invariant is a rule that is true for the object at all times when other code can see it. Example: a bookstore's Book always has a stock count of at least 0.

A2. Name the two places where an invariant must be enforced.

Show solution A2

A2. The constructor, and every method that changes the object's data.

A3. Which exception fits: (a) deposit(-5), (b) withdraw(500) when the balance is 120?

Show solution A3

A3. (a) IllegalArgumentException: the argument is wrong whatever the state. (b) IllegalStateException: the argument is fine in itself, but the current balance does not allow it.

Level B: Application

B1. Write a Rating class with a private int stars that must always be 1 to 5. Add a constructor, an update(int stars) method, and a getter stars(). Put the rule in one private method. Create new Rating(3), update it to 5, and print the stars.

Show solution B1

B1.

java
class Rating {
    private int stars;

    Rating(int stars) {
        check(stars);
        this.stars = stars;
    }

    void update(int stars) {
        check(stars);
        this.stars = stars;
    }

    private static void check(int stars) {
        if (stars < 1 || stars > 5) {
            throw new IllegalArgumentException("Stars must be 1 to 5: " + stars);
        }
    }

    int stars() { return stars; }
}

Using Rating r = new Rating(3); r.update(5); System.out.println(r.stars()); prints 5.

B2. The Rating constructor is correct, but update has no check. Describe, in two sentences, the invalid state that can be reached.

Show solution B2

B2. Callers can write r.update(99) or r.update(-1), and the object silently holds an impossible rating. The constructor's guarantee is lost the first time anyone changes the value.

B3. Rewrite this method so a refused call changes nothing:

java
void withdraw(double amount) {
    balance -= amount;
    if (balance < 0) throw new IllegalStateException("Insufficient funds");
}
Show solution B3

B3.

java
void withdraw(double amount) {
    if (amount > balance) throw new IllegalStateException("Insufficient funds");
    balance -= amount;
}

Check first, then change. (A complete version also rejects amount <= 0.)

Level C: Challenge

C1. Add a transferTo(Account other, double amount) method to Account. It must never create or destroy money, and a refused transfer must not change either account. Show a successful transfer of 40 from a 100 account to an empty one, and a failing transfer of 500.

Show solution C1

C1.

java
void transferTo(Account other, double amount) {
    this.withdraw(amount);   // fails first if invalid: nothing has moved yet
    other.deposit(amount);
}

withdraw already rejects bad amounts and insufficient funds before changing anything; deposit then receives an amount that is already known to be positive, so it cannot refuse it. With a = new Account(100) and b = new Account(0), a.transferTo(b, 40) leaves 60.0 and 40.0. The call a.transferTo(b, 500) stops with IllegalStateException: Insufficient funds, raised in withdraw, before b is touched.

C2. A teammate says: "The form already stops negative amounts, so the check in deposit is redundant." Respond in three sentences.

Show solution C2

C2. The form is one caller, and others (tests, import jobs, future code) may not use it. Only the class is on the path of every change, so only the class can guarantee the rule. The check costs one line and protects against every other caller.

Mini Challenge

Write a Product class with a private name (not blank), a private price in whole cents (not negative), and a private stock count (not negative). Add a constructor, a method sell(int quantity) that reduces stock but refuses a quantity that is zero, negative, or larger than the stock, and a method restock(int quantity). Use a private helper for any rule that appears twice. Write a main that triggers one refusal and read the exception message.

Check your work: every field is private, no way exists to make stock or price negative, each message names the rule and the bad value, and checks come before changes.

Summary & Next Lesson

Key takeaways

  • An invariant is a rule that holds for an object whenever other code can see it.
  • Enforce it in the constructor and in every method that changes the data.
  • Refuse bad requests with an exception and a clear message; never ignore them silently.
  • Use IllegalArgumentException for a bad argument and IllegalStateException for a bad moment.
  • Check first, change second; put a shared rule in one private method.

Next lesson

The class can now guard itself. The next lesson, "Getters, setters, and tell, don't ask", asks which methods to add for callers, and why a generated setBalance would undo everything you just built.

Self-check

  1. What is an invariant?
  2. Why must the constructor check the rule too?
  3. Which exception for withdraw(500) with a balance of 120?
  4. Why check before changing a field?
  5. Why is a clear message important?
Show answers
  1. A rule that is always true for the object, such as "balance is never negative".
  2. Otherwise an invalid object can exist from the very start, before any method runs.
  3. IllegalStateException, because the state (balance) does not allow it.
  4. So a refused request leaves the object unchanged and still valid.
  5. It tells whoever reads the failure which rule was broken and with what value.

References

  • Oracle, IllegalArgumentException (Java SE 8 API). It supports that this is an unchecked exception thrown to indicate a method was passed an illegal or inappropriate argument.
  • Oracle, IllegalStateException (Java SE 21 API). It supports that this exception signals a method was called at an illegal or inappropriate time, and that it is unchecked.
  • The stack traces and outputs were produced by compiling and running the code with JDK 21. The statement that comparisons with NaN are false is standard Java behaviour that was not checked against a fetched page in this lesson.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.