A quote request needs to fetch price and stock from two independent services. The response only makes sense when both queries finish successfully. If one fails, the other is no longer useful; if the operation reaches its deadline, both should stop. The expected result is either a complete QuoteView or one predictable failure, never half a quote accompanied by a task forgotten in the executor.
This fan-out fits in a few lines until cancellation, timeout, and exception propagation enter the picture. With CompletableFuture, the application is responsible for those details. Java 25's StructuredTaskScope places the tasks under the same lifetime as the request: the owner thread opens the scope, creates the children, waits, reads the results, and closes the scope.
The benefit here is not a promise of speed. It makes the rule “either they all finish, or none keeps running” explicit. It looks obvious once written down. Before that, it usually lives in the finally block scheduled for the next sprint.
Versions, support, and the preview flag
This example uses Spring Boot 4.1.1 with Java 25 LTS. The official Spring Boot support matrix says that version 4.1.1 requires Java 17 or later and supports up to Java 26, so Java 25 is within the supported range.
Structured concurrency remains a preview API in JDK 25, defined by JEP 505. That requires --enable-preview during compilation, testing, and execution, and it means accepting possible API changes in a future JDK update. Maven can be configured like this:
<properties>
<java.version>25</java.version>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-preview</argLine>
</configuration>
</plugin>
</plugins>
</build>
The flag must also be passed to the JVM when running the packaged artifact:
java --enable-preview -jar target/quotes-api.jar
| Component | Version | Role in the example |
|---|---|---|
| Java | 25 LTS | StructuredTaskScope API and virtual threads for child tasks |
| Spring Boot | 4.1.1 | HTTP endpoint and application lifecycle |
| Maven Compiler/Surefire | version managed by the project | Enable preview features in code and tests |
The scope belongs to the request
The API flow in JDK 25 is open → fork → join → read the results → close. The try-with-resources statement guarantees closure. All these control operations belong to the owner thread, which in this case is the same thread handling the request.
This means you do not put @Async on the method that opens the scope or hand the scope to Spring's TaskExecutor. By default, StructuredTaskScope already creates unnamed virtual threads to run its children; it is not an ExecutorService and does not depend on applicationTaskExecutor.
The service can combine two dependencies of different types with Joiner.awaitAllSuccessfulOrThrow():
package academy.devdojo.quotes;
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
public final class QuoteService {
private final PriceClient prices;
private final InventoryClient inventory;
public QuoteService(PriceClient prices, InventoryClient inventory) {
this.prices = prices;
this.inventory = inventory;
}
public QuoteView load(String sku) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
config -> config.withTimeout(Duration.ofSeconds(2)))) {
var price = scope.fork(() -> prices.get(sku));
var stock = scope.fork(() -> inventory.get(sku));
scope.join();
return new QuoteView(price.get(), stock.get());
} catch (StructuredTaskScope.FailedException failure) {
throw new UpstreamCallException(failure.getCause());
} catch (StructuredTaskScope.TimeoutException timeout) {
throw new UpstreamDeadlineException(timeout);
}
}
}
The timeout clock starts at open. If one child fails or the deadline expires, the scope cancels unfinished children through interruption. close() waits for them to finish, so PriceClient and InventoryClient must use operations that respond to interruption. A blocked driver or client that ignores interruption does not acquire cancellation by osmosis: closing the scope will keep waiting.
In the controller, the call remains synchronous on the request thread. If join() throws InterruptedException, restore the interruption status before converting it into an application response:
@GetMapping("/quotes/{sku}")
QuoteView quote(@PathVariable String sku) {
try {
return quoteService.load(sku);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"request interrupted",
interrupted);
}
}
To observe this in production, record a counter for each outcome: success, dependency failure, deadline, and request interruption. Add a structured log with the operation identifier and the cause's class. Avoid logging complete service responses. Besides creating noise, they have an unpleasant habit of containing data nobody intended to put in a log.
Choosing the Java 25 Joiner
The completion policy lives in the Joiner, not in subclasses or public constructors. The three most useful cases for fan-out are:
Joiner.awaitAllSuccessfulOrThrow(): waits for all tasks, returnsVoid, and lets you collect heterogeneous results from theSubtaskobjects;Joiner.allSuccessfulOrThrow(): waits for all tasks and returns aStreamof subtasks, which is convenient when the results share the same type;Joiner.anySuccessfulResultOrThrow(): returns the first successful result and cancels the others, suitable for a race between equivalent sources.
The Java 25 0 documentation also describes the owner-thread rules and subtask states. Call get() only after a successful join(). After a failure or timeout, a cancelled subtask may not have a result available.
For a homogeneous collection, the shape changes very little:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Offer>allSuccessfulOrThrow(),
config -> config.withTimeout(Duration.ofSeconds(2)))) {
skus.forEach(sku -> scope.fork(() -> offerClient.fetch(sku)));
return scope.join()
.map(StructuredTaskScope.Subtask::get)
.toList();
}
If the list can grow while tasks are being forked, check scope.isCancelled() in the loop and stop creating work once the scope has been cancelled. For safe observability, measure the number of children created per request and alert when it grows beyond the domain-defined limit, rather than discovering accidental fan-out on the external service bill.
The same coordination with CompletableFuture
CompletableFuture.allOf remains valid, but it does not provide the same lifetime policy by construction. At comparable complexity, the application must supply the executor, apply the deadline, and decide what to do with each future on an exceptional exit:
var price = CompletableFuture.supplyAsync(
() -> prices.getUnchecked(sku), executor);
var stock = CompletableFuture.supplyAsync(
() -> inventory.getUnchecked(sku), executor);
try {
CompletableFuture.allOf(price, stock).get(2, TimeUnit.SECONDS);
return new QuoteView(price.join(), stock.join());
} catch (Exception failure) {
price.cancel(true);
stock.cancel(true);
throw failure;
}
A failure in price does not make allOf cancel stock automatically. Also, the interruption argument to CompletableFuture.cancel(true) does not guarantee that a computation started by supplyAsync will be interrupted. To match the scope's behavior, you still need to define the executor strategy, coordinate shutdown, and observe tasks that outlive the request.
That does not make CompletableFuture wrong or StructuredTaskScope faster. CF is a better choice when the graph deliberately crosses threads and lifetimes, when the application already has a stable asynchronous composition, or when the result will be completed far from the point that started the work. STS is easier to read when the children exist only to produce their parent's response.
Verifying that a failure interrupts the sibling
A useful test does not need to measure time. It needs to prove that the blocked child started, the other child failed, join() reported FailedException, and the blocked child received an interruption:
@Test
void failingChildInterruptsSleepingSibling() throws Exception {
var sleeperStarted = new CountDownLatch(1);
var siblingInterrupted = new AtomicBoolean(false);
var expected = new IllegalStateException("inventory unavailable");
try (StructuredTaskScope<Object, Void> scope =
StructuredTaskScope.open()) {
scope.fork(() -> {
sleeperStarted.countDown();
try {
Thread.sleep(Duration.ofMinutes(1));
return "unexpected";
} catch (InterruptedException interrupted) {
siblingInterrupted.set(true);
throw interrupted;
}
});
scope.fork(() -> {
sleeperStarted.await();
throw expected;
});
var failure = assertThrows(
StructuredTaskScope.FailedException.class,
scope::join);
assertSame(expected, failure.getCause());
}
assertTrue(siblingInterrupted.get());
}
A local smoke test with Java 25.0.4 and --enable-preview compiled this API path: the default joiner, FailedException, and interruption of the sleeping sibling. The test records only pass or fail; it does not demonstrate performance.
Three diagnostics deserve their own CI gate and their own production signal:
| Problem | Concrete fix | Safe production observation |
|---|---|---|
| Preview enabled during compilation but not in tests or at runtime | Configure Compiler, Surefire, and the java command with --enable-preview | Fail the startup smoke test in the same image used for deployment |
| I/O client ignores interruption | Set a timeout in the client itself and test cancellation with a controlled dependency | Count scopes closing after their deadline and external calls that remain active |
| Scope controlled by another thread | Keep open, fork, join, and close in the method called by the controller | Treat WrongThreadException as a programming error and alert on it, rather than retrying |
Use StructuredTaskScope.TimeoutException when handling the deadline. java.util.concurrent.TimeoutException belongs to other APIs and does not catch this case. Do not try to read Subtask.get() after join() fails, either; handle the cause of FailedException and end the response.
When not to use it
STS is not the tool for CPU-bound work that calls for recursive splitting; ForkJoinPool or parallel streams solve a different kind of problem. It also does not fix I/O that ignores interruption, continuous streaming between tasks, unbounded queues, or distributed coordination between services. A huge fan-out is still a huge fan-out, only better organized now.
DevDojo would adopt the API in a service already pinned to Java 25 that consciously accepts the preview contract and has a small, request-bound fan-out with clients proven to be interruptible. We would keep CompletableFuture when the flow crosses the request lifetime, depends on an existing asynchronous graph, or when the team's policy does not allow preview APIs in production.
The next step is small: choose an endpoint with two independent dependencies, put the scope on the request thread, and run the interruption test. Then trigger a failure and a deadline in a controlled environment and check all four outcomes in the logs and counters. If the blocked child does not finish, the problem is not the joiner syntax; it is the dependency that refuses to cooperate with cancellation, exactly the kind of useful truth a test should reveal early.