Hello. The previous lesson focused on streams as a way to express a clean transformation of data. This lesson addresses the other side of robust backend code: what should happen when an operation cannot complete normally.
You will learn to model failures with appropriate Java exception types, decide where an exception should be caught, preserve the original cause and stack trace when adding higher-level context, and use try-with-resources without losing cleanup failures. These are recurring interview topics because they reveal whether someone treats exceptions as part of an API and diagnostic design, rather than merely compiler errors to silence.
Exceptions communicate abnormal outcomes
An exception is an object representing a failure or abnormal condition that interrupts normal control flow. When code throws it, Java looks up the current call stack for a matching catch block. If no code handles it, the exception reaches the top of the thread and Java reports a stack trace.
Not every undesirable outcome warrants an exception. A “customer has no saved address” result may be an ordinary, anticipated business state represented by an empty collection or Optional. An exception is more appropriate when the current operation cannot honor its contract:
- a method receives an invalid argument;
- an object is in an invalid state for an operation;
- a required file, database, or network resource cannot be used;
- a domain invariant is violated, such as attempting to confirm an order that was already cancelled.
The key design question is not “can I throw something here?” It is:
What has failed, which layer has enough context to respond, and what information will the next person diagnosing the failure need?
All throwable objects sit under Throwable.

The main categories have different intended uses:
| Category | Meaning in typical application code | Compiler enforcement |
|---|---|---|
Error | Serious JVM or environment failure, such as OutOfMemoryError; application code normally does not attempt to recover. | Not checked |
| Checked exception | A subclass of Exception that is not a RuntimeException, such as IOException. | Must be caught or declared with throws. |
| Unchecked exception | RuntimeException or one of its subclasses, such as IllegalArgumentException. | No catch or throws declaration required. |
“Checked” and “unchecked” are primarily compiler categories, not a ranking of seriousness. An unchecked exception can be a production incident, and a checked exception can be an ordinary environmental failure that the caller knows how to address.
Watch this segment from Checked and Unchecked Exceptions in Java by Will Tollefson for the language-level distinction and the mechanics of catching or declaring checked exceptions.
Checked and Unchecked Exceptions in Java - Java Programming
The video establishes the compiler rule behind checked exceptions, then contrasts it with RuntimeException. Watch it to make the hierarchy and throws behavior concrete before applying the design rules below.
Watch the distinction for the central compiler rule. Then watch checked exceptions, including the relationship between a matching catch and a throws declaration. Finish with unchecked exceptions to see why RuntimeException subclasses are not compiler-enforced.
Choose a failure type that expresses the contract
Exception types are useful because callers can make decisions based on their meaning. Prefer a standard exception when Java already provides a precise description.
For example, a public method that forbids a null or invalid parameter can fail fast:
public Customer findCustomer(long customerId) {
if (customerId <= 0) {
throw new IllegalArgumentException(
"customerId must be positive: " + customerId
);
}
// Lookup omitted
return null;
}
This says that the caller violated the method’s precondition. Catching IllegalArgumentException simply to proceed normally is usually a mistake; it commonly indicates a calling bug that should be prevented through validation before this point.
A custom exception is justified when its type conveys a domain or application-level distinction that generic Java exceptions cannot. For example, an order-import service may want to expose “import failed” rather than force every caller to understand whether the low-level failure was an IOException, a CSV library exception, or another implementation detail.
public final class OrderImportException extends RuntimeException {
public OrderImportException(String message, Throwable cause) {
super(message, cause);
}
}
Then the service can present a stable, meaningful failure at its own boundary:
public List<Order> importOrders(Path path) {
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.lines()
.map(this::parseOrder)
.toList();
} catch (IOException exception) {
throw new OrderImportException(
"Could not read the order import file: " + path,
exception
);
}
}
This small example does several things correctly:
IOExceptionrepresents the technical failure observed at the file boundary.OrderImportExceptiondescribes what the application operation failed to do.- The original exception becomes the cause, so its exact type, message, and stack trace remain available.
- The higher-level message adds useful context: the failed operation and relevant source.
Do not create a custom exception for every method. A new exception type has a maintenance cost and is most valuable when code needs to distinguish that failure from other failures, or when a package needs a cohesive error model. For many precondition failures, IllegalArgumentException and IllegalStateException already communicate the intended meaning.
When defining custom exceptions, extend:
Exceptionif you deliberately want callers to be forced to acknowledge and handle or declare the failure;RuntimeExceptionif the failure should propagate without compiler-enforced declarations.
In many Spring applications, application and domain exceptions are unchecked. This avoids infecting every intermediate method signature with implementation-specific checked exceptions, while allowing handling at an appropriate boundary. It is a convention, not an excuse to throw vague exceptions: the type, message, and cause must still be intentional.
Do not create application exceptions by extending Throwable or Error. Error is reserved for grave system-level conditions, and code that catches Throwable risks attempting to continue after failures such as memory exhaustion.
Watch the focused portion of Java Custom Exceptions Tutorial by Coding with John. It demonstrates the constructors that make a custom exception diagnostically useful, particularly the constructor that accepts a message and a cause.
Java Custom Exceptions Tutorial - It's Way Easier Than You Think
This segment shows why a custom exception needs more than an empty class: it should be able to carry a meaningful message and the underlying throwable that caused it.
Watch useful constructors for message, cause, and message-plus-cause constructors. Then watch superclass choice for the practical reason to extend an appropriate existing exception class rather than Throwable or Error.
Catch at the layer that can do something useful
A catch block should have a clear responsibility. There are four common legitimate reasons to catch an exception:
- Recover with a valid, well-defined alternative.
- Translate a lower-level exception into one meaningful at the current abstraction boundary.
- Add context and rethrow while preserving the cause.
- Perform final handling at an application boundary, such as recording the failure and producing an appropriate response.
Merely catching an exception does not make a system resilient. This is a dangerous pattern:
try {
paymentGateway.charge(order);
} catch (Exception exception) {
// Ignore it so the application keeps running.
}
markOrderPaid(order.id());
The code has converted an unknown payment result into a false claim that the order was paid. Worse, the evidence needed to investigate the actual failure has been discarded.
Another common mistake is wrapping an exception but losing its cause:
try {
return Files.readString(path);
} catch (IOException exception) {
throw new OrderImportException("Could not read order data", null);
}
Even if a new exception has a useful message, passing null as the cause destroys the connection to the real failure: perhaps a missing file, an access-denied error, or an I/O failure. The correct version passes the caught exception:
catch (IOException exception) {
throw new OrderImportException(
"Could not read order data from " + path,
exception
);
}
A cause chain lets a reader move from the application-level conclusion to the technical root cause. Conceptually, the chain might say:
OrderImportException: Could not read order data from /imports/orders.csv
Caused by: AccessDeniedException: /imports/orders.csv
The outer exception answers what the application was trying to do. The cause answers why the lower-level operation failed.
Avoid broad catches in ordinary business code
This is also a warning against casually writing:
catch (Exception exception) {
throw new OrderImportException("Import failed", exception);
}
A broad catch can accidentally relabel programming defects—such as NullPointerException or IndexOutOfBoundsException—as routine import failures. That makes defects harder to distinguish from genuine expected infrastructure failures.
Catch the narrowest type that you can meaningfully handle or translate:
catch (IOException exception) {
// The service owns the translation from file access failure
// to an order-import failure.
}
There are limited boundary-level cases where catching a broad exception is appropriate, such as a top-level framework boundary that must prevent an unhandled request failure from terminating processing. Even there, the exception should be logged or reported with the original throwable and should not be silently converted into a success.
Add context without leaking secrets
An exception message should add diagnostics that the lower-level exception could not know:
- operation: “Could not import orders”
- stable identifier:
orderId=3812 - safe external source name or path
- relevant state: “order was already cancelled”
Do not place passwords, API tokens, authentication headers, full payment details, or sensitive personal data in exception messages. Exception messages often reach logs, tracing systems, and incident reports.
A practical logging rule is to log a failure with its exception object at a deliberate handling boundary. Logging only exception.getMessage() often loses the stack trace and cause chain. Logging at every layer and rethrowing creates duplicate, noisy records of the same incident. Add context while propagating; log once where the failure is finally handled.
Checked exceptions and throws: propagation is not handling
A method that can throw a checked exception must either catch it or declare it:
public String loadTemplate(Path path) throws IOException {
return Files.readString(path);
}
The throws IOException clause tells callers: “This method does not resolve this I/O concern; you must handle or further propagate it.”
That is appropriate when the method is a low-level utility and the caller may have a better recovery policy. But adding throws Exception to many service methods is usually poor API design: it hides the actual failure categories and pushes responsibility upward without adding meaning.
A useful interview explanation is:
I catch an exception at the layer that can recover, translate it into a meaningful abstraction, or make the final handling decision. A
throwsdeclaration is not handling; it transfers responsibility to the caller. When I translate an exception, I preserve the original cause so that diagnosis still reaches the real source of failure.
For a custom checked exception, the compiler imposes the same rule:
public void validateImport(Path path) throws InvalidImportException {
// ...
}
For a RuntimeException subclass, callers may still choose to catch it, but Java does not require them to declare or catch it. Do not add throws RuntimeException declarations just to be explicit; they rarely communicate a useful contract.
Cleanup must not erase the primary failure
Files, database connections, sockets, streams, and many framework resources must be closed. Older Java code commonly used finally:
BufferedReader reader = null;
try {
reader = Files.newBufferedReader(path);
return reader.readLine();
} finally {
if (reader != null) {
reader.close();
}
}
The problem is subtle: if reading fails and then close() also fails, a failure thrown by finally can replace the original read failure. The diagnostic information about the primary fault is lost.
Use try-with-resources instead:
public String readFirstLine(Path path) {
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.readLine();
} catch (IOException exception) {
throw new OrderImportException(
"Could not read the first line of " + path,
exception
);
}
}
Any AutoCloseable resource declared within the parentheses is closed automatically after the block ends, whether the body succeeds or fails. This is the standard choice for resource-owning Java code.
The diagnostic behavior is especially valuable:
- If the body succeeds but
close()fails, the close failure is propagated. - If the body fails and
close()also fails, the body failure remains the primary exception. - The close failure is attached as a suppressed exception.
- With multiple resources, Java closes them in reverse order of declaration.
Read the Dev.java material Catching and Handling Exceptions for the failure mode of finally, the try-with-resources solution, and how suppressed exceptions preserve both diagnostic signals.
Catching and Handling Exceptions - Dev.java
Read Dev.java’s explanation of cleanup and try-with-resources. It is particularly useful for interview answers because it explains not only that resources are closed automatically, but why try-with-resources is safer for diagnosing multiple failures.
In “The Finally Block,” read the explanation of why cleanup is needed and then the subsection “Exceptions Thrown from a Finally Block.” Next, in “The Try-with-resources Statement” under “Closing Resources and Handling Exceptions,” focus on how an AutoCloseable resource is declared and closed, and why the primary exception is retained. Finally, read “Suppressed Exceptions.” Focus on the suppression rule: a failure during cleanup is retained instead of overwriting the failure from the operation itself.
When you print or log an exception object using normal exception-aware logging, Java’s stack trace output normally includes its cause chain and suppressed exceptions. If you need to inspect suppressed failures programmatically, use:
for (Throwable suppressed : exception.getSuppressed()) {
// Inspect or record a cleanup failure.
}
Usually, you do not need this loop in normal application code. Its purpose is to show that cleanup information remains available rather than being discarded.
A compact failure-design checklist
When writing or reviewing Java backend code, ask these questions:
| Question | Good direction |
|---|---|
| Is this actually exceptional? | Use an ordinary return value for normal absence or expected branching. |
| Does Java already have a suitable type? | Prefer a precise built-in exception such as IllegalArgumentException before creating a custom type. |
| Should callers distinguish this failure? | Use a custom type when the distinction is part of your API or domain model. |
| Where should it be caught? | At the layer that can recover, translate, add context, or finally handle it. |
| Is the root cause retained? | Use new SomeException(message, cause), not a message-only wrapper. |
| Can cleanup hide the initial failure? | Use try-with-resources for AutoCloseable resources. |
| Will diagnostics be useful and safe? | Preserve the throwable, add safe context, and do not log secrets. |
Explain it in an interview
A strong answer connects the hierarchy, design choice, and diagnostic behavior:
I use exceptions to represent failures that prevent an operation from completing its contract, not as normal branching. Checked exceptions are
Exceptionsubclasses other thanRuntimeException, so the compiler requires callers to catch or declare them. Unchecked exceptions areRuntimeExceptionsubclasses and do not have that compiler requirement.I catch an exception only where I can recover, translate it at an abstraction boundary, add useful context, or perform final handling. For example, a file import service can catch an
IOExceptionand throw anOrderImportException, but it must pass theIOExceptionas the cause. That preserves the original type and stack trace while giving callers an application-level failure type.For files, connections, and streams, I use try-with-resources. It closes resources automatically and, if both the main operation and cleanup fail, Java retains the operation failure and attaches the cleanup failure as suppressed rather than losing it.
Key takeaways
Throwabledivides broadly intoErrorandException; ordinary application failures should use exception types, not customErrororThrowablesubclasses.- Checked versus unchecked is a compiler-enforcement distinction, not a measure of severity.
- Catch exceptions only to recover, translate, add context, or finally handle them.
- Preserve a lower-level failure with a cause-aware constructor:
new ExceptionType(message, cause). - Use a custom exception when it models a meaningful application or domain failure, not merely to give every method its own exception class.
- Do not suppress failures through empty catch blocks, broad catches, message-only logging, or wrappers that discard the cause.
- Prefer try-with-resources for
AutoCloseableresources; it preserves the primary failure and records cleanup failures as suppressed exceptions.
Next, you will move from Java language features into build tooling: how Maven or Gradle resolves dependencies and packages an application into a runnable artifact.
Can't find a good explanation? Sign up and we'll make it for you
Sign up