Skip to content
Back
Software design

Modern Java Error Handling: Scoped Values, Structured Concurrency and Result

Try-with-resources and multi-catch, Scoped Values for request context, structured concurrency with the Java 25 API, the Result type, CompletableFuture, and what exceptions cost.

Michał Artur Marciniak

4 min read

On this page
  1. The Modern Exception Handling Toolkit
  2. Scoped Values - ThreadLocal’s Successor (Java 25)
  3. Structured Concurrency - Better Async Error Handling (Preview)
  4. Advanced Patterns for Modern Java
  5. Performance Considerations
  6. Quick Reference
  7. References

Part 1 covered how exceptions work and how to design them. This part turns to the tools: try-with-resources, Scoped Values, structured concurrency as it looks in Java 25, the Result type, and what exceptions cost at run time.

The Modern Exception Handling Toolkit

Traditional Try-Catch: The Foundation

The classic structure remains the workhorse:

Java
public void processFile(String path) {
    try (BufferedReader reader = Files.newBufferedReader(Path.of(path))) {
        return reader.readLine();
    } catch (FileNotFoundException e) {
        logger.error("Config file not found: {}", path, e);
        return getDefaultConfig();
    } catch (IOException e) {
        logger.error("Error reading file: {}", path, e);
        throw new ConfigLoadException("Failed to load: " + path, e);
    }
}

Multi-Catch: Java 7’s Gift

When handling logic is identical for multiple exceptions:

Java
try {
    database.connect();
} catch (SQLException | IOException e) {
    throw new DatabaseUnavailableException("Cannot connect", e);
}

Note: The variable e is implicitly final and is declared with the union type SQLException | IOException. Its static type is the closest common supertype of the caught exceptions, here Exception.

Try-With-Resources: The Modern Way

Always use this for AutoCloseable objects-resources close automatically in reverse order:

Java
try (Connection conn = dataSource.getConnection();
     PreparedStatement stmt = conn.prepareStatement(SQL);
     ResultSet rs = stmt.executeQuery()) {

    while (rs.next()) {
        processRow(rs);
    }
}  // All three closed automatically: rs → stmt → conn

The generated bytecode includes Throwable.addSuppressed() to ensure exceptions from close operations don’t mask the original exception.


Scoped Values - ThreadLocal’s Successor (Java 25)

Java 25 finalizes Scoped Values (JEP 506), the modern replacement for ThreadLocal that enables efficient context sharing across Virtual Threads and Structured Concurrency.

The ThreadLocal Problem

ThreadLocal has three fatal flaws:

  1. Unconstrained mutability - any code can call set() at any time
  2. Unbounded lifetime - values persist until explicitly removed (memory leaks in thread pools)
  3. Expensive inheritance - child threads must copy all parent thread-locals

With millions of Virtual Threads (Java 21+), these costs become prohibitive.

The Scoped Value Solution

Scoped values are immutable, bounded to a scope, and efficiently inherited by child threads:

Java
private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

Response handleRequest(Request req) throws InterruptedException {
    var context = new RequestContext(req.getId(), req.getUser());

    return ScopedValue.where(CONTEXT, context).call(() -> {
        // Context available to all callees, including forked subtasks
        return processRequest();
    });
    // Context automatically cleaned up here-no memory leaks!
}

Response processRequest() throws InterruptedException {
    var ctx = CONTEXT.get();  // Always get the right value
    logger.info("Processing request {}", ctx.requestId());

    try (var scope = StructuredTaskScope.open()) {
        Subtask<UserData> user = scope.fork(() -> fetchUser(ctx.userId()));
        Subtask<Permissions> perms = scope.fork(() -> fetchPermissions(ctx.userId()));

        scope.join();  // Throws FailedException if a subtask failed
        return new Response(user.get(), perms.get());
    }
}

Why Scoped Values Matter for Error Handling

In concurrent applications, context is crucial for error diagnosis:

Java
Response handleRequest(Request req) throws InterruptedException {
    var context = new RequestContext(req.getId(), req.getUserId());

    return ScopedValue.where(CONTEXT, context).call(() -> {
        try {
            return processRequest();
        } catch (Exception e) {
            // CONTEXT.get() still available for logging!
            var ctx = CONTEXT.get();
            logger.error("Request {} failed for user {}",
                ctx.requestId(), ctx.userId(), e);
            throw e;
        }
    });
}

Without Scoped Values, context might be lost when exceptions propagate across thread boundaries.

Scoped Values are final in Java 25 (JEP 506) and represent the best practice for context sharing in modern Java.


Structured Concurrency - Better Async Error Handling (Preview)

Structured Concurrency (JEP 505) is in its 5th preview in Java 25. While not yet finalized, it provides superior error handling for concurrent code.

The Problem with Traditional Async Error Handling

Java
// Traditional: Errors get lost or mishandled
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Order> orderFuture = executor.submit(() -> fetchOrder(id));

// What if one fails? What if both fail? Complexity ensues.

Structured Concurrency Solution

Java
try (var scope = StructuredTaskScope.open()) {
    Subtask<User> user = scope.fork(() -> fetchUser(id));
    Subtask<Order> order = scope.fork(() -> fetchOrder(id));

    scope.join();  // Wait for both; throws if either failed

    return new Response(user.get(), order.get());
} catch (StructuredTaskScope.FailedException e) {
    return switch (e.getCause()) {  // The exception of the failed subtask
        case UserNotFoundException _ -> Response.notFound("User not found");
        case OrderException _ -> Response.error("Failed to load order");
        default -> throw e;
    };
}

Key benefits:

  • Fail-fast: If one task fails, others are cancelled automatically
  • Clear exception hierarchy: Errors propagate cleanly to the caller
  • No lost exceptions: All failures are properly reported

Scoped Values + Structured Concurrency

Together they enable clean context propagation in concurrent code:

Java
Data handleConcurrentRequest(Request req) throws InterruptedException {
    var context = new RequestContext(req.getId());

    return ScopedValue.where(CONTEXT, context).call(() -> {
        try (var scope = StructuredTaskScope.open()) {
            // Subtasks automatically see CONTEXT
            Subtask<Data> primary = scope.fork(() -> fetchPrimary(CONTEXT.get()));
            Subtask<Data> backup = scope.fork(() -> fetchBackup(CONTEXT.get()));

            scope.join();
            return merge(primary.get(), backup.get());
        }
    });
}

Note: Structured Concurrency is in preview (JEP 505). Enable with --enable-preview flag. Java 25 reworked its API: a scope opens with StructuredTaskScope.open(), fork() returns a Subtask, and the exception of a failed subtask arrives as the cause of StructuredTaskScope.FailedException. Code written for ShutdownOnFailure from earlier previews no longer compiles.


Advanced Patterns for Modern Java

The Result Type (Functional Error Handling)

For operations that might fail but shouldn’t throw:

Java
public sealed interface Result<T, E> {
    record Success<T, E>(T value) implements Result<T, E> {}
    record Failure<T, E>(E error) implements Result<T, E> {}
}

public Result<User, UserError> findUser(UserId id) {
    try {
        return new Result.Success<>(db.loadUser(id));
    } catch (UserNotFoundException e) {
        return new Result.Failure<>(UserError.NOT_FOUND);
    }
}

// Usage with pattern matching (Java 21+)
var result = findUser(id);
switch (result) {
    case Result.Success<User, UserError> s -> displayUser(s.value());
    case Result.Failure<User, UserError> f -> showError(f.error());
}

CompletableFuture Exception Handling

Async code requires special handling:

Java
CompletableFuture.supplyAsync(this::fetchData)
    .exceptionally(ex -> {
        logger.error("Fetch failed, using cache", ex);
        return cache.getDefault();
    })
    .thenAccept(this::process)
    .exceptionally(ex -> {
        showError("Processing failed");
        return null;
    });

Global Exception Handlers

For uncaught exceptions at application boundaries:

Java
Thread.setDefaultUncaughtExceptionHandler((thread, ex) -> {
    logger.error("Uncaught exception in thread {}", thread.getName(), ex);
    alertingService.notify("Application error", ex);
});

Performance Considerations

The Cost of Exceptions

Creating an exception captures the entire stack trace-a relatively expensive operation involving:

  • Walking the call stack
  • Creating StackTraceElement objects
  • String manipulation for class/method names

Benchmarks show throwing and catching an exception can be 100x+ slower than a normal return (nanoseconds vs. microseconds).

Optimization Strategies

  1. Don’t use exceptions for control flow (see Deadly Sin #4)

  2. Override fillInStackTrace() for performance-critical code:

Java
public class FastException extends RuntimeException {
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;  // Skip stack trace creation
    }
}
  1. Use Optional for nullable returns instead of exceptions

Quick Reference

Common Checked Exceptions

When thrownTypical handling
IOExceptionI/O operations failRetry, use defaults, notify the user
SQLExceptionDatabase errorsRetry with backoff, circuit breaker
InterruptedExceptionThread interruptedRestore the interrupt, terminate gracefully
ExecutionExceptionAsync computation failedExtract the cause, handle it

Common Unchecked Exceptions

IndicatesResponse
NullPointerExceptionMissing null checkAdd validation, fix the bug
IllegalArgumentExceptionInvalid parameterValidate at method entry
IllegalStateExceptionObject misusedCheck preconditions
IndexOutOfBoundsExceptionArray or collection boundsValidate indices

Modern Java Patterns

Try-With-Resources:

Java
try (Resource r1 = openResource1();
     Resource r2 = openResource2()) {
    // Use resources
} catch (SpecificException e) {
    // Handle
}

Scoped Values (Java 25):

Java
private static final ScopedValue<Context> CTX = ScopedValue.newInstance();

ScopedValue.where(CTX, context).run(() -> {
    // Context available to all callees
    CTX.get();  // Always returns the bound value
});

Pattern Matching for Types (Java 21+):

Java
String error = switch (exception) {
    case IOException ioe -> "I/O: " + ioe.getMessage();
    case SQLException sqle -> "DB: " + sqle.getSQLState();
    default -> "Unknown: " + exception.getMessage();
};

References

Java 25 Features and Documentation

Modern Exception Handling Practices

Java Language Specification