Error Handling — Exceptions, Result Patterns, and RAII
Prerequisites: records-sealed-pattern-matching
What You'll Learn
- How checked and unchecked exceptions differ and when to reach for each
- Why
try-with-resourcesis Java's answer to Rust's RAII /Droptrait - How to model domain errors as data using the
PaymentResultsealed type hierarchy from Module 05 - Why
Optional<T>is not aResult<T, E>— and what to use instead - Why checked exceptions and lambdas do not mix, and what to do about it
Why This Matters
Error handling is where Java and Rust diverge most sharply in day-to-day code. In Rust, every fallible function returns a Result<T, E> or Option<T>, and the type system forces you to decide what to do with it. In Java, the primary error mechanism is exceptions — objects thrown out of normal control flow and caught somewhere up the call stack.
This difference has real consequences. Exceptions interrupt composition. A lambda inside a Stream pipeline cannot throw a checked exception. A method returning PaymentResult communicates failure in its return type; a method declaring throws PaymentException communicates failure in a side channel that bypasses your type algebra entirely. Understanding when each mechanism applies — and how to bridge them — is essential for writing idiomatic modern Java.
Core Concept
Rust comparison: In Rust, errors are values. A function that can fail returns
Result<T, E>, and the caller decides what to do: unwrap, match, propagate with?, or chain with.map_err(). The type system is involved at every step. Java's exceptions are not values — they are thrown objects that interrupt control flow and propagate up the call stack until caught. These two models have different strengths, and modern Java code uses both, strategically.
Checked Exceptions
A checked exception is declared in a method's throws clause. The compiler enforces that every caller either handles it with a catch block or re-declares it in its own throws clause. This is Java's closest structural analog to returning a Result — the compiler forces acknowledgment.
// Java
public PaymentResult processPayment(Payment payment) throws PaymentException {
if (payment.amount() <= 0) {
throw new PaymentException("Amount must be positive");
}
// ... processing
}
// Caller must handle or propagate:
try {
PaymentResult result = processPayment(payment);
} catch (PaymentException e) {
log.error("Payment failed: {}", e.getMessage());
}
The analogy to Result<PaymentResult, PaymentException> is imperfect. The error type is a side-channel, not part of the return type. You cannot chain processPayment(payment).map(this::logSuccess) the way you can in Rust — the exception breaks the chain entirely.
Use checked exceptions at system boundaries: I/O operations, network calls, database access — anywhere the failure is environmental, recoverable, and outside the caller's control.
Unchecked Exceptions
Unchecked exceptions extend RuntimeException. The compiler does not require you to declare or handle them. They propagate silently up the call stack until caught or the program crashes.
Think of unchecked exceptions as Java's panic!(). They signal programmer errors — conditions that should never happen if the code is correct: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException, IllegalStateException.
// Java
public void validatePaymentId(long id) {
if (id <= 0) {
// Unchecked: not declared, not required to be caught
throw new IllegalArgumentException("Payment ID must be positive, got: " + id);
}
}
The Rust equivalent is:
// Rust equivalent
fn validate_payment_id(id: i64) {
if id <= 0 {
panic!("Payment ID must be positive, got: {}", id);
}
}
Note: The decision between checked and unchecked is about recoverability. If the caller can reasonably recover and correct the situation, use a checked exception or model it as a domain error (see below). If the condition represents a bug that should never occur in correct code, use an unchecked exception.
Why Checked Exceptions Break Functional Composition
This is the moment most Rust engineers get tripped up. Suppose you have a list of payments and want to process each one, where processPayment is declared throws PaymentException:
// Java — this does NOT compile
List<PaymentResult> results = payments.stream()
.map(p -> processPayment(p)) // ERROR: unhandled exception PaymentException
.toList();
The compiler rejects this with:
error: unreported exception PaymentException; must be caught or declared to be thrown
The functional interface powering .map() is Function<T, R>, whose apply method declares no checked exceptions. There is no way to use a checked-exception method directly in a lambda without wrapping it.
The common workaround is to wrap the checked exception in an unchecked one inside the lambda:
// Java — workable but verbose
List<PaymentResult> results = payments.stream()
.map(p -> {
try {
return processPayment(p);
} catch (PaymentException e) {
throw new RuntimeException(e); // Wrap to satisfy the lambda contract
}
})
.toList();
This is the practical argument for using sealed type hierarchies as your primary domain error model instead of checked exceptions — they compose cleanly in functional pipelines.
Modeling Errors as Data — The Better Path
Instead of throwing PaymentException, return a PaymentResult. You introduced this sealed type hierarchy in Module 05:
// Java — from Module 05
sealed interface PaymentResult permits PaymentSuccess, PaymentFailure {}
record PaymentSuccess(String transactionId, double amount) implements PaymentResult {}
record PaymentFailure(String reason, ErrorCode code) implements PaymentResult {}
enum ErrorCode { INSUFFICIENT_FUNDS, INVALID_ACCOUNT, NETWORK_TIMEOUT }
A method that returns PaymentResult communicates all possible outcomes in its return type — no side channels, no throws clause. The caller uses an exhaustive switch expression:
// Java
public PaymentResult processPayment(Payment payment) {
if (payment.amount() <= 0) {
return new PaymentFailure("Non-positive amount", ErrorCode.INVALID_ACCOUNT);
}
// ... business logic
return new PaymentSuccess("TXN-" + payment.id(), payment.amount());
}
// Caller uses switch — composable, exhaustive, no try-catch
PaymentResult result = processPayment(payment);
switch (result) {
case PaymentSuccess(var txId, var amt) -> log.info("Success: {} for {}", txId, amt);
case PaymentFailure(var reason, var code) -> log.warn("Failed: {} ({})", reason, code);
}
This is also composable in streams — processPayment returns a plain value, so .map(this::processPayment) works without wrappers.
Rust comparison: This is structurally identical to returning
Result<PaymentSuccess, PaymentFailure>in Rust, except theswitchexpression replacesmatch. The sealed type hierarchy gives the compiler the same exhaustiveness guarantee: if you add a third permitted type and don't update yourswitch, the code fails to compile.
Try-With-Resources — Java's RAII Analogy
Any class that implements AutoCloseable can be used in a try-with-resources block. The resource is closed automatically at the end of the try block, whether the block completes normally or throws an exception.
Suppose your payment processor opens a database connection to persist each payment:
// Java
public PaymentResult processPayment(Payment payment) {
try (DatabaseConnection db = connectionPool.acquire()) {
PaymentResult result = executePayment(db, payment);
db.commit();
return result;
}
// db.close() is called automatically here, even if an exception was thrown
}
The Rust equivalent is Drop:
// Rust equivalent
fn process_payment(payment: &Payment) -> PaymentResult {
let db = connection_pool.acquire(); // Drop called when db goes out of scope
let result = execute_payment(&db, payment);
db.commit();
result
// db.drop() is called here implicitly
}
The effect is the same — resources are cleaned up at the end of the scope. The mechanism is different:
- Java: You must explicitly declare the resource in
try (Resource r = ...). The cleanup is syntactically visible. - Rust:
Dropis implicit. The compiler inserts thedrop()call at the end of the owning scope.
Note: Try-with-resources only applies to
AutoCloseableresources. It does not substitute for Rust's full ownership model — it is a scoped cleanup mechanism for I/O handles, database connections, and similar resources. General memory is managed by the garbage collector, not byAutoCloseable.
Module 02 mentioned AutoCloseable as the Java mechanism for deterministic resource cleanup. This is the full picture: wrap your resource in a try header, and the JVM guarantees close() is called.
Concrete Example
Here is the full refactoring this module is building toward. We start with an exception-based payment processor and end with a sealed-type-based one that composes cleanly.
Before: exception-based
// Java — exception-based (hard to compose)
public class ExceptionBasedProcessor {
public String processPayment(Payment payment) throws PaymentException {
if (payment.amount() <= 0) {
throw new PaymentException("Invalid amount");
}
try (DatabaseConnection db = connectionPool.acquire()) {
String txId = db.insert(payment);
return txId;
} catch (DatabaseException e) {
throw new PaymentException("DB error: " + e.getMessage(), e);
}
}
}
// Caller — cannot use in a stream without wrapping:
try {
String txId = processor.processPayment(payment);
System.out.println("Success: " + txId);
} catch (PaymentException e) {
System.err.println("Failed: " + e.getMessage());
}
After: sealed-type-based
// Java — sealed-type-based (composable)
public class SealedTypeProcessor {
public PaymentResult processPayment(Payment payment) {
if (payment.amount() <= 0) {
return new PaymentFailure("Non-positive amount", ErrorCode.INVALID_ACCOUNT);
}
try (DatabaseConnection db = connectionPool.acquire()) {
String txId = db.insert(payment);
return new PaymentSuccess(txId, payment.amount());
} catch (DatabaseException e) {
return new PaymentFailure("DB error: " + e.getMessage(), ErrorCode.NETWORK_TIMEOUT);
}
}
}
// Caller — clean switch, no try-catch at the call site:
PaymentResult result = processor.processPayment(payment);
switch (result) {
case PaymentSuccess(var txId, var amt) ->
System.out.printf("Success: %s for %.2f%n", txId, amt);
case PaymentFailure(var reason, var code) ->
System.err.printf("Failed [%s]: %s%n", code, reason);
}
// And it works in streams too:
List<PaymentResult> results = payments.stream()
.map(processor::processPayment) // No wrapping needed
.toList();
Notice that the try-with-resources block is still there inside processPayment. The database connection is cleaned up whether db.insert() succeeds or throws. The DatabaseException (a checked exception from the DB driver) is caught at the boundary and converted into a PaymentFailure value. Only unchecked exceptions from programmer errors can escape.
Analogy
Think of checked exceptions as compiler-enforced TODO comments. When a Java method declares throws IOException, the compiler is saying: "You cannot ignore this. Handle it or declare that you'll pass it up." It is similar in intent to Rust's Result — the caller must acknowledge the possibility of failure.
But acknowledgment and composability are different things. A checked exception forces acknowledgment by interrupting control flow. A Result type forces acknowledgment by making the value inaccessible without pattern matching — and it does so while staying in the normal data flow. The ? operator in Rust is essentially map_err followed by early return; it stays in the type system. Java has no equivalent that stays in the type system.
The sealed type hierarchy (PaymentResult) is the closest Java gets to the composable half of Result. It does not give you ?, but it gives you exhaustive switch expressions and clean lambda composition.
Going Deeper
The Optional Trap
Optional<T> can tempt you into using it as a Result<T, E>. Resist this.
Optional<T> models absence, not failure. It has exactly one "unhappy" state — empty — and that state carries no information. There is no "reason" slot. If you return Optional<PaymentResult> when a payment could either succeed or fail with an error code, you have thrown away the error information:
// Java — WRONG: Optional hides the reason
public Optional<PaymentResult> processPayment(Payment payment) {
if (payment.amount() <= 0) {
return Optional.empty(); // Why? Unknown to the caller.
}
// ...
}
Common pitfall:
Optional<PaymentResult>is an anti-pattern.Optional.empty()cannot tell the caller whether the payment was rejected, the account was invalid, or the network timed out. Use thePaymentResultsealed hierarchy —PaymentFailurehas areasonand anErrorCode. Optional is for absence (no account found), not for failure (account found but balance too low).
The correct use of Optional<T> in the payment domain is narrow: use it when a query genuinely may return nothing and no explanation is needed. For example, looking up an optional preferred currency for an account:
// Java — correct: Optional for absence
public Optional<String> getPreferredCurrency(String accountId) {
return accountRepository.findPreferredCurrency(accountId);
// Returns Optional.empty() if the account has no preference set.
// No error — just absence.
}
The canonical rule from this plan: Optional<T> models absence, not failure. If you need to communicate what went wrong, use a sealed class hierarchy or a typed exception. Never use Optional<T> as a Result<T, E> replacement.
Practical Decision Guide
| Situation | Use |
|---|---|
| Programmer error, can't recover | Unchecked exception (RuntimeException subclass) |
| Environmental failure at a system boundary (file, network, DB) | Checked exception |
| Domain error that is part of business logic | Sealed type hierarchy (e.g., PaymentResult) |
| Value might simply be absent (no error) | Optional<T> |
| Async error propagation (older code) | CompletableFuture (see Module 08 for the modern approach) |
Checked Exceptions and Library Code
The trend in modern Java is to avoid checked exceptions in library and utility code. They leak through abstraction layers, force callers to declare throws clauses for exceptions they cannot meaningfully handle, and resist composition. Libraries like Apache Commons, Guava, and Spring have largely moved to unchecked exceptions for this reason.
If you are writing library code, prefer unchecked exceptions or sealed return types. Reserve checked exceptions for I/O-boundary methods where the caller genuinely needs to distinguish and handle the error type.
CompletableFuture and Async Error Handling
Before virtual threads (Module 08), the common way to handle async errors without checked-exception ceremony was CompletableFuture. It chains operations and error handlers:
// Java — older async pattern
CompletableFuture<PaymentResult> future = CompletableFuture
.supplyAsync(() -> processPayment(payment))
.exceptionally(e -> new PaymentFailure(e.getMessage(), ErrorCode.NETWORK_TIMEOUT));
This is functional, but it is also complex. Module 08 shows how virtual threads restore the straightforward blocking model — you write normal blocking code and the JVM handles concurrency.
Common Misconceptions
1. "Unchecked exceptions don't need to be handled because the compiler doesn't complain."
This is technically true and practically dangerous. Unchecked exceptions still propagate up the call stack and will crash your program if uncaught. The compiler's silence means you are responsible for deciding whether to catch them. In a payment processor, an unhandled NullPointerException deep in processPayment will surface as a 500 error to the user. The absence of a compiler warning is not permission to ignore the possibility.
2. "Rust's Result<T, E> and Java's checked exceptions are the same thing."
They are similar in intent — both force the caller to acknowledge failure — but fundamentally different in mechanism. A checked exception is a control flow interruption declared in a method signature. Result<T, E> is a data type: you get back a value, pattern-match on it, and continue in normal data flow. The ? operator makes propagation seamless. Java has no equivalent that composes in functional pipelines — every checked exception in a lambda requires a try-catch wrapper.
3. "Try-with-resources is Java's full equivalent of Rust's ownership system."
Try-with-resources achieves one specific thing: deterministic cleanup of AutoCloseable resources at block exit. Rust's ownership system does far more — it prevents use-after-free, enforces single-writer / multiple-reader rules, and applies to all values, not just closeable resources. Java's garbage collector handles memory; try-with-resources handles I/O handles and similar external resources. These are complementary, not equivalent, and neither together constitutes what Rust's borrow checker provides.
Check Your Understanding
-
You have a method that queries a database for a
Paymentby ID. The payment might not exist (the ID was valid but the record was deleted). What should the return type be, and why?Answer:
Optional<Payment>. The query returning no record is not a failure — it is absence. There is no error to report, just "no such payment." UseOptional.empty()to represent this case. If the database connection itself failed (a different condition), that is an environmental failure and belongs in a checked exception or aPaymentFailurewith an appropriateErrorCode. -
You want to stream a
List<Payment>and callprocessPayment(payment)inside.map(), butprocessPaymentis declaredthrows PaymentException. What happens and how do you fix it?Answer: The code does not compile. The lambda passed to
.map()is aFunction<Payment, PaymentResult>, andFunction.apply()declares no checked exceptions. Two fixes: (a) wrap the exception inside the lambda withtry { ... } catch (PaymentException e) { throw new RuntimeException(e); }, or (b) refactorprocessPaymentto returnPaymentResultinstead of throwing, which removes the problem entirely. Option (b) is preferred in modern Java because it keeps the pipeline clean. -
What is the difference between a
PaymentFailurereturn value and athrow new PaymentException()for communicating that an account has insufficient funds?Answer:
PaymentFailureis a value — the caller receives it as the return of the method, pattern-matches on it, and handles it in normal control flow. Athrowinterrupts control flow: the exception propagates up the call stack, bypassing any code between thethrowsite and the nearest matchingcatch.PaymentFailureis composable in streams and functional pipelines;PaymentExceptionis not (without wrapping).PaymentFailurealso carries structured data (reasonandErrorCode) in a type-safe way, whereas a checked exception's type information is less compositional. -
A colleague wraps a database connection in
try-with-resources. The business logic inside thetryblock callsexecutePayment(db, payment), which returns aPaymentResult. When isdb.close()called? Does the answer change ifexecutePaymentthrows aRuntimeExceptionpartway through?Answer:
db.close()is called when thetryblock exits, regardless of whether it exits normally (by reaching the closing brace and returning a value) or abnormally (by throwing an exception). IfexecutePayment(db, payment)throws aRuntimeException,db.close()is still called before the exception propagates. Try-with-resources guarantees cleanup at block exit under all circumstances. -
When should you use
CompletableFuturefor error handling vs. the virtual thread / structured concurrency approach from Module 08?Answer: Prefer virtual threads and structured concurrency for new code in Java 21.
CompletableFutureis appropriate in legacy codebases already using it, or when integrating with APIs that return futures. The structured concurrency approach (StructuredTaskScope) allows you to write blocking code in virtual threads while achieving the same concurrency — and exceptions from child tasks propagate naturally through the scope without the callback-chaining complexity ofCompletableFuture.
Key Takeaways
- Checked exceptions enforce caller acknowledgment at the cost of functional composability. Use them at I/O system boundaries, not for domain errors.
- Unchecked exceptions are for programmer errors — conditions that represent bugs, not recoverable situations. They are Java's
panic!(). - Sealed type hierarchies (
PaymentResult) are the composable, functional-friendly alternative for domain errors. They work in streams, enable exhaustiveswitchexpressions, and carry structured error data. - Try-with-resources gives
AutoCloseableresources deterministic cleanup at block exit — Java's explicit RAII analog. It is not a substitute for Rust's full ownership system, but it reliably handles I/O handles and database connections. Optional<T>models absence, not failure. If you need to communicate why something failed, use a sealed class hierarchy. Never useOptional<T>as aResult<T, E>replacement.
References
- Checked and Unchecked Exceptions in Java | Baeldung — Comprehensive guide distinguishing checked and unchecked exceptions with practical examples and Java 21 context.
- The try-with-resources Statement (The Java Tutorials) — Official Java documentation on try-with-resources for safe, deterministic resource management.
- Java Try With Resources | Baeldung — Deep dive into the
AutoCloseablecontract, suppressed exceptions, and multi-resource syntax. - Unchecked Exceptions — The Controversy (The Java Tutorials) — The official Java tutorial's discussion of when to use checked vs. unchecked exceptions, including the guidelines that informed modern library design.
- Optional (Java SE 21 & JDK 21) — The official Java 21 API documentation for
Optional, including its design note: "Optional is primarily intended for use as a method return type where there is a clear need to represent 'no result'."