java 107 lines · 4 tabs

Aggregating Parallel Downstream Calls With CompletableFuture in a Spring @Async Service

Shared by codesnips Aug 2026
4 tabs
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "downstreamExecutor")
    public ThreadPoolTaskExecutor downstreamExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("downstream-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}
4 files · java Explain with highlit

This snippet shows how a Spring Boot service fans out to several independent downstream systems in parallel and joins their results into one aggregated response, instead of calling them one after another. The pattern matters whenever a single endpoint has to stitch together data from multiple microservices — a naive sequential implementation pays the sum of every latency, while a parallel one pays roughly the slowest single call.

In AsyncConfig, a dedicated ThreadPoolTaskExecutor bean named downstreamExecutor is registered and @EnableAsync is switched on. Giving the async work its own bounded pool is deliberate: it isolates outbound I/O from the Tomcat request threads, and setThreadNamePrefix makes the stacks readable in logs. Relying on the auto-configured SimpleAsyncTaskExecutor is avoided because it spawns an unbounded number of threads.

In ProfileClient, each downstream call is wrapped in an @Async("downstreamExecutor") method that returns CompletableFuture<T>. The key subtlety is that the method itself calls the blocking RestTemplate and then wraps the value with CompletableFuture.completedFuture(...); because the method is proxied as @Async, Spring runs the whole body on the executor and hands back a real, already-executing future. A brittle exceptionally fallback lets one flaky dependency degrade to an empty value rather than fail the entire aggregation, which is the common trade-off between completeness and availability.

In ProfileAggregationService, the three futures are kicked off up front so they run concurrently, then CompletableFuture.allOf(...) waits for all of them. Using .get(2, TimeUnit.SECONDS) on the combined future enforces a single overall budget; on TimeoutException the code cancels outstanding work and throws a domain exception. After the join, each individual .join() is non-blocking because completion is guaranteed. assembleProfile merges the parts into the response DTO.

In ProfileController, the endpoint simply delegates and returns the assembled DTO. A pitfall worth noting: @Async only works through the Spring proxy, so the client methods must be invoked from a different bean (as they are here) rather than via a self-call, which would bypass the proxy and run synchronously. Reaching for this pattern makes sense when downstream calls are independent and the aggregate latency, not throughput, is the bottleneck.


Related snips

Share this code

Here's the card — post it anywhere.

Aggregating Parallel Downstream Calls With CompletableFuture in a Spring @Async Service — share card
Link copied