Modern Java for Rust Engineers
Module 6 of 8 Intermediate 35 min

Error Handling — Exceptions, Result Patterns, and RAII

Prerequisites: records-sealed-pattern-matching

What You'll Learn

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 the switch expression replaces match. The sealed type hierarchy gives the compiler the same exhaustiveness guarantee: if you add a third permitted type and don't update your switch, 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:

Note: Try-with-resources only applies to AutoCloseable resources. 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 by AutoCloseable.

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 the PaymentResult sealed hierarchy — PaymentFailure has a reason and an ErrorCode. 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

SituationUse
Programmer error, can't recoverUnchecked exception (RuntimeException subclass)
Environmental failure at a system boundary (file, network, DB)Checked exception
Domain error that is part of business logicSealed 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

  1. You have a method that queries a database for a Payment by 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." Use Optional.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 a PaymentFailure with an appropriate ErrorCode.

  2. You want to stream a List<Payment> and call processPayment(payment) inside .map(), but processPayment is declared throws PaymentException. What happens and how do you fix it?

    Answer: The code does not compile. The lambda passed to .map() is a Function<Payment, PaymentResult>, and Function.apply() declares no checked exceptions. Two fixes: (a) wrap the exception inside the lambda with try { ... } catch (PaymentException e) { throw new RuntimeException(e); }, or (b) refactor processPayment to return PaymentResult instead of throwing, which removes the problem entirely. Option (b) is preferred in modern Java because it keeps the pipeline clean.

  3. What is the difference between a PaymentFailure return value and a throw new PaymentException() for communicating that an account has insufficient funds?

    Answer: PaymentFailure is a value — the caller receives it as the return of the method, pattern-matches on it, and handles it in normal control flow. A throw interrupts control flow: the exception propagates up the call stack, bypassing any code between the throw site and the nearest matching catch. PaymentFailure is composable in streams and functional pipelines; PaymentException is not (without wrapping). PaymentFailure also carries structured data (reason and ErrorCode) in a type-safe way, whereas a checked exception's type information is less compositional.

  4. A colleague wraps a database connection in try-with-resources. The business logic inside the try block calls executePayment(db, payment), which returns a PaymentResult. When is db.close() called? Does the answer change if executePayment throws a RuntimeException partway through?

    Answer: db.close() is called when the try block exits, regardless of whether it exits normally (by reaching the closing brace and returning a value) or abnormally (by throwing an exception). If executePayment(db, payment) throws a RuntimeException, db.close() is still called before the exception propagates. Try-with-resources guarantees cleanup at block exit under all circumstances.

  5. When should you use CompletableFuture for 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. CompletableFuture is 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 of CompletableFuture.

Key Takeaways

References