انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-021-L06

عرض عملي: حماية ثابت الرصيد (balance invariant)

  • الدرس 6 من 6
  • 11 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

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 Money class for amounts and balances.
  • Check a finished class against the "no invalid state from outside" rule.
  • Write a short test main that tries to break your own class.

Written Explanation

The plan

StepChangeEvidence
1Start from the unprotected Accountacc.balance = -1000; works
2Make all fields privatethe same line no longer compiles
3Add read-only accessorscallers can look, not touch
4Enforce rules in constructor, deposit, withdrawbad calls throw exceptions
5Planned mistake: add setBalance; then remove itthe rule breaks, then is restored
6Introduce immutable Money for amountsno double surprises, no negative money

Step 1: the starting point

java
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);
    }
}
text
-1000.0

Every 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:

text
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 errors

Both 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.

java
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:

java
void setBalance(double balance) {   // generated by reflex
    this.balance = balance;
}

Now the test main:

java
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());
text
Sara 120.0
-1000.0

Everything 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:

java
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.

java
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);
    }
}
java
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));
text
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; owner and number are final.
  • 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?

أظهر الحل 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?

أظهر الحل 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?

أظهر الحل 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):

java
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; }
}
أظهر الحل 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.

أظهر الحل B2

B2.

java
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.

أظهر الحل 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.

أظهر الحل C1

C1.

java
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.

أظهر الحل 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 Money removes 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

  1. Which step shows that the fields are no longer reachable?
  2. What does the planned mistake demonstrate?
  3. Why can balance() safely return a Money?
  4. Why are there no setters on the finished Account?
  5. What is the acceptance rule for the lab?
أظهر الإجابات
  1. Step 2: making the fields private produces a compile-time error for outside access.
  2. That one generated setter bypasses every check and reopens the invariant.
  3. Money is immutable, so the caller cannot change the account through it.
  4. Every legitimate change is covered by deposit and withdraw, which apply the rules.
  5. No way to reach an invalid state from outside the class.

References

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.