Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
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.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
The migration was smaller than I expected, but sharper.
Business logic did not move much. The simulated service delays stayed the same. The fan-out shapes stayed recognizable. What changed was the boundary around each scope: how a scope is opened, where failure policy lives, what join() returns, and what the owner is allowed to read before joining.
That is the right lesson for this series to end on. Structured concurrency is still preview, and preview APIs can move. The ideas from Java 21 still matter, but the Java 25 syntax is not a cosmetic rename.
This article is learning material. It is not a production migration report. The point is to show exactly what changed in this repository and what the migrated code proved when it ran locally.
The Java 21 version is separately managed in the feature/java-21 branch. The current branch has been migrated to Java 25 preview APIs. The measurements below were generated with OpenJDK 25.0.2. Java 25 structured concurrency is the fifth preview from JEP 505, and OpenJDK has already delivered a sixth preview for Java 26 in JEP 525. That reinforces the practical rule for the whole series: keep preview API usage contained.
I compared feature/java-21 with the current Java 25 code without switching branches:
git diff --stat feature/java-21..HEAD -- pom.xml src/main/java
The structured-concurrency migration touched 15 files:
15 files changed, 190 insertions(+), 169 deletions(-)
The scope-related counts tell the story:
| Pattern | Java 21 branch | Current Java 25 code |
|---|---|---|
StructuredTaskScope.ShutdownOnFailure | 56 occurrences | 0 executable occurrences |
StructuredTaskScope.ShutdownOnSuccess | 7 occurrences | 0 executable occurrences |
throwIfFailed() | 48 occurrences | 0 occurrences |
executable scope.joinUntil(...) calls | 4 occurrences | 0 occurrences |
StructuredTaskScope.open(...) | 0 occurrences | 63 occurrences |
StructuredTaskScope.Joiner | 0 occurrences | 63 occurrences |
The Maven compiler target changed from 21 to 25 in pom.xml:
- <maven.compiler.source>21</maven.compiler.source>
- <maven.compiler.target>21</maven.compiler.target>
+ <maven.compiler.source>25</maven.compiler.source>
+ <maven.compiler.target>25</maven.compiler.target>
Both versions still need preview enabled for structured concurrency:
<compilerArgs>--enable-preview</compilerArgs>
That is the project-level migration. The rest is call-site work.
For the Article 9 pass, the local toolchain was OpenJDK 25.0.2 and Maven 3.9.12:
mvn clean compile -DskipTests
The build succeeded and compiled 35 source files.
Then I ran the migrated standalone examples:
| Command | Fresh result |
|---|---|
java --enable-preview -cp "$CP" app.js.concurrent.StructuredExampleWithSuccess | all three failure-policy subtasks completed, then first-success returned Service-C result |
java --enable-preview -cp "$CP" app.js.structured.AdvancedStructuredPatterns | partial results completed 2 of 4 tasks, conditional cancellation finished in 410ms, progressive results completed 4 of 4 tasks in 271ms, and the class ended with All advanced patterns completed! |
java --enable-preview -cp "$CP" app.js.structured.ProgressiveHierarchicalDemo | progressive demo completed 5 of 5 tasks in 438ms, hierarchical demo completed in 1748ms, e-commerce workflow completed in 367ms |
java --enable-preview -cp "$CP" app.js.scoped.ScopedValueExample | structured-scope subtasks saw test-user and all five completed; the separate unstructured child virtual-thread section printed unknown for scoped values |
The HTTP smoke checks also passed on the migrated services:
| Endpoint | Fresh result |
|---|---|
GET /structured/aggregate on port 8082 | four advanced service branches completed in 203ms |
GET /timeout/graceful on port 8082 | cache-service-ok won in 55ms |
GET /async/race on port 8082 | hedge-ok won in 191ms |
GET /timeout/short on port 8082 | failed with HTTP 500 after 503ms |
GET /services/aggregate on port 8085 | four clean service branches completed in 207ms inside the service and 213ms over HTTP |
GET /timed/operation?op=slow-task on port 8085 | completed in 1507ms |
GET /cache/data?key=userdata on port 8085 | fell through to DB-userdata in 209ms |
The focused load checks on the two aggregate endpoints landed almost identically:
| Endpoint | Latency average | Requests | Requests/sec |
|---|---|---|---|
GET /structured/aggregate on port 8082 | 205.55ms | 980 | 97.08 |
GET /services/aggregate on port 8085 | 205.36ms | 980 | 97.02 |
Those numbers are not a claim that Java 25 is faster than Java 21. I did not run the Java 21 branch under JDK 21 for this pass. They are proof that the migrated Java 25 code compiles and that the core examples still behave like the previous articles said they should.
In Java 21 preview, the simplest fail-fast scope used a policy subclass:
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();
}
In the current repository, the equivalent Java 25 shape uses StructuredTaskScope.open(...) with a Joiner:
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();
}
The important migration is not just "constructor becomes factory." The failure policy moved from the subtype to the joiner. In Java 21, you opened a ShutdownOnFailure scope and then had to remember throwIfFailed() after joining. In the Java 25 code, awaitAllSuccessfulOrThrow() carries that policy.
That removed 48 throwIfFailed() calls from this repository.
You can see the cleanest version in ScopedRequestHandler.java. runInScope, both runInParallel overloads, aggregate, fallback, retry, and circuit-breaker helpers now all use the same joiner-based scope shape.
Fail-fast migration is mostly mechanical. First-success migration needs more thought because Java 21 exposed scope.result():
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
for (Callable<T> task : tasks) {
scope.fork(task);
}
scope.join();
return scope.result();
}
The current Java 25 helper in ScopedRequestHandler.java expresses the policy with allUntil(...) and then filters joined subtasks:
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"));
}
That shape is a little noisier than the Java 21 teaching API, but it makes the policy explicit: join until a subtask succeeds, then select the successful subtask result.
The migrated first-success demo still behaved as expected:
Service-C completed on thread:
First successful result: Service-C result
Service-C is the 200ms branch. The 500ms and 1000ms branches did not decide the parent result.
The Java 21 branch had four executable scope.joinUntil(...) calls:
AdvancedStructuredPatterns.java:348
AdvancedStructuredPatterns.java:472
HierarchicalProgressiveHandler.java:107
ProgressiveResultsBenchmark.java:512
Those calls belonged to polling-style progressive and conditional flows. In the current Java 25 code, those loops use short sleeps and inspect subtask state:
while (totalCompleted < subtasks.size() && Instant.now().isBefore(maxTime)) {
Thread.sleep(50);
for (int i = 0; i < subtasks.size(); i++) {
var subtask = subtasks.get(i);
switch (subtask.state()) {
case SUCCESS -> ...
case FAILED -> ...
case UNAVAILABLE -> ...
}
}
}
scope.join();
This is the least satisfying part of the migration, and the Article 8 timeout result shows why. A deadline checked after join() is not the same as a deadline that stops waiting at the boundary.
For the progressive demos, polling is acceptable learning code because the purpose is to publish progress while subtasks complete. For hard timeout enforcement, the wrapper needs more care than "replace joinUntil with join and check the clock later."
That is why Part 8 called out this result:
GET /timeout/short
Request timed out after 503ms
The configured deadline in that code is 300ms, but the slow branch takes 500ms and the timeout is observed after joining. The migration compiled, but the behavior is not the same as a wait-boundary timeout.
This rule mattered during the Article 4 work and it matters even more in Java 25.
The migrated progressive code only reads subtask results after the owner has joined the scope. In ProgressiveHierarchicalDemo.java and HierarchicalProgressiveHandler.java, the successful run produced:
Progressive execution completed: 5/5 tasks in 438ms
Hierarchical Results Summary: Duration: 1748 ms, Success: Yes
E-commerce Order Results: Processing Time: 367 ms, Status: Success
Earlier in the series, the broken migrated shape produced:
java.lang.IllegalStateException: join not called
java.lang.IllegalStateException: Owner did not join after forking
That failure was not incidental. It was the runtime enforcing the same ownership rule this series has been teaching: the parent that forks the work owns the join boundary.
Do not treat Subtask::get as a normal future read. In this API shape, reading a result before the owner has joined is a structure violation.
Some Java migration guides discuss moving from ScopedValue.runWhere and callWhere to fluent ScopedValue.where(...) style. That is true as a general Java API history point, but it is not what happened in this repository.
The feature/java-21 branch already used:
ScopedValue.where(USER_ID, userId)
.where(REQUEST_ID, requestId)
.where(CORRELATION_ID, "corr-" + requestId)
.run(() -> handleBusinessLogic());
So the repo migration for ScopedValueExample.java was still about structured scopes, not scoped-value syntax. ShutdownOnFailure changed to StructuredTaskScope.open(...), and throwIfFailed() disappeared.
The current run also showed a useful behavior distinction. Subtasks forked inside a structured scope saw the bound user:
Task 0 completed for test-user
Task 1 completed for test-user
Task 2 completed for test-user
Task 3 completed for test-user
Task 4 completed for test-user
But the separate virtual-thread inheritance demo printed:
Child context - inherited values: unknown
Grandchild context - inherited values: unknown
That output keeps the article honest. In this codebase, scoped values pair well with StructuredTaskScope subtasks. The unstructured child-thread demo is not evidence of inheritance across arbitrary virtual-thread starts.
The migration was spread across a small set of files:
| File | What changed |
|---|---|
| pom.xml | compiler source and target moved from 21 to 25 while keeping --enable-preview |
| ScopedRequestHandler.java | reusable structured helpers moved to StructuredTaskScope.open(...) and joiners |
| ConcurrentServiceLayer.java | advanced service examples migrated from policy subclasses to joiners |
| VirtualThreadMicroservice.java | basic structured endpoints migrated to Java 25 scope construction |
| AdvancedStructuredPatterns.java | progressive and conditional polling paths replaced joinUntil(...) with sleep plus state inspection |
| HierarchicalProgressiveHandler.java | progressive owner-join ordering was made explicit for Java 25 |
| ProgressiveResultsBenchmark.java | benchmark progressive and hierarchical helpers migrated to joiners |
| ScopedValueExample.java | structured scopes migrated; scoped-value binding syntax was already fluent on the Java 21 branch |
Several other example classes changed in the same mechanical way, but these are the files that explain the series.
A migration review should start with the policy, not the syntax. A Java 21 ShutdownOnFailure scope should become a Java 25 scope whose joiner still fails the parent when any required child fails. A Java 21 ShutdownOnSuccess scope should become an explicit first-success policy, and the migrated code should show how it selects the winning result. Any Java 21 joinUntil(...) call deserves special attention because replacing it with join() plus a clock check can change timeout behavior.
After that, review ownership. The owner that forks must join before reading results. The Article 4 failure proved this with join not called and Owner did not join after forking. The migrated Article 4 run proved the fixed shape by completing the progressive and hierarchical demos.
Then run the code paths that represent the policies, not only the build. For this repository, the useful smoke checks were first-success, aggregate fan-out, progressive results, scoped values inside structured subtasks, timeout behavior, and the two HTTP aggregate endpoints.
Finally, keep the preview boundary small. OpenJDK delivered the Java 25 structured-concurrency changes in JEP 505, and Java 26 has already continued the preview in JEP 525 with more API movement. That is not a reason to avoid learning the model. It is a reason to keep the API close to helper classes and away from every business method.
This series started with a simple question: what does structured concurrency give us that futures and callbacks do not?
After nine articles, the answer is still ownership. The parent owns the sibling tasks. The scope owns their lifetime. The policy owns success, failure, timeout, fallback, and cancellation behavior. The tests and benchmarks make that ownership visible.
Java 21 taught the model with ShutdownOnFailure, ShutdownOnSuccess, joinUntil, and throwIfFailed. Java 25 moved that model toward StructuredTaskScope.open(...) and Joiner. The exact API is still preview. The habit is the durable part: related concurrent work should have one local owner, one visible policy, and one place where the result becomes real.