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 typeTinto a value of typeU. ReturnsCompletableFuture<U>.thenCompose(Function<T, CompletionStage<U>> fn): Accepts a function that returns another asynchronous stage (CompletionStage<U>). ReturnsCompletableFuture<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
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));
- String parsing, DTO mapping, entity conversion, mathematical calculations, and filtering.
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>>
- 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).
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);
}
UniApplyMechanics:- When the source stage completes,
UniApplyextracts valueT. - Executes
fn.apply(T)synchronously. - Atomically writes result
Uinto the output future via CAS.
- When the source stage completes,
UniComposeMechanics (Two-Phase Subscription):- When the source stage completes,
fn.apply(T)is invoked, returning an innerCompletionStage<U> inner. - If
inneris already completed, its result is copied directly into the outer future. - If
inneris still executing, the outer future registers aUniRelaylistener oninner. - When
innereventually completes, the worker thread executinginnercompletes the outer future.
- When the source stage completes,
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
thenComposeis 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
fnsubsequently fails: the inner future’s exception is propagated to the outer future. In both cases, downstream consumers observe aCompletionException.
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
thenApplyis the asynchronous equivalent ofmap: transformsT -> U, wrapping the result inCompletableFuture<U>. Use for fast in-memory synchronous operations.thenComposeis the asynchronous equivalent offlatMap: acceptsT -> CompletionStage<U>, preventing nestedCompletableFuture<CompletableFuture<U>>. Use for sequential dependent asynchronous calls.- Independent asynchronous tasks should be combined concurrently using
thenCombineorallOf, not sequentially viathenCompose. - Cancellation on a composed stage does not propagate upstream to cancel antecedent futures.
Common Interview Mistakes
- Using
thenApplyon 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
thenComposeAsyncforces the inner stage’s background task to run on the supplied executor.