Module 10 · Capstone: Professional Java Project ⏱ 19 min

Milestone 4: Robust Error Handling

By the end of this lesson you will be able to:
  • Throw domain-specific exceptions that tell callers exactly what broke
  • Use checked and unchecked exceptions deliberately in a layered design
  • Predict finally and close() behaviour when resources are involved

A library system fails in ways generic exceptions cannot describe well. A member tries to borrow a book that does not exist; a borrower exceeds their loan limit; an ISBN string is malformed. If every failure throws IllegalArgumentException or RuntimeException with a text message, the caller must parse that message to decide what to do. That is fragile: reword the message and every handler breaks. Worse, a generic type tells you nothing about which business rule failed, so you cannot route different failures to different recovery paths.

Custom exceptions turn vague failures into precise signals. BookNotFoundException tells you exactly which business rule broke. LoanLimitExceededException tells you the member has too many books. The type itself becomes the signal, so callers catch exactly what they can handle and let everything else propagate. That precision is how production code stays debuggable when things go wrong at 3 a.m. and the logs are all you have.

flowchart TD
  E["Exception"] --> BNE["BookNotFoundException\n(checked)"]
  E --> LLE["LoanLimitExceededException\n(checked)"]
  E --> IIE["InvalidIsbnException\n(unchecked)"]
  style BNE fill:#c2410c,color:#fff
  style LLE fill:#c2410c,color:#fff
  style IIE fill:#b45309,color:#fff
Domain exceptions extend the standard hierarchy. Checked for recoverable conditions, unchecked for programming errors.

Domain exceptions

A custom exception is a class that extends Exception or RuntimeException. The name should end in Exception and describe the business rule that broke. BookNotFoundException is checked because the caller might want to show a friendly message or suggest alternatives. LoanLimitExceededException is checked because the UI can prompt the user to return a book first. InvalidIsbnException is unchecked because a malformed ISBN usually means a programming error: the input should have been validated earlier, before it reached the domain layer.

The constructor forwards a message to super, and a two-argument constructor also accepts a Throwable cause. That cause preserves the original stack trace when you wrap a lower-level failure. A service layer should never leak NullPointerException or ArrayIndexOutOfBoundsException to the user; it should wrap them in domain exceptions that explain what the user actually did wrong, while keeping the original stack trace attached for developers.

Designing the exception

Look at the custom exceptions below. Each name ends in Exception, carries a clear message, and extends the appropriate superclass. The service method declares throws BookNotFoundException, LoanLimitExceededException because both are checked. That declaration is a contract: any caller must handle these failures or pass them upward. The compiler enforces the contract, so a missing catch or throws clause fails the build rather than crashing at runtime. When you design your own APIs, treat the throws clause as documentation that the compiler can verify.

Main.java — custom checked exceptions and a service method that declares them. Real, compiling Java.
public class Main {
    static class BookNotFoundException extends Exception {
        BookNotFoundException(String message) {
            super(message);
        }
    }

    static class LoanLimitExceededException extends Exception {
        LoanLimitExceededException(String message) {
            super(message);
        }
    }

    static class LibraryService {
        static void borrowBook(String isbn, int currentLoans)
                throws BookNotFoundException, LoanLimitExceededException {
            if (!"001".equals(isbn)) {
                throw new BookNotFoundException("No book with ISBN " + isbn);
            }
            if (currentLoans >= 3) {
                throw new LoanLimitExceededException("Limit is 3");
            }
            System.out.println("Loan approved");
        }
    }

    public static void main(String[] args) {
        try {
            LibraryService.borrowBook("002", 1);
        } catch (BookNotFoundException e) {
            System.out.println("Missing: " + e.getMessage());
        } catch (LoanLimitExceededException e) {
            System.out.println("Limit: " + e.getMessage());
        }
    }
}

Checked or unchecked?

The choice matters because it changes what the compiler enforces. Checked exceptions (extending Exception) force callers to handle or declare them. Use them when the failure is external or recoverable: a missing book, a network timeout, a full disk. The caller can show a dialog, retry, or fall back to cached data. Unchecked exceptions (extending RuntimeException) skip the compiler check. Use them for programming errors: a null reference, a bad index, a precondition violation. The right fix is to correct the code, not to catch the exception.

In the library domain, BookNotFoundException is checked because the UI can show a search-again dialog. InvalidIsbnException is unchecked because ISBNs should be validated by the time they reach the service. If you make every domain exception unchecked, callers ignore real failure modes. If you make every one checked, signatures drown in boilerplate that obscures the real API. The rule is simple: checked when the caller can recover, unchecked when the code is wrong.

flowchart TD
  Q{"Can the caller recover?"} -->|"yes"| CH["checked\nextends Exception"]
  Q -->|"no"| UC["unchecked\nextends RuntimeException"]
  CH --> BNE["BookNotFoundException"]
  UC --> IIE["InvalidIsbnException"]
  style CH fill:#15803d,color:#fff
  style UC fill:#b45309,color:#fff
Choose checked when the caller can recover; unchecked when the failure is a programming bug.

Persisting with try-with-resources

A professional application saves its state to disk. When the save operation opens a file, writes data, and closes the file, an exception in the middle can leave the file handle open. try-with-resources removes that risk by declaring the resource in the try header and closing it automatically — success or failure. The class must implement AutoCloseable, which is a single-method interface just like the ones you used in the last milestone.

The resource is opened when the try begins, closed in reverse declaration order when the block ends, and closed even if the body throws. That guarantee is what makes try-with-resources the standard pattern for file I/O, database connections, and any scoped resource in modern Java.

Main.java — a custom AutoCloseable used in try-with-resources. Real, compiling Java.
public class Main {
    static class LibraryStore implements AutoCloseable {
        LibraryStore() {
            System.out.println("store opened");
        }
        void save(String data) {
            System.out.println("saved: " + data);
        }
        public void close() {
            System.out.println("store closed");
        }
    }

    public static void main(String[] args) {
        try (LibraryStore store = new LibraryStore()) {
            store.save("book-001");
        }
        System.out.println("done");
    }
}

How exceptions travel through layers

In a layered application, exceptions climb the call stack until something catches them. A repository throws BookNotFoundException; the service catches it and wraps it in LibraryException with the cause attached; the controller catches that and maps it to an HTTP 404. Each layer translates the failure into language the next layer understands, without losing the original context. The user sees a clear message, the developer sees the full stack trace, and the operations team sees which subsystem failed.

That translation is why the cause constructor matters. If the service threw a fresh LibraryException("book missing") with no cause, the operations team would see the service error but not the repository line that originally failed. With the cause attached, getCause() reveals the full chain. Always wrap with a cause when crossing a layer boundary; never build a new exception from only the message, or you throw away the map to the bug.

flowchart TD
  R["Repository\nthrows BookNotFoundException"] --> S["Service\ncatches and wraps"]
  S --> C["Controller\ncatches LibraryException"]
  C --> U["User sees 404"]
  style S fill:#c2410c,color:#fff
Exceptions propagate up the stack and are wrapped at layer boundaries so no context is lost.

Preserving the cause

When a repository failure reaches the service, the service should not invent a new message and discard the original exception. The code below shows the correct pattern: catch the low-level failure, wrap it in a domain exception with the cause constructor, and throw the wrapper. The handler can then print the business-friendly message and still access the original exception class and message for debugging. This is the standard pattern in every serious Java framework.

Main.java — wrapping a low-level failure in a domain exception while preserving the cause. Real, compiling Java.
public class Main {
    static class LibraryException extends Exception {
        LibraryException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    static void loadData() {
        throw new RuntimeException("corrupted file");
    }

    public static void main(String[] args) {
        try {
            try {
                loadData();
            } catch (RuntimeException e) {
                throw new LibraryException("Failed to load library", e);
            }
        } catch (LibraryException le) {
            System.out.println(le.getMessage());
            System.out.println(le.getCause().getMessage());
        }
    }
}
Exercise

What does this program print? A custom exception carries a domain message. Predict the exact output (one line).

public class Main {
    static class BookNotFoundException extends Exception {
        BookNotFoundException(String message) {
            super(message);
        }
    }

    public static void main(String[] args) {
        try {
            throw new BookNotFoundException("ISBN 404");
        } catch (BookNotFoundException e) {
            System.out.println(e.getMessage());
        }
    }
}
Exercise

A member tries to borrow a book with a negative loan count. This indicates a programming bug earlier in the code. Which exception type is most appropriate?

Exercise

What does this print? The body throws, but the resource still closes. Predict the exact output (four lines).

public class Main {
    static class Store implements AutoCloseable {
        Store() {
            System.out.println("open");
        }
        public void close() {
            System.out.println("close");
        }
    }

    public static void main(String[] args) {
        try (Store s = new Store()) {
            System.out.println("work");
            throw new RuntimeException("boom");
        } catch (RuntimeException e) {
            System.out.println("caught");
        }
    }
}
Exercise

What does this print? A low-level failure is wrapped and rethrown. Predict the exact output (two lines).

public class Main {
    static class ServiceException extends Exception {
        ServiceException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    public static void main(String[] args) {
        try {
            try {
                throw new RuntimeException("low-level");
            } catch (RuntimeException e) {
                throw new ServiceException("high-level", e);
            }
        } catch (ServiceException se) {
            System.out.println(se.getMessage());
            System.out.println(se.getCause().getMessage());
        }
    }
}
Exercise

What is the danger of an empty catch block like catch (IOException e) { }?

Recap

  • Custom exceptions carry domain meaning: the type tells you which business rule broke.
  • Extend Exception for checked failures the caller can recover from; extend RuntimeException for unchecked programming errors.
  • The (String, Throwable) constructor preserves the original stack trace when wrapping failures.
  • try-with-resources guarantees cleanup even when the body throws.
  • Empty catch blocks silently hide failures; always log, recover, or rethrow.

Checkpoint quiz

Why provide a (String, Throwable) constructor in a custom exception?

A try-with-resources body throws an exception. When does the resource's close() run?

Go deeper — technical resources