engnotes.dev
NotebookTopicsAbout

Weekly Digest

The engineering brief.

Weekly insights · No spam · RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Structured Concurrency9

Elsewhere

  • GitHub
  • X (Twitter)
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/structured-concurrency/part 9
Structured Concurrency

Migrating our fan-out service from Java 21 to Java 25

Most of the migration was mechanical. ShutdownOnFailure became a Joiner, throwIfFailed disappeared, and StructuredTaskScope.open replaced the constructor. Two things were not mechanical, and those are the ones worth reading.

J
Jagdish Salgotra
2026-05-17·12 min read·~571 words

Series navigation

← PreviousFour operational checks we run on every StructuredTaskScope
Next →N/A (end of series)
Code repositoryproject-loom
#structured-concurrency
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

all posts

Keep reading

  • 2026-05-1010 min readFour operational checks we run on every StructuredTaskScope
  • 2026-05-048 min readThree structured-concurrency patterns we run in a fan-out service
  • 2026-04-2610 min readComposing resilience policies as separable layers

Was this useful?

Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 12 · ~12 left

Ask the post

Any answer points back at the paragraph it came from.

The migration started with a compile error. ShutdownOnFailure was gone. Not deprecated, not replaced with a warning, just gone. That is where the Java 25 structured concurrency story begins: the subclass model is out, and a Joiner passed into StructuredTaskScope.open() is in.

Once I understood that one substitution, the rest followed. And throwIfFailed() disappearing was a relief, not a problem. It was always the call you had to remember to make after join(). Java 25 moves that responsibility into the joiner itself, where it belongs.

This is a migration appendix for teams on Java 21 preview who want to understand what actually changes. The core idea stays identical. The API surface gets cleaner.


What changed and what did not

FeatureJava 21Java 25Impact
Structured ConcurrencyPreview (JEP 453)Preview (JEP 505)API redesigned
Scoped ValuesPreview (JEP 446)Final (JEP 506)Stable, pairs cleanly with scopes
Virtual ThreadsFinal (JEP 444)FinalNo change

The execution model is stable. Virtual threads, lifecycle guarantees, cancellation propagation, none of that moves. What changes is how you express the policy for what happens when a subtask fails or succeeds.

The substitution

Composition over inheritance, same lifecycle guarantees, fewer missed failure checks.

Every extends StructuredTaskScope.ShutdownOnFailure becomes a Joiner passed into StructuredTaskScope.open(). That is the migration. Everything else follows from it.

Java 21:

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var fetch1 = scope.fork(() -> fetchFromService1());
    var fetch2 = scope.fork(() -> fetchFromService2());

    scope.join();
    scope.throwIfFailed();

    return fetch1.get() + fetch2.get();
}

Java 25:

java
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
    var fetch1 = scope.fork(() -> fetchFromService1());
    var fetch2 = scope.fork(() -> fetchFromService2());

    scope.join();

    return fetch1.get() + fetch2.get();
}

throwIfFailed() is gone. The joiner handles it. scope.result() is gone too. You call .get() directly on the subtask.

Java 25 Code: Timeout Wrapper Pattern Used in This Project

java
public <T> T runInScopeWithTimeout(Callable<T> task, Duration timeout) throws Exception {
    Instant deadline = Instant.now().plus(timeout);

    try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
        var future = scope.fork(task);
        scope.join();

        // Explicit deadline check for compatibility; adopt cfg.withTimeout() in fuller previews.
        if (Instant.now().isAfter(deadline)) {
            throw new TimeoutException("Operation exceeded timeout: " + timeout);
        }

        return future.get();
    }
}

First successful result

Java 21 required a custom subclass. Java 25 uses a joiner:

java
public <T> T runFirstSuccess(Callable<T>... tasks) throws Exception {
    try (var scope = StructuredTaskScope.open(
            StructuredTaskScope.Joiner.<T>allUntil(
                s -> s.state() == Subtask.State.SUCCESS))) {
        for (Callable<T> task : tasks) {
            scope.fork(task);
        }

        Stream<Subtask<T>> results = scope.join();
        return results
            .filter(s -> s.state() == Subtask.State.SUCCESS)
            .findFirst()
            .map(Subtask::get)
            .orElseThrow(() -> new Exception("No successful result"));
    }
}

Timeout handling

joinUntil(Instant) is gone. The pattern that worked in this project is an explicit deadline check in a wrapper method:

java
public <T> T runWithTimeout(Callable<T> task, Duration timeout) throws Exception {
    Instant deadline = Instant.now().plus(timeout);

    try (var scope = StructuredTaskScope.open(
            StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
        var future = scope.fork(task);
        scope.join();

        if (Instant.now().isAfter(deadline)) {
            throw new TimeoutException("Exceeded timeout: " + timeout);
        }

        return future.get();
    }
}

Scoped values

Scoped values are final in Java 25. The fluent API replaces the old static style:

Java 21:

java
ScopedValue.runWhere(USER_ID, userId, () -> handleBusinessLogic());

Java 25:

java
ScopedValue.where(USER_ID, userId)
           .where(REQUEST_ID, requestId)
           .run(() -> handleBusinessLogic());

Cleaner to read, especially when binding multiple values before entering a scope.


What actually broke during migration

Compile errors, not runtime surprises. In order:

  • ShutdownOnFailure and ShutdownOnSuccess no longer exist as subclasses
  • throwIfFailed() does not compile
  • joinUntil(Instant) does not compile
  • ScopedValue.runWhere and callWhere must move to fluent style
  • Custom shutdown policies from Java 21 need reimplementing as custom Joiner strategies

Business logic inside subtasks did not need touching. Virtual thread behavior did not change. The breakage was entirely at the scope construction and join call sites.


Migration checklist

  1. Replace subclass construction with StructuredTaskScope.open() and choose a joiner
  2. Remove throwIfFailed() and scope.result()
  3. Replace joinUntil(Instant) with explicit deadline checks in wrapper methods
  4. Update scoped value bindings to fluent style
  5. Reimplement any custom shutdown policies as custom Joiner strategies
  6. Keep --enable-preview on both compile and runtime paths

One thing to track

Structured concurrency is still preview in Java 25. JEP 505 is the current spec. Follow it for finalization timeline signals. The API has moved meaningfully between Java 21 and Java 25 and could move again before it stabilizes.


The API is not final yet. But the direction is clear enough to migrate with confidence.

Sources

  • JEP 444: Virtual Threads
  • JEP 453: Structured Concurrency (Preview)
  • JEP 505: Structured Concurrency (Fifth Preview)
  • JEP 506: Scoped Values
  • JEP 491: Synchronize Virtual Threads without Pinning
  • JDK 21 StructuredTaskScope API
  • JDK 25 StructuredTaskScope API
  • JDK 25 ScopedValue API