Java
CompletableFuture
thenApply
thenCompose
concurrency

CompletableFuture thenApply vs thenCompose

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

thenApply transforms the result of a CompletableFuture using a synchronous function, while thenCompose chains the result into another asynchronous operation that itself returns a CompletableFuture. The distinction mirrors the difference between map and flatMap on streams and optionals: thenApply wraps the return value in a future, whereas thenCompose flattens a nested CompletableFuture<CompletableFuture<T>> into a single CompletableFuture<T>.

thenApply: Synchronous Transformation

thenApply accepts a Function<T, R> and produces a CompletableFuture<R>. The function runs when the upstream future completes, and its return value becomes the result of the new future.

java
1CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> 42)
2    .thenApply(result -> "The answer is " + result);
3
4System.out.println(future.join()); // The answer is 42

The key point is that the function passed to thenApply returns a plain value, not a CompletableFuture. Java wraps that value into a future automatically. This is the right choice when the transformation itself does not involve I/O, network calls, or any other asynchronous work.

Chaining Multiple thenApply Calls

You can chain several thenApply calls for a pipeline of synchronous transformations:

java
1CompletableFuture<String> pipeline = CompletableFuture.supplyAsync(() -> "  Hello World  ")
2    .thenApply(String::trim)
3    .thenApply(String::toLowerCase)
4    .thenApply(s -> s.replace(" ", "-"));
5
6System.out.println(pipeline.join()); // hello-world

Each step runs after the previous one completes, and the result flows through the chain without nesting.

thenCompose: Asynchronous Chaining

thenCompose accepts a Function<T, CompletionStage<R>> and produces a CompletableFuture<R>. The function itself returns a future, and thenCompose flattens the result so you do not end up with a CompletableFuture<CompletableFuture<R>>.

java
1CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> 1001)
2    .thenCompose(userId -> fetchUserFromDatabase(userId));
3
4// fetchUserFromDatabase returns CompletableFuture<String>
5private CompletableFuture<String> fetchUserFromDatabase(int userId) {
6    return CompletableFuture.supplyAsync(() -> "User-" + userId);
7}

Without thenCompose, using thenApply with a function that returns a CompletableFuture produces nested futures:

java
1// Wrong: results in CompletableFuture<CompletableFuture<String>>
2CompletableFuture<CompletableFuture<String>> nested =
3    CompletableFuture.supplyAsync(() -> 1001)
4        .thenApply(userId -> fetchUserFromDatabase(userId));

The nested type is unwieldy and forces an extra join() or get() call to unwrap. thenCompose exists precisely to avoid this.

The map vs flatMap Analogy

If you have worked with Java streams or optionals, the pattern is familiar:

java
1// Stream.map = thenApply (wraps result)
2Stream.of(1, 2, 3).map(n -> n * 2);
3
4// Stream.flatMap = thenCompose (flattens nested structure)
5Stream.of(1, 2, 3).flatMap(n -> Stream.of(n, n * 10));
ConceptStreamOptionalCompletableFuture
Transform valuemapmapthenApply
Flatten nested wrapperflatMapflatMapthenCompose
Resulting type (map)Stream<R>Optional<R>CF<R>
Resulting type (flatMap)Stream<R>Optional<R>CF<R>

Once you see the parallel, choosing between thenApply and thenCompose becomes mechanical: if your function returns a plain value, use thenApply; if it returns a CompletableFuture, use thenCompose.

Real-World Example: Service Call Chain

Consider an order processing pipeline where each step calls an external service:

java
1public CompletableFuture<OrderConfirmation> processOrder(int orderId) {
2    return fetchOrder(orderId)                          // CF<Order>
3        .thenCompose(order -> validateInventory(order)) // CF<Order> (async validation)
4        .thenCompose(order -> chargePayment(order))     // CF<PaymentReceipt> (async)
5        .thenApply(receipt -> buildConfirmation(receipt)) // plain transformation
6        .thenCompose(confirmation -> sendEmail(confirmation)); // CF<Void> (async)
7}

thenCompose is used for each step that calls an external service (returns a future), and thenApply is used for the purely computational transformation that builds the confirmation object.

Async Variants

Both methods have async counterparts that execute the function on a different thread from the ForkJoinPool (or a custom executor):

java
1// Runs the transformation on a ForkJoinPool thread
2future.thenApplyAsync(result -> expensiveTransformation(result));
3
4// Runs on a custom executor
5ExecutorService executor = Executors.newFixedThreadPool(4);
6future.thenComposeAsync(result -> asyncServiceCall(result), executor);

Use the async variants when the transformation or the composed function is CPU-intensive or when you need to control which thread pool handles the work. For lightweight transformations, the non-async versions are sufficient and avoid the overhead of task submission.

Exception Handling

Both thenApply and thenCompose propagate exceptions. If the upstream future completes exceptionally, the downstream function is never called and the exception flows to the next stage. Handle exceptions with exceptionally, handle, or whenComplete:

java
1CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
2        if (true) throw new RuntimeException("Service unavailable");
3        return 42;
4    })
5    .thenApply(n -> "Value: " + n)
6    .exceptionally(ex -> "Fallback: " + ex.getMessage());
7
8System.out.println(result.join()); // Fallback: Service unavailable

The handle method is more flexible because it receives both the result and the exception:

java
1CompletableFuture<String> result = fetchOrder(1001)
2    .thenCompose(order -> chargePayment(order))
3    .handle((receipt, ex) -> {
4        if (ex != null) return "Payment failed: " + ex.getMessage();
5        return "Payment succeeded: " + receipt.getId();
6    });

Quick Reference

AspectthenApplythenCompose
Function signatureFunction<T, R>Function<T, CompletionStage<R>>
Return typeCompletableFuture<R>CompletableFuture<R> (flattened)
Analogous toStream.map, Optional.mapStream.flatMap, Optional.flatMap
Use whenTransformation is synchronousNext step is itself asynchronous
Async variantthenApplyAsyncthenComposeAsync
Exception behaviorSkipped if upstream failsSkipped if upstream fails

Common Pitfalls

Using thenApply when the function returns a CompletableFuture. This is the most frequent mistake. The result becomes CompletableFuture<CompletableFuture<T>>, which is awkward to work with and usually signals a bug. If your lambda returns a future, use thenCompose.

Blocking inside thenApply or thenCompose. Calling .get() or .join() inside the function defeats the purpose of asynchronous chaining. It blocks the ForkJoinPool thread and can cause thread starvation under load. Keep the chain non-blocking end to end.

Ignoring the executor for CPU-heavy transformations. The non-async thenApply runs on whatever thread completes the previous stage. If that stage was completed by a shared pool (like the common ForkJoinPool), a slow transformation can starve other tasks. Use thenApplyAsync with a dedicated executor for expensive work.

Forgetting exception propagation. A failure in any stage silently skips all subsequent thenApply and thenCompose calls until an exceptionally or handle block catches it. Always add error handling at the end of the chain, or at intermediate points where recovery is possible.

Mixing thenCompose with thenCombine. thenCompose is sequential: the second future starts after the first completes. thenCombine runs two futures in parallel and combines their results. Using thenCompose when both operations are independent wastes time by serializing them.

Summary

  • Use thenApply for synchronous value transformations where the function returns a plain object.
  • Use thenCompose when the function returns a CompletableFuture and you need to chain asynchronous operations without nesting.
  • Think of it as the map vs flatMap distinction from streams and optionals.
  • Use async variants (thenApplyAsync, thenComposeAsync) when the function is CPU-intensive or needs to run on a specific thread pool.
  • Always handle exceptions with exceptionally or handle to prevent silent failures in the chain.

Course illustration
Course illustration

All Rights Reserved.