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
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.
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
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.
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
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.
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());
}
}
}
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());
}
}
}
The exception is constructed with the message "ISBN 404".
getMessage() returns exactly the string passed to the constructor.
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?
A negative loan count means a bug in validation or calculation, not an external condition the caller can recover from. RuntimeException is the right choice for programming errors.
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");
}
}
}
The constructor runs first, printing 'open'.
The body prints 'work', then throws.
The resource is still closed before the catch is reached.
The catch then prints 'caught'.
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());
}
}
}
The ServiceException carries the message 'high-level'.
Its cause is the original RuntimeException with message 'low-level'.
getCause().getMessage() returns 'low-level'.
What is the danger of an empty catch block like catch (IOException e) { }?
An empty catch swallows the exception. The program continues as if nothing failed, which usually corrupts state or misleads the user. At minimum, log or rethrow.
Recap
- Custom exceptions carry domain meaning: the type tells you which business rule broke.
- Extend
Exceptionfor checked failures the caller can recover from; extendRuntimeExceptionfor 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.