Uma requisição de cotação precisa buscar preço e estoque em dois serviços independentes. A resposta só faz sentido quando as duas consultas terminam bem. Se uma falhar, a outra deixa de ter utilidade; se o prazo da operação expirar, ambas devem parar. O resultado esperado é um QuoteView completo ou uma falha única e previsível — nunca meia cotação acompanhada de uma tarefa esquecida no executor.
Esse fan-out cabe em poucas linhas até surgirem cancelamento, timeout e propagação de exceção. Com CompletableFuture, esses detalhes ficam por conta da aplicação. A StructuredTaskScope do Java 25 coloca as tarefas sob o mesmo tempo de vida da requisição: o thread dono abre o escopo, cria os filhos, espera, lê os resultados e fecha o escopo.
O ganho aqui não é uma promessa de velocidade. É tornar explícita a regra “ou todos terminam, ou nenhum continua”. Parece óbvio depois de escrito. Antes disso, costuma morar naquele finally que seria adicionado na próxima sprint.
Versões, suporte e a flag de preview
A combinação usada é Spring Boot 4.1.1 com Java 25 LTS. A matriz oficial do Spring Boot informa que a versão 4.1.1 requer Java 17 ou superior e aceita até Java 26, portanto o Java 25 está dentro do conjunto suportado.
A structured concurrency continua sendo uma API de preview no JDK 25, definida pela JEP 505. Isso exige --enable-preview na compilação, nos testes e na execução, além de aceitar possíveis mudanças de API em uma atualização futura do JDK. O Maven pode ser configurado assim:
<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>
Para executar o artefato empacotado, a flag também acompanha a JVM:
java --enable-preview -jar target/quotes-api.jar
| Componente | Versão | Papel no exemplo |
|---|---|---|
| Java | 25 LTS | API StructuredTaskScope e virtual threads dos filhos |
| Spring Boot | 4.1.1 | Endpoint HTTP e ciclo da aplicação |
| Maven Compiler/Surefire | versão gerenciada pelo projeto | Habilitar preview em código e testes |
O escopo pertence à requisição
O fluxo da API no JDK 25 é open → fork → join → leitura dos resultados → close. O try com recursos garante o fechamento. Todas essas operações de controle pertencem ao thread dono, que, neste caso, é o mesmo thread que atende a requisição.
Isso significa que não se coloca @Async no método que abre o escopo e não se entrega o escopo ao TaskExecutor do Spring. A StructuredTaskScope já cria, por padrão, virtual threads sem nome para executar os filhos; ela não é um ExecutorService e não depende do applicationTaskExecutor.
O serviço pode consolidar duas dependências de tipos diferentes com 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);
}
}
}
O relógio do timeout começa em open. Se um filho falhar ou o prazo expirar, o escopo cancela os filhos inacabados por interrupção. O close() espera que eles terminem; portanto, PriceClient e InventoryClient precisam usar operações que respondam à interrupção. Um driver ou cliente bloqueado que a ignore não ganha cancelamento por osmose: o fechamento continuará esperando.
No controller, a chamada permanece síncrona no thread da requisição. Se join() lançar InterruptedException, restaure o estado de interrupção antes de convertê-la em resposta da aplicação:
@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);
}
}
Para observar isso em produção, registre um contador por desfecho — sucesso, falha de dependência, deadline e interrupção da requisição — e um log estruturado com o identificador da operação e a classe da causa. Evite registrar respostas completas dos serviços. Além de ruído, elas têm o hábito desagradável de conter dados que ninguém pretendia colocar no log.
Escolhendo o Joiner do Java 25
A política de conclusão fica no Joiner, não em subclasses ou construtores públicos. Os três casos mais úteis para fan-out são:
Joiner.awaitAllSuccessfulOrThrow(): espera todas as tarefas, retornaVoide permite reunir resultados heterogêneos pelos objetosSubtask;Joiner.allSuccessfulOrThrow(): espera todas e devolve umStreamde subtarefas, conveniente quando os resultados têm o mesmo tipo;Joiner.anySuccessfulResultOrThrow(): devolve o primeiro resultado bem-sucedido e cancela os demais, adequado para uma corrida entre fontes equivalentes.
A documentação da 0 no Java 25 também mostra as regras do thread dono e os estados das subtarefas. Chame get() somente depois de um join() bem-sucedido. Após falha ou timeout, uma subtarefa cancelada pode não ter resultado disponível.
Para uma coleção homogênea, a forma muda pouco:
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();
}
Se a lista puder crescer durante o fork, verifique scope.isCancelled() no laço e pare de criar trabalho quando o escopo já estiver cancelado. A observação segura é medir a quantidade de filhos criada por requisição e alertar para crescimento fora do limite definido pelo domínio, em vez de descobrir o fan-out acidental pela conta do serviço externo.
A mesma coordenação com CompletableFuture
CompletableFuture.allOf continua válido, mas não oferece a mesma política de vida útil por construção. Em complexidade equivalente, a aplicação precisa fornecer o executor, aplicar o prazo e decidir o que fazer com cada futuro na saída excepcional:
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;
}
Uma falha em price não faz allOf cancelar stock automaticamente. Além disso, o argumento de interrupção de CompletableFuture.cancel(true) não garante que a computação iniciada por supplyAsync seja interrompida. Para equivaler ao escopo, ainda é preciso definir a estratégia do executor, coordenar o encerramento e observar tarefas que sobreviveram à requisição.
Isso não torna CompletableFuture errado nem a StructuredTaskScope mais rápida. CF é uma escolha melhor quando o grafo deliberadamente atravessa threads e tempos de vida, quando a aplicação já possui uma composição assíncrona estável ou quando o resultado será completado longe do ponto que iniciou o trabalho. STS é mais legível quando os filhos existem apenas para produzir uma resposta do pai.
Verificando que a falha interrompe o irmão
Um teste útil não precisa medir tempo. Ele precisa provar que o filho bloqueado começou, que o outro filho falhou, que join() reportou FailedException e que o bloqueado recebeu interrupção:
@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());
}
O smoke local com Java 25.0.4 e --enable-preview compilou esse caminho da API: joiner padrão, FailedException e interrupção do irmão adormecido. O teste registra apenas passou ou falhou; ele não demonstra desempenho.
Há três diagnósticos que merecem gate próprio no CI e sinal próprio em produção:
| Problema | Correção concreta | Observação segura em produção |
|---|---|---|
| Preview habilitado na compilação, mas não nos testes ou runtime | Configurar Compiler, Surefire e comando java com --enable-preview | Falhar o smoke de inicialização na mesma imagem usada no deploy |
| Cliente de I/O ignora interrupção | Definir timeout no próprio cliente e testar cancelamento com uma dependência controlada | Contar scopes fechando após deadline e chamadas externas ainda ativas |
| Escopo controlado por outro thread | Manter open, fork, join e close no método chamado pelo controller | Tratar WrongThreadException como erro de programação e alertar, não como retry |
Use StructuredTaskScope.TimeoutException no tratamento do prazo. java.util.concurrent.TimeoutException pertence a outras APIs e não captura esse caso. Também não tente ler Subtask.get() depois que join() falhou; trate a causa de FailedException e encerre a resposta.
Quando não usar
STS não é a ferramenta para trabalho CPU-bound que pede divisão recursiva; ForkJoinPool ou streams paralelos atendem outro tipo de problema. Também não resolve I/O que ignora interrupção, streaming contínuo entre tarefas, filas sem limite ou coordenação distribuída entre serviços. Um fan-out enorme continua sendo um fan-out enorme, agora apenas mais bem organizado.
A DevDojo adotaria a API em um serviço que já está fixado no Java 25, aceita conscientemente o contrato de preview e possui fan-out pequeno, limitado pela request, com clientes comprovadamente interruptíveis. Manteria CompletableFuture quando o fluxo atravessa o tempo de vida da requisição, depende de um grafo assíncrono existente ou quando a política da equipe não permite API de preview em produção.
O próximo passo é pequeno: escolha um endpoint com duas dependências independentes, coloque o escopo no thread da requisição e execute o teste de interrupção. Depois provoque falha e deadline em ambiente controlado e confira os quatro desfechos nos logs e contadores. Se o filho bloqueado não terminar, o problema não está na sintaxe do joiner; está na dependência que não coopera com cancelamento — exatamente o tipo de verdade útil que um teste deve trazer cedo.