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

الاستثناءات المخصصة

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

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

Lesson Overview

Java's built-in exceptions cover technical problems well: a bad index, a null, a missing file. But your program also has rules of its own. "You cannot withdraw more than you have." "This seat is already booked." "That coupon has expired." None of these is a technical failure, and none of the standard names, IllegalStateException for instance, says what actually happened.

A custom exception is a class you write to give a business problem its own name. In the fire-alarm picture, it is a dedicated alarm: instead of a general bell, a specific light that reads "Kitchen". Anyone who sees it knows what it is and which plan applies.

Why this matters: readable names make code and logs self-explanatory (InsufficientFundsException says more than IllegalStateException: balance), and callers can catch exactly that case and nothing else. It is also the class your bank lab throws.

This lesson builds on inheritance and constructors with super (BEG-022), on the hierarchy (lesson 2), and on throwing and declaring (lesson 4).

Learning Objectives

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

  • Write a custom checked exception class that extends Exception.
  • Write one that extends RuntimeException, and explain when to choose each.
  • Give it the two standard constructors: one with a message, one with a message and a cause.
  • Add useful data (fields and getters) to an exception.
  • Wrap a lower-level exception and keep it as the cause.
  • Read the "Caused by" section of a stack trace.

Written Explanation

The smallest custom exception

A custom exception is an ordinary class that extends an existing exception class. The minimum is a class and a constructor that passes the message up to the parent:

java
class InvalidPinException extends Exception {
    InvalidPinException(String message) {
        super(message);
    }
}

The super(message) call is the constructor chaining you learned in BEG-022: the parent class (Exception, ultimately Throwable) stores the message and fills in the stack trace. That is why getMessage() works on your class without extra code.

Name the class after the problem, and end the name with Exception: InsufficientFundsException, InvalidPinException. Prefer names that tell the reader what went wrong, not where it happened.

Checked or unchecked?

The parent you extend decides the group (lesson 2):

  • extends Exception: checked. Every caller must catch or declare it. Use it for a business situation that is a normal part of the method's contract and that callers can reasonably handle: insufficient funds, an invalid PIN.
  • extends RuntimeException: unchecked. Callers are not forced. Use it for mistakes in the program, or in code where forcing everyone to declare it would only add noise.

This module follows the traditional guideline and uses checked exceptions for recoverable business rules. As lesson 2 said, many teams and frameworks prefer unchecked exceptions everywhere; follow the convention of the code base you work in.

The two standard constructors

Pass both a message-only and a message-plus-cause constructor. The Java API documentation for Throwable describes this as the convention: subclasses provide a no-argument constructor, a String constructor, and where a cause is likely, a Throwable and a (String, Throwable) constructor. Two are enough for most of your classes:

java
class InvalidQuantityException extends Exception {
    InvalidQuantityException(String message) {
        super(message);
    }

    InvalidQuantityException(String message, Throwable cause) {
        super(message, cause);
    }
}

In real projects each exception is in its own file; here we put classes in one file only to keep the examples short.

What is a cause, and why keep it?

Sometimes your code catches a low-level exception and wants to throw a higher-level one in its place. Parsing a quantity fails with NumberFormatException, but the layer above should see a meaningful InvalidQuantityException. This is called wrapping (or translating) an exception. The Throwable documentation names two reasons: a higher layer can hide the implementation details of a lower one, and a method may not be allowed to throw the low-level type directly.

When you wrap, pass the original as the cause:

java
} catch (NumberFormatException e) {
    throw new InvalidQuantityException("Quantity must be a whole number, got '" + text + "'", e);
}

The cause is the exception that led to this one. It travels with the new exception and getCause() returns it. If you throw the new exception without it, the original is gone: you lose the exact message, the line where it began, and the call chain. This is the third common mistake of the module ("losing the original cause"), and it makes the real problem much harder to find.

Adding data

An exception can carry facts in fields, so a handler can act on them instead of parsing the message text:

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

    InsufficientFundsException(double requested, double available) {
        super("Cannot withdraw " + requested + ": only " + available + " available");
        this.requested = requested;
        this.available = available;
    }

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

The class builds the message itself from the data, so every throw site produces a consistent message. Fields are private final and exposed through getters, just like any well-encapsulated class (BEG-021). Add data only when a caller will use it; do not turn exceptions into large objects.

Reading "Caused by"

When a trace includes a cause, it is printed in a second section that starts with Caused by:. Read each section the same way as before, starting with the top line. The cause usually holds the root problem. The Throwable documentation says frames shared with the enclosing exception are abbreviated as ... n more. Example 2 shows a real one.

Examples

Example 1: a minimal business exception (simple)

java
class InvalidPinException extends Exception {
    InvalidPinException(String message) {
        super(message);
    }
}

public class Pin {
    static void checkPin(String pin) throws InvalidPinException {
        if (pin.length() != 4) {
            throw new InvalidPinException("PIN must have 4 digits, had " + pin.length());
        }
    }

    public static void main(String[] args) {
        try {
            checkPin("1234");
            System.out.println("PIN accepted");
            checkPin("12");
            System.out.println("not reached");
        } catch (InvalidPinException e) {
            System.out.println("Rejected: " + e.getMessage());
        }
    }
}

Real output:

text
PIN accepted
Rejected: PIN must have 4 digits, had 2

The class is three lines. Because it extends Exception, checkPin must declare it, and main must catch it. The message reports the length without ever printing the PIN itself, which is the right instinct for a secret.

Example 2: wrapping and keeping the cause (practical)

java
class InvalidQuantityException extends Exception {
    InvalidQuantityException(String message) {
        super(message);
    }

    InvalidQuantityException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class Qty {
    static int parseQuantity(String text) throws InvalidQuantityException {
        try {
            return Integer.parseInt(text.trim());
        } catch (NumberFormatException e) {
            throw new InvalidQuantityException("Quantity must be a whole number, got '" + text + "'", e);
        }
    }

    static int parseLosingCause(String text) throws InvalidQuantityException {
        try {
            return Integer.parseInt(text.trim());
        } catch (NumberFormatException e) {
            throw new InvalidQuantityException("Quantity must be a whole number, got '" + text + "'");
        }
    }

    public static void main(String[] args) throws InvalidQuantityException {
        try {
            parseQuantity("3 pens");
        } catch (InvalidQuantityException e) {
            System.out.println("Message: " + e.getMessage());
            System.out.println("Cause:   " + e.getCause());
        }
        try {
            parseLosingCause("3 pens");
        } catch (InvalidQuantityException e) {
            System.out.println("Cause without passing it: " + e.getCause());
        }
        parseQuantity("3 pens");
    }
}

Real output:

text
Message: Quantity must be a whole number, got '3 pens'
Cause:   java.lang.NumberFormatException: For input string: "3 pens"
Cause without passing it: null
Exception in thread "main" InvalidQuantityException: Quantity must be a whole number, got '3 pens'
	at Qty.parseQuantity(Qty.java:16)
	at Qty.main(Qty.java:40)
Caused by: java.lang.NumberFormatException: For input string: "3 pens"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Qty.parseQuantity(Qty.java:14)
	... 1 more

The first block keeps the cause, so getCause() returns the NumberFormatException. The second block throws the same message but without the cause, and getCause() returns null: the original is lost. The last line of main lets the exception go uncaught on purpose (hence throws on main) to show the full trace: the top section is our exception, and Caused by: shows the original failure, including that it began inside Integer.parseInt. (The line numbers inside java.base depend on the JDK version.)

Example 3: an exception with data (realistic)

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

    InsufficientFundsException(double requested, double available) {
        super("Cannot withdraw " + requested + ": only " + available + " available");
        this.requested = requested;
        this.available = available;
    }

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

public class Funds {
    static void check(double balance, double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException(amount, balance);
        }
    }

    public static void main(String[] args) {
        try {
            check(70, 500);
        } catch (InsufficientFundsException e) {
            System.out.println(e.getMessage());
            System.out.println("You are short by " + e.getShortfall());
        }
    }
}

Real output:

text
Cannot withdraw 500.0: only 70.0 available
You are short by 430.0

The exception builds a consistent message from its data, and the handler can use getShortfall() without parsing text. This is the exception your bank lab will use. In lesson 7 we wire it into the real Account.withdraw.

Backend Insight

Custom exceptions are the vocabulary of a backend's business layer. A service method that throws InsufficientFundsException or OrderAlreadyShippedException says what happened in the language of the domain. The code at the top of the application can then catch each one and turn it into the right answer for the user, without ever looking at message text.

Wrapping is how layers stay independent. A repository that fails with a database-specific exception wraps it in something like DataAccessException, so the service above does not depend on one database product. Frameworks you will meet later do exactly this, and the cause chain is what lets an engineer still find the original failure in a log. (We mention this pattern as common practice; the details belong to the later modules.)

Common Mistakes

Mistake 1: Losing the cause

  • Incorrect: throw new InvalidQuantityException("bad quantity"); inside a catch (NumberFormatException e).
  • Correct: throw new InvalidQuantityException("bad quantity", e);

Without the cause, the real problem and the original trace are discarded.

Mistake 2: Extending Exception or Throwable by default, unthinkingly

Picking the parent decides who is forced to handle your exception. Choose deliberately between Exception and RuntimeException using the guideline above. Do not extend Throwable or Error for ordinary business rules.

Mistake 3: Creating a class for every little thing

A new exception class is worth it when callers will catch it separately or when the name adds real meaning. If a built-in such as IllegalArgumentException says it well, use that.

Mistake 4: Vague names and empty messages

BankException or MyException explains nothing. Name the exact problem, and always build a message with the relevant values.

Mistake 5: Exposing mutable data

A public, mutable field on an exception lets any handler change what the original raiser reported. Use private final fields with getters.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. What does super(message) do inside a custom exception's constructor?

أظهر الحل A1

A1. It calls the parent constructor, handing the message to Exception/Throwable, which stores it and records the stack trace, so getMessage() returns it.

A2. Which parent class would you extend for a checked exception, and which for an unchecked one?

أظهر الحل A2

A2. Exception for checked, RuntimeException for unchecked.

A3. What does getCause() return when no cause was passed?

أظهر الحل A3

A3. null.

Level B: Application

B1. Write SeatTakenException (checked) with a message constructor, and a method static void book(boolean seatFree) throws SeatTakenException that throws it with a helpful message when the seat is not free.

أظهر الحل B1

B1.

java
class SeatTakenException extends Exception {
    SeatTakenException(String message) {
        super(message);
    }
}

static void book(boolean seatFree) throws SeatTakenException {
    if (!seatFree) {
        throw new SeatTakenException("That seat is already booked; please choose another");
    }
}

B2. The code below loses the cause. Fix it, and say what extra information the stack trace now contains.

java
try {
    qty = Integer.parseInt(text);
} catch (NumberFormatException e) {
    throw new InvalidQuantityException("bad quantity: " + text);
}
أظهر الحل B2

B2. Add the cause: throw new InvalidQuantityException("bad quantity: " + text, e);, with the matching constructor in the class. The trace now has a Caused by: java.lang.NumberFormatException: ... section showing the original failure and the line inside Integer.parseInt.

B3. A teammate wrote class BankException extends Exception { } and uses it for everything. Give two problems.

أظهر الحل B3

B3. The name says nothing about the problem, so callers cannot catch one case separately and logs are unhelpful. It also has no constructor taking a message or cause (only the default one), so messages cannot be set and causes are lost.

Level C: Challenge

C1. Write CouponExpiredException that stores the coupon code and the expiry date text, builds its own message from them, and has a getter for the code. Write a main that throws and catches it, printing the message and the code.

أظهر الحل C1

C1.

java
class CouponExpiredException extends Exception {
    private final String code;

    CouponExpiredException(String code, String expiry) {
        super("Coupon " + code + " expired on " + expiry);
        this.code = code;
    }

    String getCode() { return code; }
}

In main: try { throw new CouponExpiredException("SAVE10", "2025-12-31"); } catch (CouponExpiredException e) { System.out.println(e.getMessage() + " / " + e.getCode()); } prints Coupon SAVE10 expired on 2025-12-31 / SAVE10.

C2. Decide whether InsufficientFundsException should be checked or unchecked for a banking class used by many teams. Argue both sides in four sentences and pick one.

أظهر الحل C2

C2. For checked: every caller must decide what to do, so no one forgets the failure of a withdrawal, which is part of normal banking. For unchecked: many callers may not be able to act meaningfully, and checked exceptions spread throws clauses through every layer. Either defensible choice should be consistent. For this module, we pick checked, since an overdraw is a normal, recoverable business outcome.

Mini Challenge

Design a small exception family for a library system. Write BookNotAvailableException (with the title and a getter) and LoanLimitExceededException (with the member name and the limit), both extending Exception and both with the message-only and message-plus-cause forms where it makes sense. Write a Library.lend(...) method that throws each under the right condition and a main that catches them separately, with a different user-facing message for each. Finally, write one method that catches an IllegalArgumentException and rethrows it wrapped in LoanLimitExceededException, and check that getCause() returns the original.

Check your work: each exception has a precise name, messages include the relevant values, the cause is preserved, and no catch block is empty.

Summary & Next Lesson

Key takeaways

  • A custom exception is a class that extends Exception (checked) or RuntimeException (unchecked), named after the problem and ending with Exception.
  • Give it a message constructor and a message-plus-cause constructor.
  • When you catch one exception and throw another, pass the original as the cause so the root problem is not lost.
  • Add fields and getters only when callers will use them; build the message inside the class for consistency.
  • A trace with Caused by: shows the chain; the cause often holds the root problem.
  • Create a new class only when the name adds meaning or callers will catch it separately.

Next lesson

You can now raise and handle your own exceptions. The next lesson, "try-with-resources", deals with a related problem: resources such as files that must be closed whatever happens, and the cleaner Java syntax that does it for you.

Self-check

  1. Which parent class makes a custom exception checked?
  2. Which two constructors should most custom exceptions offer?
  3. What happens to the original exception if you do not pass it as the cause?
  4. Where in a trace do you find the root problem when a cause exists?
  5. When is a new exception class worth creating?
أظهر الإجابات
  1. Exception (but not RuntimeException).
  2. One taking a message, and one taking a message and a cause.
  3. It is lost: getCause() returns null, and its message and trace are not available.
  4. Usually in the Caused by: section.
  5. When the name adds meaning, or callers need to catch that case separately.

References

  • Oracle, Java SE 21 API, Throwable. Fetched. Supports the cause mechanism, getCause() returning null when there is none, the reasons for wrapping, the convention of four standard constructors, and the ... n more abbreviation in printed traces.
  • Oracle, The Java Tutorials, The Catch or Specify Requirement. Fetched. Supports the checked/unchecked distinction used in the guideline.
  • All outputs above were produced by compiling and running with JDK 21.0.10. The exercise solutions B1 and C1 were not run. The claim that many frameworks prefer unchecked exceptions is general knowledge, not tied to one fetched source.

التطبيق

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

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

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

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