Definition
- thenApply with plain return – function returns T, result is CF
; works correctly. - thenApply with CF return – function returns CF
, result is CF >; nested, requires double unwrap. - thenCompose – function returns CF
, thenCompose unwraps the inner CF, result is CF ; single level. - Stream analogy – thenApply is like
map()(wraps as-is); thenCompose is likeflatMap()(unwraps inner container). - Decision rule – function returns plain value: thenApply; function returns CompletableFuture: thenCompose.
At National Bank of India: thenApply with an async function is like asking for Rajesh's balance and receiving an envelope containing another envelope containing the balance. You must open two envelopes. thenCompose is the clerk opening the inner envelope for you and handing you one envelope with the balance directly.
Understanding the Concepts
Why does thenApply produce nested CF>?
thenApply wraps whatever the function returns inside a CompletableFuture. If the function returns a plain Integer, you get CF
package com.bank.service;
import java.util.concurrent.CompletableFuture;
public class NestedCFDemo {
static CompletableFuture<Integer> fetchBalanceAsync(String accountId) {
return CompletableFuture.supplyAsync(() -> 150000);
}
public static void main(String[] args) {
// WRONG: thenApply with async function produces NESTED CF
CompletableFuture<CompletableFuture<Integer>> nested =
CompletableFuture.supplyAsync(() -> "SAV2001")
.thenApply(id -> fetchBalanceAsync(id)); // returns CF<Integer>
// Result: CF<CF<Integer>>! Needs: nested.join().join()
// CORRECT: thenCompose flattens to single CF
CompletableFuture<Integer> flat =
CompletableFuture.supplyAsync(() -> "SAV2001")
.thenCompose(id -> fetchBalanceAsync(id)); // returns CF<Integer>
// Result: CF<Integer>! Needs: flat.join()
System.out.println("Balance: Rs." + flat.join());
}
}
Balance: Rs.150000
How does this relate to Stream map() vs flatMap()?
The concept is identical. map() wraps the return value in the container. flatMap() unwraps an inner container.
// Stream analogy:
// map: Stream<String> -> map(s -> List.of(s)) -> Stream<List<String>> NESTED
// flatMap: Stream<String> -> flatMap(s -> List.of(s).stream()) -> Stream<String> FLAT
// CompletableFuture analogy:
// thenApply: CF<String> -> thenApply(s -> asyncCall(s)) -> CF<CF<Integer>> NESTED
// thenCompose: CF<String> -> thenCompose(s -> asyncCall(s)) -> CF<Integer> FLAT
What is the decision rule?
| Function returns | Use | Result |
|---|---|---|
| Plain value (T) | thenApply | CF
| CompletableFuture
| CompletableFuture
// Function returns String (plain value) -> thenApply
cf.thenApply(s -> s.toUpperCase()); // CF<String>
// Function returns Integer (plain value) -> thenApply
cf.thenApply(s -> Integer.parseInt(s)); // CF<Integer>
// Function returns CF<Integer> (async call) -> thenCompose
cf.thenCompose(id -> fetchBalanceAsync(id)); // CF<Integer>
// WRONG: function returns CF but using thenApply
cf.thenApply(id -> fetchBalanceAsync(id)); // CF<CF<Integer>>!
What happens with deeply nested pipelines?
If you use thenApply for every async step, the nesting grows with each step.
// WRONG: three thenApply calls with async functions
CompletableFuture<CompletableFuture<CompletableFuture<String>>> deep =
CompletableFuture.supplyAsync(() -> "SAV2001")
.thenApply(id -> fetchProfileAsync(id)) // CF<CF<Profile>>
.thenApply(cfProfile -> /* how to use this? */);
// Requires: deep.join().join().join() -- unworkable!
// CORRECT: thenCompose keeps it flat
CompletableFuture<String> flat = CompletableFuture
.supplyAsync(() -> "SAV2001")
.thenCompose(id -> fetchProfileAsync(id)) // CF<Profile>
.thenCompose(profile -> fetchScoreAsync(profile)) // CF<Integer>
.thenApply(score -> "Score: " + score); // CF<String>
// flat.join() returns String directly
What are common mistakes?
// WRONG: thenApply with async function
cf.thenApply(id -> fetchAsync(id)); // CF<CF<T>> nested!
// CORRECT: thenCompose with async function
cf.thenCompose(id -> fetchAsync(id)); // CF<T> flat!
// WRONG: mixing up when to use which
cf.thenCompose(s -> CompletableFuture.completedFuture(s.toUpperCase()));
// Works but unnecessarily creates a CF. Use thenApply:
cf.thenApply(s -> s.toUpperCase()); // simpler, no extra CF
What are common interview follow-up questions?
- Is thenCompose equivalent to thenApply followed by unwrapping? Conceptually yes, but thenCompose avoids creating the nested CF in the first place.
- Can you use thenCompose when the function returns a plain value? No, thenCompose expects the function to return a CompletableFuture. Use thenApply for plain values.
- What is the performance difference? thenCompose avoids creating an extra CompletableFuture wrapper, so it has slightly less overhead.
Visual Overview
thenApply with async function:
CF<String> ──▶ fetchBalanceAsync() ──▶ CF<CF<Integer>>
"SAV2001" returns CF<Integer> NESTED!
.join().join() needed
thenCompose with async function:
CF<String> ──▶ fetchBalanceAsync() ──▶ CF<Integer>
"SAV2001" returns CF<Integer> FLAT!
(inner CF unwrapped) .join() enough
Decision Rule:
┌───────────────────────────────────────┐
│ Function returns T? → thenApply │
│ Function returns CF<T>? → thenCompose│
└───────────────────────────────────────┘
Key Takeaway
When your function returns a CompletableFuture, using thenApply wraps it in another CompletableFuture, creating CF
The rule is simple. Does the function return a plain value like String or Integer? Use thenApply. Does it return a CompletableFuture? Use thenCompose. In async pipelines, most steps call another service that returns a CompletableFuture, so thenCompose is used more often than thenApply.
At National Bank of India, the loan approval pipeline chains three async calls: fetch profile, fetch credit score, calculate eligibility. Each returns a CompletableFuture. Using thenCompose for all three keeps the pipeline flat and readable: one join() at the end returns the final result.
Remember one sentence: thenApply is map and thenCompose is flatMap; use compose when the function returns a CompletableFuture.
---