Back to Blog
Java

CompletableFuture thenCompose for Async Chaining

Use CompletableFuture.thenCompose to flatten nested async calls, chain dependent tasks, and handle exceptions in Java async pipelines.

CompletableFutureJava ConcurrencyAsync ProgrammingthenComposeJava Streams
Illustration of two chained CompletableFuture boxes flattening into one result box

When you work with CompletableFuture in Java, you often need to run one asynchronous operation after another, and the second operation depends on the result of the first. The thenCompose method supports this pattern directly: it chains two dependent async tasks and flattens the result so you do not end up with a nested CompletableFuture<CompletableFuture<T>>. This article explains how thenCompose works, how it differs from thenApply, and where it fits in a real asynchronous pipeline.

The Problem: Nested CompletableFuture

Imagine you have a method that returns CompletableFuture<Order> and another that takes an Order and returns CompletableFuture<Invoice>. If you try to combine them with thenApply, the result is a CompletableFuture<CompletableFuture<Invoice>>. That nested structure is awkward to work with: you have to call join() or get() twice, and error handling becomes messy. The following code shows the issue:

CompletableFuture<Order> orderFuture = fetchOrder(orderId); CompletableFuture<CompletableFuture<Invoice>> nested = orderFuture.thenApply(order -> generateInvoice(order));

thenApply expects a function that returns a plain value. If that function returns a CompletableFuture, the framework does not flatten it. The result is a future that completes with another future, which is rarely what you want.

thenCompose vs thenApply: The Core Difference

The key difference is the return type of the function you pass. thenApply takes a Function<T, U> and returns CompletableFuture<U>. thenCompose takes a Function<T, CompletableFuture<U>> and returns CompletableFuture<U> directly. In other words, thenCompose flattens the nested future so you get a single CompletableFuture that completes with the final value.

MethodFunction return typeResult typeUse case
thenApplyU (plain value)CompletableFuture<U>Transform a result synchronously
thenComposeCompletableFuture<U>CompletableFuture<U>Chain a dependent async operation

The distinction matters because thenCompose is designed for composition of asynchronous operations. It is analogous to flatMap in the Stream API, whereas thenApply is like map.

Minimal Example: Chaining Two Dependent Async Calls

Here is a practical example. Suppose you have a user service that fetches a user profile, and a second service that fetches the user's account details using the profile ID. Both are asynchronous and return CompletableFuture.

CompletableFuture<UserProfile> profileFuture = userService.fetchProfile(userId); CompletableFuture<AccountDetails> accountFuture = profileFuture.thenCompose(profile -> accountService.fetchAccountDetails(profile.getAccountId()) );

The function passed to thenCompose returns a CompletableFuture<AccountDetails>, and thenCompose flattens it. The resulting accountFuture completes with the AccountDetails directly, not with a nested future. This is the core benefit: you can chain as many dependent async steps as needed without accumulating nesting.

You can also combine thenCompose with other methods. For example, after fetching the account, you might want to transform it synchronously with thenApply:

CompletableFuture<String> accountNameFuture = accountFuture.thenApply(AccountDetails::getDisplayName);

This shows how thenCompose and thenApply work together in a pipeline.

Error Handling and Exception Propagation

When an exception occurs in any stage of a thenCompose chain, the resulting CompletableFuture completes exceptionally. The exception propagates through the chain. At the end, you can use exceptionally or handle to recover with a fallback, or whenComplete to observe the outcome without changing whether the future completed normally or exceptionally. Consider this example:

CompletableFuture<AccountDetails> accountFuture = profileFuture .thenCompose(profile -> accountService.fetchAccountDetails(profile.getAccountId())) .exceptionally(ex -> { System.err.println("Failed to fetch account: " + ex.getMessage()); return AccountDetails.empty(); });

If fetchProfile fails, the thenCompose stage is never executed, and the exception is passed to exceptionally. The same happens if fetchAccountDetails fails. This behavior is consistent with other CompletableFuture composition methods.

One subtle point: if the function passed to thenCompose itself throws an exception (rather than returning a failed future), that exception is also captured and completes the resulting future exceptionally. So you do not need to wrap the body in a try-catch unless you want to handle it locally.

When thenCompose Is the Right Choice

Use thenCompose when the next step depends on the result of the previous step and that next step is itself asynchronous. Common scenarios include:

  • Fetching a resource by ID obtained from a previous API call.
  • Performing a write operation after a read, where the write needs data from the read.
  • Calling a remote service that returns a CompletableFuture and needs a value from an earlier call.

If the next step is synchronous, use thenApply. If you need to combine two independent futures, use thenCombine. If you need to run several independent futures and wait for all, use allOf. The choice depends on the dependency structure.

Performance and Concurrency Considerations

thenCompose adds a dependent stage to the future graph, so the real cost is usually the asynchronous operations you chain. One concurrency detail matters, though. The plain thenCompose action is not executed by an Async method; it may run on the thread that completes the previous stage. With thenComposeAsync, the function runs on a supplied executor. By default, the *Async variants that do not take an explicit executor use the common ForkJoinPool. If the chained function does blocking work, use thenComposeAsync with a dedicated executor to avoid blocking the completing thread.

ExecutorService executor = Executors.newFixedThreadPool(4); CompletableFuture<AccountDetails> accountFuture = profileFuture.thenComposeAsync(profile -> accountService.fetchAccountDetails(profile.getAccountId()) , executor);

This is important in production systems where you want to avoid blocking the common pool or where you need to isolate workloads. The choice between thenCompose and thenComposeAsync depends on whether you want the function to run on the completing thread or on a separate executor.

Common Pitfalls and How to Avoid Them

One common mistake is using thenApply when the function returns a CompletableFuture, resulting in a nested future. Another is forgetting that thenCompose does not run the function asynchronously by default; if the function performs blocking work, it can block the completing thread. In that case, use thenComposeAsync with a dedicated executor.

Avoid leaving failures unobserved. If no stage in the chain handles an exception, the exception is stored in the resulting future and is not surfaced until code calls join() or get(). Attach exceptionally or handle when you need a fallback, or make sure the caller inspects the future for exceptional completion.

Finally, be careful with variable capture in lambdas. A lambda can capture only effectively final local variables, so you cannot reassign a counter inside a thenCompose lambda. You can still mutate an effectively final collection or use an AtomicInteger or another mutable holder when you need shared state.

A practical pattern is to combine thenCompose with thenApply for a multi-stage pipeline where some steps are synchronous and others are asynchronous. For example:

CompletableFuture<Report> reportFuture = fetchData() .thenCompose(data -> processDataAsync(data)) .thenApply(report -> enrichReport(report));

This keeps the code readable and avoids nesting while maintaining a clear flow of data through the pipeline. The final future completes with the Report object, and any exception in any stage is propagated to the caller.

Understanding how thenCompose flattens futures is essential for writing clean asynchronous code in Java. It lets you express dependent async operations without the clutter of nested callbacks or manual unwrapping. By choosing the right composition method for each step, you can build maintainable and reliable async pipelines.

CompletableFuture.thenCompose: Async Chaining and Error Handling in Java | RYUSLOG DEV