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

عرض عملي: InsufficientFundsException من البداية إلى النهاية

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

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

Lesson Overview

This is a hands-on demo. Over the last five lessons you learned the pieces: throwing, declaring, catching, and writing your own exception. Now you will use them together, in one small program, in the order a working developer does: define the exception, throw it from Account.withdraw, meet the compiler's complaint when a caller does nothing about it, and handle it so the program tells the user what happened and carries on.

This prepares the lab, Bank with rules, where you build the same thing yourself. Follow along in your own project and type the code rather than pasting it. The editor and project steps depend on your setup (IntelliJ IDEA, another IDE, or the command line), so we describe them generally and give exact commands only for the plain javac route.

Why this matters: this pattern, a rule enforced in a domain class, reported through a named exception, and handled at the edge, is the same shape as a real banking or ordering service, only smaller.

This lesson builds on lessons 3 to 5 of this module, on the Account class from the object-oriented modules (BEG-020 to BEG-022), and on encapsulation (BEG-021).

Learning Objectives

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

  • Create a checked business exception with data and a helpful message.
  • Enforce a business rule in Account.withdraw using that exception, and declare it with throws.
  • Explain the compiler error you see when the exception is not handled.
  • Handle it in main with a narrow catch and a message the user can act on.
  • Confirm that a refused withdrawal leaves the balance unchanged.
  • Separate "bug" exceptions (IllegalArgumentException) from "business" exceptions.

Written Explanation

The plan

We build four small pieces, in this order:

  1. InsufficientFundsException: the named problem, with data.
  2. Account: enforces the rule.
  3. A first main that forgets to handle it, so you see the compiler stop you.
  4. A second main that handles it properly.

Keep one rule in mind throughout: checks come before state changes. A withdrawal that is refused must leave the balance exactly as it was.

Step 1: the exception

Create InsufficientFundsException.java in its own file. It extends Exception, so it is checked: a refused withdrawal is a normal, recoverable situation, and the compiler should make every caller decide what to do (lesson 5).

java
public class InsufficientFundsException extends Exception {
    private final double requested;
    private final double available;

    public InsufficientFundsException(double requested, double available) {
        super(String.format("Cannot withdraw %.2f: balance is only %.2f", requested, available));
        this.requested = requested;
        this.available = available;
    }

    public double getShortfall() {
        return requested - available;
    }
}

The constructor builds the message from the data, so every place that throws it produces the same helpful text. There is no cause constructor: nothing lower-level triggers this exception, it is raised by our own rule check. (If it ever wraps another exception, add the (String, Throwable) form.) One detail: String.format with %.2f may print a comma instead of a decimal point on a computer set to some regional settings; the numbers stay the same.

Step 2: the rule in Account

java
public class Account {
    private final String owner;
    private double balance;

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

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

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Withdrawal must be positive, was " + amount);
        }
        if (amount > balance) {
            throw new InsufficientFundsException(amount, balance);
        }
        balance -= amount;
    }

    public double getBalance() {
        return balance;
    }

    public String getOwner() {
        return owner;
    }
}

Two kinds of failure live here, and they are deliberately different:

  • A zero or negative amount is a bug in the caller: IllegalArgumentException, unchecked, no throws.
  • Asking for more than the balance is a business situation: InsufficientFundsException, checked, declared with throws.

Both checks are placed before the line balance -= amount;, so no failure can leave the object half-changed.

Step 3: the planned mistake

Now write a main that calls withdraw and does nothing about the exception:

java
public class Bad {
    public static void main(String[] args) {
        Account account = new Account("Sara", 100);
        account.withdraw(500);
        System.out.println("Balance: " + account.getBalance());
    }
}

Compile it. Whether you use an IDE or the command line, you see the same message. From the command line (javac Bad.java, with the other .java files in the same folder):

text
Bad.java:4: error: unreported exception InsufficientFundsException; must be caught or declared to be thrown
        account.withdraw(500);
                        ^
1 error

An IDE usually underlines the call in red and offers quick fixes. Read the message, as it tells you exactly what the language wants: caught or declared. The compiler is doing its job: the method's throws clause is a promise, and every caller has to honor it.

There are two ways to make it compile. Declaring throws InsufficientFundsException on main would compile but only pushes the problem to the JVM, which would end the program with a trace. The right fix here is to catch it, because main is where we talk to the user.

Step 4: handle it

java
public class Main {
    public static void main(String[] args) {
        Account account = new Account("Sara", 100);
        double[] requests = {30, 500, 70, 1};
        for (double request : requests) {
            try {
                account.withdraw(request);
                System.out.printf("Withdrew %.2f. Balance: %.2f%n", request, account.getBalance());
            } catch (InsufficientFundsException e) {
                System.out.println("Refused: " + e.getMessage());
                System.out.printf("You need %.2f more. Balance unchanged: %.2f%n",
                    e.getShortfall(), account.getBalance());
            }
        }
    }
}

Real output:

text
Withdrew 30.00. Balance: 70.00
Refused: Cannot withdraw 500.00: balance is only 70.00
You need 430.00 more. Balance unchanged: 70.00
Withdrew 70.00. Balance: 0.00
Refused: Cannot withdraw 1.00: balance is only 0.00
You need 1.00 more. Balance unchanged: 0.00

Examples

Example 1: the exception class on its own (simple)

Compile InsufficientFundsException.java and create one in a throwaway main: new InsufficientFundsException(500, 70). Its getMessage() returns Cannot withdraw 500.00: balance is only 70.00 and getShortfall() returns 430.0. Testing a small piece on its own, before the rest exists, is a good habit. If the message looks wrong here, you find out in ten seconds instead of in the middle of the bank.

Example 2: the compiler stops you (practical)

The Bad class from Step 3 failed with unreported exception InsufficientFundsException; must be caught or declared to be thrown. Fix it three ways and see the difference:

  1. Catch it in main, as in Step 4. The program survives and the user gets a message.
  2. Declare it on main with throws InsufficientFundsException. It compiles, but the exception now travels out of main, and the JVM prints a stack trace and ends the program.
  3. Change the exception to unchecked, by extending RuntimeException. The error disappears, but so does the compiler's reminder. This is the shortcut lesson 2 warned about: choose the type by who can recover, not by what makes the build pass.

For this rule, option 1 is right.

Example 3: the whole run (realistic)

The output of Step 4 shows a request sequence of 30, 500, 70, and 1 against a balance of 100:

  • 30 is within the balance, so it succeeds and the balance becomes 70.
  • 500 is too much: the exception is thrown before the balance changes, the catch prints the message and the shortfall, and the loop goes on.
  • 70 succeeds and empties the account. The earlier refusal did not damage anything.
  • 1 is refused with a shortfall of 1.00.

Two ideas to notice. First, the loop survived: a refused request is one iteration's problem, not the program's. Second, the catch block uses the exception's data (getShortfall()) rather than slicing the message string.

You can try the unchecked failure too. Add account.withdraw(-5); to the loop's input array and run: you get an IllegalArgumentException that nothing catches, which ends the program with a trace, because a negative amount is a bug the caller should fix and not a normal outcome. In a real application, the top level would still catch it and report an error.

Backend Insight

This structure is the skeleton of a banking service. The domain class (Account) owns the rule. The rule violation has a name (InsufficientFundsException). And the code at the edge (here main, later a web controller) catches the named exception and converts it to a user-facing answer, such as a "payment declined" response, without any chance of leaving a half-finished withdrawal behind.

When money is involved, "check first, change after" is not just tidiness. Later you will learn about transactions, which make a group of changes succeed or fail together. The habit you practiced here is the single-object version of the same principle, and the career link for this module is rollback rules: frameworks decide whether to undo work based on the kind of exception thrown, which is why choosing checked or unchecked carefully matters.

Common Mistakes

Mistake 1: Changing state before checking

If balance -= amount; came before the check, a refused withdrawal would still reduce the balance. Always validate first.

Mistake 2: Catching Exception in main to make it compile

catch (Exception e) would also catch a bug such as a negative amount and report it as a normal refusal. Catch InsufficientFundsException only.

Mistake 3: Parsing the message instead of using the data

Code that cuts numbers out of e.getMessage() breaks the moment the wording changes. Put facts in fields and read them with getters.

Mistake 4: Messages without numbers

"Insufficient funds" tells the user the rule but not the situation. Include the requested amount and the balance (and never an account password or full card number).

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. Why is InsufficientFundsException checked in this demo, while a negative amount throws IllegalArgumentException?

أظهر الحل A1

A1. A refused withdrawal is a normal business outcome the caller can handle, so it is checked and the compiler forces a decision. A negative amount is a mistake in the calling code that should simply be fixed, so it is unchecked.

A2. Which line of withdraw must come last, and why?

أظهر الحل A2

A2. balance -= amount;, the state change, must come after every check so that a failure leaves the balance untouched.

A3. What does the compiler message unreported exception ... must be caught or declared to be thrown ask you to do?

أظهر الحل A3

A3. Either catch the exception with try/catch, or declare it with throws on your own method.

Level B: Application

B1. Add a transfer(Account to, double amount) throws InsufficientFundsException method to Account, which withdraws from this account and deposits into to. Explain why the order of those two calls matters if the withdrawal fails.

أظهر الحل B1

B1.

java
public void transfer(Account to, double amount) throws InsufficientFundsException {
    withdraw(amount);
    to.deposit(amount);
}

Withdraw first: if it fails, the exception leaves immediately and the other account was never touched. Deposit first and fail on the withdrawal, and the money would exist in both accounts.

B2. In the catch block of Main, the program prints e.getShortfall(). Change the code so the user also sees how much they could withdraw. Which field would you expose, and how?

أظهر الحل B2

B2. Expose the available amount through a getter such as getAvailable() in the exception, then print it: System.out.printf("You can withdraw up to %.2f%n", e.getAvailable());. (The account's getBalance() also works in this program, because the same balance is available there.)

B3. The loop in Main gets {30, 500, 70, 1}. Predict, without running, the balance after each step if the starting balance is 100 and the rule is unchanged.

أظهر الحل B3

B3. After 30: 70. After 500: 70 (refused). After 70: 0. After 1: 0 (refused), matching the real output.

Level C: Challenge

C1. Add a unit-style check in main that proves a refused withdrawal leaves the balance unchanged: record the balance, attempt a too-large withdrawal inside a try/catch, and print OK or BROKEN. Run it.

أظهر الحل C1

C1.

java
double before = account.getBalance();
try {
    account.withdraw(before + 1);
} catch (InsufficientFundsException e) {
    System.out.println(account.getBalance() == before ? "OK" : "BROKEN");
}

It prints OK, because the check runs before the subtraction. (This exact snippet was not run; a close variant of the whole demo was.)

C2. A teammate changes InsufficientFundsException to extend RuntimeException. List two consequences for the code you wrote, and say whether you would accept the change.

أظهر الحل C2

C2. Consequences: callers are no longer forced to catch it, so Bad.java would compile and then crash at run time; and the throws clause on withdraw becomes optional documentation. I would not accept it without discussion, because the compiler's reminder protects us from forgetting the case. If the team's convention is unchecked, I would accept it and add a note to the method's documentation.

Mini Challenge

Extend the demo in your own project. Add a daily withdrawal limit: a new checked exception DailyLimitExceededException (with the limit and the attempted total as data) thrown by withdraw after the funds check. Update Main so each of the two exceptions has its own catch block and its own user message. Make sure a refused request never changes the balance or the day's total.

Check your work: the new exception has a precise name, helpful message, and data; both catches are narrow; no catch is empty; and a test run shows the balance unchanged after each refusal.

Summary & Next Lesson

Key takeaways

  • Define a named, checked exception for a business rule, with data and a message built inside the class.
  • Enforce the rule in the domain class, with all checks before any state change, and declare the exception with throws.
  • The compiler enforces the contract: callers must catch or declare. Catch where you can talk to the user.
  • Use the exception's data, not its message text, when the handler needs the numbers.
  • Keep bug-type failures (IllegalArgumentException) separate from business ones.

Next lesson

The final lesson, "Demo: finding a swallowed exception", looks at the opposite of what we built here: a program where an error was caught and thrown away, so nothing is reported, and how to find and fix it.

Self-check

  1. Which exception type does withdraw throw for a negative amount, and which for an overdraw?
  2. Why must the balance be changed last?
  3. What does throws InsufficientFundsException on withdraw oblige callers to do?
  4. Where is the best place in this program to catch the exception?
  5. Why read getShortfall() instead of slicing the message?
أظهر الإجابات
  1. IllegalArgumentException for the negative amount, InsufficientFundsException for the overdraw.
  2. So a refused withdrawal leaves the balance exactly as it was.
  3. Catch it, or declare it themselves.
  4. In main, the code that talks to the user.
  5. The data is exact and does not break when the message wording changes.

References

  • Oracle, The Java Tutorials, The Catch or Specify Requirement. Fetched. Supports that code which can throw a checked exception must catch it or declare it, and that code which does neither does not compile.
  • Oracle, The Java Tutorials, Specifying the Exceptions Thrown by a Method. Fetched. Supports the placement and purpose of the throws clause.
  • All code, compiler output, and program output were produced with JDK 21.0.10 compiled from the command line. The remark about transaction rollback by exception type is general knowledge to be confirmed in a framework's documentation later. The solutions to B1, B2, and C1 were not run.

التطبيق

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

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

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

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