Skip to content

Objects that refuse

50 minutesWeek 3, session 1Needs: What a variable really holds

By the end of this page you can write a class that cannot be put into an invalid state, tell the difference between a caller asking for something reasonable and a caller passing you nonsense, and decide which methods a class should expose rather than exposing all of them.

Your Account is protected in one direction only. Nobody can reach in and change the balance, which is real progress. But the constructor will still accept a negative opening balance and a null owner, and once an object is born wrong it stays wrong for the rest of its life.

The thing that must always be true

Every useful class has one or two statements that must hold from the moment the object exists until it is thrown away. For an account:

The balance is never negative.

That is an invariant. It is not a nice-to-have and it is not checked occasionally. If it is ever false, the object is broken, and every part of the program that trusted it is now wrong in a way nobody will notice until the totals do not add up.

Two things have to happen for an invariant to survive. It must be true when the object is created, and no method may leave it false. That is the whole job of this session.

The constructor as a gate

Right now this compiles and runs:

Account broken = new Account("AC-1001", null, -5000.00);

An account owned by nobody, holding negative money. Nothing stops it, and from here it will be passed around, added to lists, and totalled.

    public Account(String accountNumber, String ownerName, double openingBalance) {
        this.accountNumber = requireText(accountNumber, "Account number");
        this.ownerName = requireText(ownerName, "Owner name");

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

    private String requireText(String value, String label) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(label + " is required");
        }
        return value;
    }

throw stops the constructor immediately. The remaining lines never run, new never gives back a reference, and the object never exists. There is no half-built account to worry about later, because there is no account.

You will meet exceptions properly in week 9, where you learn to catch them. Throwing one is the easy half and you can use it now. IllegalArgumentException is Java's standard way of saying: the value you handed me is not acceptable.

Put the bad value in the message. "Opening balance cannot be negative: -5000.0" tells you what happened. "Invalid input" tells you nothing and you will be the one reading it.

requireText exists because the same check is about to appear twice. One rule, one place, which is the argument from week 1 applied to your own code.

What your first instinct will be, and why it fails

Almost everyone writes this first:

if (openingBalance < 0) {
    System.out.println("Opening balance cannot be negative");
}
this.balance = openingBalance;

Read it again. It complains, and then assigns the bad value anyway. The account is created, the balance is negative, and the only thing that happened is a line of text nobody was watching.

This is worse than no check at all, because it looks like validation. A guard that does not stop anything is a comment with extra steps.

Two kinds of no

Now the interesting part, and the thing that separates a class that merely works from one that is pleasant to use.

A caller with 500 euro asks to withdraw 900. That is a completely normal thing to happen. The bank will not allow it, the customer sees a message, life continues. The account should say no and the program should carry on.

A caller asks to withdraw -50. That is not a customer doing anything. No interface offers a negative withdrawal. Somebody wrote a bug, and the sooner it is loud the better.

Those two deserve different answers.

Three requests to an account and three different outcomesThree rows. In the first, a request to withdraw nine hundred from an account holding five hundred is refused and returns false, which is a normal outcome the caller handles. In the second, a request to withdraw minus fifty throws an exception, because no valid caller would ask that. In the third, an attempt to create an account with a negative balance throws before any object exists.Accountbalance 500.00withdraw(900)falseNormal. The caller decides what to tell the customer.Accountwithdraw(-50)IllegalArgumentExceptionA bug. Stop now, loudly, with the value in the message.no object is creatednew Account(..., -5000)The gate holds. There is no broken account to find later.

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit must be positive: " + amount);
        }
        balance += amount;
    }

    public boolean withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal must be positive: " + amount);
        }
        if (amount > balance) {
            return false;
        }
        balance -= amount;
        return true;
    }

deposit returns nothing now. Last week it returned a boolean, and that boolean was always true unless the caller was broken. A return value that only ever means "you passed me rubbish" is better expressed as an exception, so the method gets simpler and the caller stops writing an if around every deposit.

withdraw keeps its boolean, because refusing a withdrawal is a real outcome that a correct caller must handle.

That is the rule worth carrying: return a value for outcomes the caller should expect, throw for situations the caller should have prevented.

Which doors to build

Encapsulation is not a rule about fields. It is a decision about what your class lets the world do.

    public void setOwnerName(String ownerName) {
        this.ownerName = requireText(ownerName, "Owner name");
    }

This setter is fine. People change their name, the bank has to record it, and the method still enforces the rule. It is a real operation with a guard on it.

There is still no setBalance, and there never will be. Adding it would hand back exactly the power private took away, and the invariant would be gone the same afternoon. The balance moves through deposit and withdraw, or it does not move.

Ask of every public method: is this something the thing genuinely does? An account accepts deposits. An account does not have its balance assigned by a stranger.

A getter and a setter for every field is not encapsulation

You will see classes where all fields are private and every one has getX and setX. Count what that protects. Anybody can still read anything and write anything. It is the same open data with four extra lines per field, and now it is longer.

Every setter you add is a rule you decided not to enforce. Write them when the operation is real, and be suspicious of yourself when you write them by reflex.

The whole Account class so far
public class Account {

    private String accountNumber;
    private String ownerName;
    private double balance;

    public Account(String accountNumber, String ownerName, double openingBalance) {
        this.accountNumber = requireText(accountNumber, "Account number");
        this.ownerName = requireText(ownerName, "Owner name");

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

    public String getAccountNumber() {
        return accountNumber;
    }

    public String getOwnerName() {
        return ownerName;
    }

    public void setOwnerName(String ownerName) {
        this.ownerName = requireText(ownerName, "Owner name");
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit must be positive: " + amount);
        }
        balance += amount;
    }

    public boolean withdraw(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal must be positive: " + amount);
        }
        if (amount > balance) {
            return false;
        }
        balance -= amount;
        return true;
    }

    private String requireText(String value, String label) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(label + " is required");
        }
        return value;
    }

    @Override
    public String toString() {
        return String.format("Account %s (%s): %.2f EUR",
                accountNumber, ownerName, balance);
    }
}

If you remember one thing. private is not about hiding information. It is about making sure you are the only one who can break your own rules.

Check yourself

Try to create a broken account

In main, write each of these on its own and run the program each time.

Account a = new Account("AC-1001", "Ada Lovelace", -100.00);
Account b = new Account("AC-1002", "", 500.00);
Account c = new Account(null, "Alan Turing", 500.00);

Each should stop the program with a message naming the problem. Read each message and check it tells you enough to fix the call without opening Account.java. If one does not, improve it.

Then the important half. Add a valid account and try these two:

Account ada = new Account("AC-1001", "Ada Lovelace", 500.00);

boolean ok = ada.withdraw(900.00);    // what happens?
ada.withdraw(-50.00);                 // what happens?
System.out.println(ada.getBalance());

One of those lines returns. One of them ends the program. Before running, write down which is which and why, in one sentence each. That distinction is the session.

Think about it

  1. The constructor refuses a negative opening balance. But a customer could deposit 500 and then the bank could apply a 600 euro fee. Should the account allow the balance to go below zero in that case? Decide, and then say where in the class your decision would live. Now suppose the bank introduces overdrafts next year.

  2. You were told to throw for a negative amount and return false for insufficient funds. Someone argues both are just failures and both should return false. Make their case as strongly as you can, then say what it would cost.

  3. setOwnerName was allowed and setBalance was not, yet both change a private field through a validated method. What is the actual difference? Try to state it as a test you could apply to a method you have not seen before.

  4. The exception message contains the bad value. That is helpful to a developer. Is there a situation where putting the value in the message would be a bad idea? Think about what else gets stored in a bank account, and about who reads log files.