⏱️ Section 19 · Question #4

What is the Difference Between thenApply() and thenCompose()

Both thenApply() and thenCompose() are used to chain transformation stages in an asynchronous CompletableFuture pipeline, but they differ fundamentally in the signature of the a...


🟢 Junior Level

Both thenApply() and thenCompose() are used to chain transformation stages in an asynchronous CompletableFuture pipeline, but they differ fundamentally in the signature of the accepted function and how the resulting stage is unpacked:

  • thenApply(Function<T, U> fn): Accepts a synchronous function transforming a value of type T into a value of type U. Returns CompletableFuture<U>.
  • thenCompose(Function<T, CompletionStage<U>> fn): Accepts a function that returns another asynchronous stage (CompletionStage<U>). Returns CompletableFuture<U> by unwrapping (flattening) the nested future.

Direct Analogy with Stream API and Optional

Context Transformation Without Unwrapping (1:1) Transformation With Unwrapping / Flattening
Stream<T> stream.map(x -> R) $\rightarrow$ Stream<R> stream.flatMap(x -> Stream<R>) $\rightarrow$ Stream<R>
Optional<T> opt.map(x -> R) $\rightarrow$ Optional<R> opt.flatMap(x -> Optional<R>) $\rightarrow$ Optional<R>
CompletableFuture<T> cf.thenApply(x -> R) $\rightarrow$ CompletableFuture<R> cf.thenCompose(x -> CompletableFuture<R>) $\rightarrow$ CompletableFuture<R>

Code Example

CompletableFuture<String> userIdFuture = CompletableFuture.supplyAsync(() -> "usr_42");

// 1. thenApply: Synchronous in-memory mapping (String -> Integer)
CompletableFuture<Integer> idLengthFuture = userIdFuture.thenApply(id -> id.length());

// 2. The Nesting Problem: Using thenApply when the function returns a CompletableFuture
// remoteService.fetchUser(id) returns CompletableFuture<User>
CompletableFuture<CompletableFuture<User>> nestedFuture = 
    userIdFuture.thenApply(id -> remoteService.fetchUser(id)); // Awkward nested future!

// 3. The Fix: thenCompose automatically flattens the nested future
CompletableFuture<User> flatFuture = 
    userIdFuture.thenCompose(id -> remoteService.fetchUser(id)); // Clean CompletableFuture<User>

🟡 Middle Level

When to Use thenApply vs thenCompose

  1. thenApply — Fast, synchronous, in-memory data transformations:
    • String parsing, DTO mapping, entity conversion, mathematical calculations, and filtering.
      fetchOrderFromDb(orderId)
        .thenApply(OrderEntity::toDto)
        .thenApply(dto -> dto.withTax(0.20));
      
  2. thenCompose — Sequential, dependent asynchronous operations:
    • When the output of step A is required as input for step B (e.g., authenticate user by token $\rightarrow$ fetch user account details $\rightarrow$ load credit cards).
      authService.authenticate(token)                                      // -> CompletableFuture<User>
        .thenCompose(user -> billingService.getAccount(user.accountId())) // -> CompletableFuture<Account>
        .thenCompose(account -> cardService.getCards(account.cardId()));  // -> CompletableFuture<List<Card>>
      

The Sequential Anti-Pattern: Mistaking Independent Tasks for Dependent Ones

A common performance pitfall is chaining independent async calls with thenCompose:

// ANTI-PATTERN: Tasks are independent, but thenCompose forces sequential execution (Latency = t1 + t2)
CompletableFuture<Profile> profile = fetchUser(userId)
    .thenCompose(user -> fetchCompany(companyId).thenApply(company -> new Profile(user, company)));

// CORRECT: Run both tasks in parallel with thenCombine (Latency = max(t1, t2))
CompletableFuture<User> userFuture = fetchUser(userId);
CompletableFuture<Company> companyFuture = fetchCompany(companyId);

CompletableFuture<Profile> profile = userFuture.thenCombine(
    companyFuture, 
    (user, company) -> new Profile(user, company)
);

🔴 Senior Level

Internal Architecture: UniApply vs UniCompose

Under the hood, both methods push dependent nodes onto a lock-free Treiber stack of completion stages:

public <U> CompletableFuture<U> thenApply(Function<? super T, ? extends U> fn) {
    return uniApplyStage(null, fn);
}

public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn) {
    return uniComposeStage(null, fn);
}
  1. UniApply Mechanics:
    • When the source stage completes, UniApply extracts value T.
    • Executes fn.apply(T) synchronously.
    • Atomically writes result U into the output future via CAS.
  2. UniCompose Mechanics (Two-Phase Subscription):
    • When the source stage completes, fn.apply(T) is invoked, returning an inner CompletionStage<U> inner.
    • If inner is already completed, its result is copied directly into the outer future.
    • If inner is still executing, the outer future registers a UniRelay listener on inner.
    • When inner eventually completes, the worker thread executing inner completes the outer future.

Thread Execution Context in thenCompose

Understanding which thread completes the resulting pipeline is vital for concurrency control:

CompletableFuture<User> cf1 = fetchUserAsync(); // Executes on DatabasePool
CompletableFuture<Order> cf2 = cf1.thenCompose(user -> fetchOrderAsync(user)); // Executes on HttpPool

The resulting CompletableFuture<Order> is completed by whichever thread finishes fetchOrderAsync (HttpPool), not DatabasePool. Any subsequent synchronous callbacks (thenAccept, thenApply) chained without the *Async suffix will execute within the context of HttpPool.


4 Tricky Questions

1. What happens if the function passed to thenCompose() returns null instead of a CompletionStage?

Answer: The resulting CompletableFuture immediately completes exceptionally with a NullPointerException wrapped inside a CompletionException. The CompletionStage contract strictly requires a non-null stage return value. It cannot proceed because it has no stage instance on which to register a completion relay.

2. What does thenComposeAsync(fn, executor) execute asynchronously: the function fn itself, or the inner future returned by fn?

Answer: thenComposeAsync offloads only the execution of the factory function fn (the step computing fn.apply(x)) to the specified executor. Where and how the returned inner CompletableFuture<U> executes depends entirely on how that inner future itself is scheduled and constructed.

3. If the first stage completes exceptionally, does the function inside thenCompose() get invoked? What if the first stage succeeds but the inner future fails?

Answer:

  • If the first stage fails: the function passed to thenCompose is never invoked. The failure short-circuits the stage, propagating the exception directly to the returned future.
  • If the first stage succeeds, but the inner future returned by fn subsequently fails: the inner future’s exception is propagated to the outer future. In both cases, downstream consumers observe a CompletionException.

4. If you call composedFuture.cancel(true), does it cancel the parent future or the inner future?

Answer: Neither the parent future nor the inner future will be cancelled. In Java’s CompletableFuture implementation, cancellation propagates only downstream, never upstream. Calling cancel(true) on composedFuture marks that specific stage as completed exceptionally with a CancellationException. The underlying background tasks in the parent or inner futures continue executing to completion.


🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • thenApply is the asynchronous equivalent of map: transforms T -> U, wrapping the result in CompletableFuture<U>. Use for fast in-memory synchronous operations.
  • thenCompose is the asynchronous equivalent of flatMap: accepts T -> CompletionStage<U>, preventing nested CompletableFuture<CompletableFuture<U>>. Use for sequential dependent asynchronous calls.
  • Independent asynchronous tasks should be combined concurrently using thenCombine or allOf, not sequentially via thenCompose.
  • Cancellation on a composed stage does not propagate upstream to cancel antecedent futures.

Common Interview Mistakes

  • Using thenApply on an async method and resorting to .join().join() to extract the nested value.
  • Chaining independent asynchronous requests with thenCompose, accidentally converting parallel operations into sequential ones and multiplying response latency.
  • Assuming thenComposeAsync forces the inner stage’s background task to run on the supplied executor.