Back to CompletableFuture & Resilience
Q6Question 6 of 19

Why thenApply produces nested CF<CF<T>>?

Key Concepts
  • thenApply wraps the function's return value in a CF; if the function itself returns a CF, the result is CF<CF<T>> (nested).
  • thenCompose flattens: when the function returns CF<T>, thenCompose unwraps the inner CF, giving CF<T> (single level).
  • This mirrors map() vs flatMap() in streams: map wraps, flatMap flattens.
  • Decision rule: function returns plain value, use thenApply; function returns CompletableFuture, use thenCompose.
  • Using thenApply with an async function creates deeply nested CFs requiring double or triple unwrapping.
  • In async pipelines, most service calls return CompletableFuture, so thenCompose is used more often than thenApply.

Definition

thenApply wraps the function's return value in a CompletableFuture, so if the function returns a CF you get nested CF<CF<T>>; thenCompose flattens the nesting to CF<T>, just like flatMap in streams.
  • 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 like flatMap() (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. If the function returns CF (another async call), thenApply wraps that CF in another CF, giving CF>.

JAVA
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());
    }
}
TEXT
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.

JAVA
// 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 (correct) |
| CompletableFuture | thenCompose | CF (correct) |
| CompletableFuture | thenApply (wrong!) | CF> (nested!) |

JAVA
// 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.

JAVA
// 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?

JAVA
// 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

DIAGRAM
  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>. You would need double unwrapping to get the actual value. thenCompose solves this by flattening: it unwraps the inner CF, giving you CF directly. This is the same concept as map() vs flatMap() in streams.

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.

---

Share
Use ← → arrow keys to navigate