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.
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:
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>>.
Without thenCompose, using thenApply with a function that returns a CompletableFuture produces nested futures:
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:
| Concept | Stream | Optional | CompletableFuture |
| Transform value | map | map | thenApply |
| Flatten nested wrapper | flatMap | flatMap | thenCompose |
| 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:
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):
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:
The handle method is more flexible because it receives both the result and the exception:
Quick Reference
| Aspect | thenApply | thenCompose |
| Function signature | Function<T, R> | Function<T, CompletionStage<R>> |
| Return type | CompletableFuture<R> | CompletableFuture<R> (flattened) |
| Analogous to | Stream.map, Optional.map | Stream.flatMap, Optional.flatMap |
| Use when | Transformation is synchronous | Next step is itself asynchronous |
| Async variant | thenApplyAsync | thenComposeAsync |
| Exception behavior | Skipped if upstream fails | Skipped 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
thenApplyfor synchronous value transformations where the function returns a plain object. - Use
thenComposewhen the function returns aCompletableFutureand you need to chain asynchronous operations without nesting. - Think of it as the
mapvsflatMapdistinction 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
exceptionallyorhandleto prevent silent failures in the chain.

