skip to content

What is the difference between thenApply and thenCompose, and when must you use thenCompose?

level: middleimportance: must knowfreq 85%

answer

  1. Function returns value → thenApply (map); returns a future → thenCompose (flatMap)
  2. thenApply on a future-returning fn → CF<CF<T>> nesting
  3. thenCompose = monadic flatMap, flattens one layer
  4. IDE shows CF<CF<...>> = you needed thenCompose

basics

~10 s

Use thenApply when your function returns a plain value. Use thenCompose when your function itself returns another CompletableFuture, so you chain asynchronous steps without ending up with a CompletableFuture wrapped inside a CompletableFuture.

solid answer

~40 s

thenApply takes a Function<T,R> that returns a plain value R, giving you CompletableFuture<R>. thenCompose takes a Function<T, CompletionStage<R>> — a function that returns another future — and flattens it to CompletableFuture<R>. The distinction matters when the next step is itself asynchronous, e.g. lookup a user, then call a remote service that returns CompletableFuture<Order>. If you use thenApply there, R is itself CompletableFuture<Order>, so you get CompletableFuture<CompletableFuture<Order>> — a nested future you'd have to manually unwrap (the old join().join() pain). thenCompose is the monadic flatMap: it subscribes to the inner future and completes the outer stage with the inner's result, so you get a flat CompletableFuture<Order>. Rule of thumb: function returns a value → thenApply (map); function returns a CompletableFuture → thenCompose (flatMap).

code

java · 12 lines
java
CompletableFuture<Long> userId = getUserId();

// WRONG: nested future
CompletableFuture<CompletableFuture<User>> nested =
        userId.thenApply(id -> fetchUser(id)); // fetchUser returns CompletableFuture<User>

// RIGHT: flat future via flatMap
CompletableFuture<User> user =
        userId.thenCompose(id -> fetchUser(id));

// Recover from an accidental nesting:
CompletableFuture<User> flattened = nested.thenCompose(inner -> inner);

go deeper

for a junior

Can state the rule of thumb: plain value → thenApply, future → thenCompose, and recognizes the nesting problem by name.

for a middle

Explains map vs flatMap, shows the CF<CF<T>> nesting concretely, and picks correctly for a 'lookup then remote call' pipeline.

for a senior

Articulates the monadic flatMap analogy, exception/threading semantics, and how Async governs only the composing function, not the inner stage.

for a principal

Connects it to general monad laws and library design, reasons about composability across CompletionStage implementations, and guides teams away from nested-future anti-patterns in shared code.

## The core problem: async steps that depend on async steps Real pipelines chain operations where **each step may itself be asynchronous**. Suppose: 1. `getUserId()` returns `CompletableFuture<Long>`. 2. `fetchUser(Long)` returns `CompletableFuture<User>` (it calls a remote service). You want a single `CompletableFuture<User>`. How you combine these is where `thenApply` and `thenCompose` diverge. ## thenApply = map (function returns a plain value) `thenApply` has signature `CompletableFuture<R> thenApply(Function<? super T, ? extends R>)`. The function maps `T → R`, a **plain value**. If you naively write `getUserId().thenApply(id -> fetchUser(id))`, the lambda returns a `CompletableFuture<User>`, so `R = CompletableFuture<User>` and the result type becomes: ``` CompletableFuture<CompletableFuture<User>> ``` That is a **nested future**. To get the `User` you'd have to do `.get().get()` or `.join().join()` — blocking, ugly, and error-prone. `thenApply` does **not** look inside the returned value; it just wraps whatever the function returns. ## thenCompose = flatMap (function returns a future) `thenCompose` has signature `CompletableFuture<R> thenCompose(Function<? super T, ? extends CompletionStage<R>>)`. The function maps `T → CompletionStage<R>` (a future). thenCompose **subscribes to that inner future** and completes its own stage with the inner result, **flattening** one layer: ```java CompletableFuture<User> user = getUserId().thenCompose(id -> fetchUser(id)); // flat — no nesting ``` This is exactly the **monadic flatMap** (or `bind`) operation: `map` produces `M<M<R>>`, `flatMap` collapses it to `M<R>`. `CompletionStage` is the interface `CompletableFuture` implements; `thenCompose` accepts any `CompletionStage`, which is why the signature uses it. ## The decision rule - Function returns a **value** → `thenApply` (map). - Function returns a **CompletableFuture / CompletionStage** → `thenCompose` (flatMap). A common smell: if your IDE shows the stage type as `CompletableFuture<CompletableFuture<...>>`, you used `thenApply` where you needed `thenCompose`. ## Threading and exceptions (same as the other transforms) - Both run only on **normal** completion of the upstream; an upstream failure skips the function and propagates. - An exception thrown *inside* the function (or an inner future that completes exceptionally, for `thenCompose`) completes the resulting stage exceptionally. - `thenComposeAsync` runs the **composing function** on the common ForkJoinPool / supplied executor. Note: with `thenCompose`, the *inner* future runs wherever *it* was scheduled — `Async` only governs where your composing lambda is invoked, not the inner stage's own work. ## Analogy with Optional / Stream If you know `Optional` or `Stream`: `map` transforms the contained value; `flatMap` is for when your transform *itself* returns an `Optional`/`Stream` and you want one flat layer. `thenApply` : `thenCompose` :: `map` : `flatMap`.

  • What is the concrete type if you call thenApply with a function that returns a CompletableFuture<Order>?
    CompletableFuture<CompletableFuture<Order>> — a nested future. You'd have to unwrap it manually (e.g. .thenCompose(x -> x), or blocking join().join()). thenCompose avoids the nesting by flattening to CompletableFuture<Order>.
  • How would you flatten an accidental CompletableFuture<CompletableFuture<T>> back to CompletableFuture<T>?
    Call thenCompose with the identity function: nested.thenCompose(inner -> inner). This subscribes to the inner future and completes the outer stage with its result, collapsing the two layers into one.

thenApply is Optional.map / Stream.map; thenCompose is Optional.flatMap / Stream.flatMap. flatMap is what you reach for when the function you apply already returns the same wrapper type, so you don't end up double-wrapped.

saying these in an interview costs you the question

  • Saying thenApply 'unwraps' the returned future — it does not; you get nesting.
  • Using thenApply for a remote/async call that returns a CompletableFuture.
  • Claiming thenCompose can take a function returning a plain value (it must return a CompletionStage).
  • Thinking the difference is about threads — it's about value vs future return type (flatten or not).

context