Question 4 · Section 19

What is the difference between thenApply() and thenCompose()

Both methods are used for chaining CompletableFuture, but differ in what they return:

Language versions: English Russian Ukrainian

🟢 Junior Level

Both methods are used for chaining CompletableFuture, but differ in what they return:

  • thenApply() — accepts a function T -> U, returns CompletableFuture<U>
  • thenCompose() — accepts a function T -> CompletableFuture<U>, returns CompletableFuture<U>
// thenApply — simple transformation
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "Hello");
cf.thenApply(s -> s.length())     // String -> Integer
  .thenAccept(len -> System.out.println(len));  // 5

// thenCompose — when the function already returns CompletableFuture
CompletableFuture<String> cf = getUserIdAsync();
cf.thenCompose(userId -> getUserDetailsAsync(userId))  // String -> CompletableFuture<User>
  .thenAccept(user -> System.out.println(user.name()));

Simple analogy:

  • thenApply — like map in Stream: transforms the value
  • thenCompose — like flatMap in Stream: unwraps nested CompletableFuture

🟡 Middle Level

Detailed comparison

thenApply — for synchronous transformations:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "Hello");

// Function: String -> Integer (synchronous)
cf.thenApply(String::length)       // CompletableFuture<Integer>
  .thenApply(len -> len * 2)       // CompletableFuture<Integer>
  .thenAccept(System.out::println); // 10

thenCompose — for asynchronous operations:

CompletableFuture<String> cf = getUserIdAsync();

// Function: String -> CompletableFuture<User> (asynchronous)
cf.thenCompose(userId -> getUserAsync(userId))  // CompletableFuture<User>
  .thenCompose(user -> getOrdersAsync(user.id()))  // CompletableFuture<List<Order>>
  .thenAccept(orders -> System.out.println(orders.size()));

Typical mistakes

  1. Nested CompletableFuture: ```java // ❌ thenApply creates a nested CompletableFuture CompletableFuture userId = getUserIdAsync(); CompletableFuture<CompletableFuture> nested = userId.thenApply(id -> getUserAsync(id)); // Nested!

// ✅ thenCompose unwraps the nesting CompletableFuture flat = userId.thenCompose(id -> getUserAsync(id)); // Flat!


2. **Confusion about when to use what:**
```java
// thenApply — when the result is synchronous
cf.thenApply(s -> s.toUpperCase());

// thenCompose — when the result is asynchronous
cf.thenCompose(id -> fetchDataFromApi(id));

🔴 Senior Level

Internal Implementation

thenApply:

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

// Creates a new CompletableFuture with UniApply dependency
// When this CF completes — function fn is applied
// The result of fn is set into the new CF

thenCompose:

public <U> CompletableFuture<U> thenCompose(
    Function<? super T, ? extends CompletionStage<U>> fn
) {
    return uniComposeStage(null, fn);
}

// Creates a new CompletableFuture with UniCompose dependency
// When this CF completes — fn is called (returns a CompletionStage)
// The result of fn is "unwrapped" into the new CF

Architectural Trade-offs

thenApply thenCompose
Synchronous function Asynchronous function
T -> U T -> CompletableFuture
Like map Like flatMap
No nesting Unwraps nesting

Edge Cases

1. thenCompose chain:

// Typical async workflow
getUserIdAsync()
    .thenCompose(userId -> getUserAsync(userId))
    .thenCompose(user -> getOrdersAsync(user.id()))
    .thenCompose(orders ->
        orders.isEmpty()
            ? CompletableFuture.completedFuture(List.<OrderDetail>of())
            : getOrderDetailsAsync(orders.get(0).id())
    )
    .thenAccept(details -> process(details));

2. Exception propagation:

// Exception in thenApply:
cf.thenApply(s -> { throw new RuntimeException("Error"); })
  .exceptionally(ex -> "recovered");  // will catch

// Exception in composed CF:
cf.thenCompose(id ->
    getUserAsync(id)  // may throw
        .exceptionally(ex -> defaultUser)  // handling inside
)
.exceptionally(ex -> fallback);  // handling outside

Performance

thenApply:
- Overhead: ~5 ns (function application)
- No thread switch

thenCompose:
- Overhead: ~10 ns (CF unwrapping)
- May switch thread (if inner CF is async)

thenApplyAsync / thenComposeAsync:
- + ~1μs per thread switch

Production Experience

Microservice call chain:

@Service
public class OrderFacade {
    public CompletableFuture<OrderSummary> getOrderSummary(Long orderId) {
        return orderRepository.findByIdAsync(orderId)
            .thenCompose(order ->
                userService.findByIdAsync(order.userId())
                    .thenApply(user -> new OrderSummary(order, user))
            );
    }

    // vs with thenApply — if user is already in memory
    public OrderSummary getOrderSummarySync(Order order, User user) {
        return CompletableFuture.completedFuture(order)
            .thenApply(o -> new OrderSummary(o, user));
    }
}

Best Practices

// ✅ thenApply for synchronous transformations
cf.thenApply(s -> s.toUpperCase());

// ✅ thenCompose for asynchronous calls
cf.thenCompose(id -> fetchDataAsync(id));

// ✅ thenCompose chain for workflows
cf.thenCompose(a -> callB(a))
  .thenCompose(b -> callC(b));

// ❌ Nested CompletableFutures
// ❌ thenCompose when thenApply would suffice
// ❌ Ignoring errors in the chain

When NOT to use thenCompose

When the second operation does NOT depend on the result of the first and can be executed in parallel. In this case, use thenCombine for parallel execution.

// ❌ thenCompose — sequential, unnecessarily slow
CompletableFuture<User> user = getUserAsync(userId);
user.thenCompose(u -> getOrdersAsync(userId));  // does not depend on user!

// ✅ thenCombine — parallel
CompletableFuture<User> user = getUserAsync(userId);
CompletableFuture<List<Order>> orders = getOrdersAsync(userId);
user.thenCombine(orders, UserProfile::new);

🎯 Interview Cheat Sheet

Must know:

  • thenApply — function T -> U, for synchronous transformations (like map)
  • thenCompose — function T -> CompletableFuture, for asynchronous calls (like flatMap)
  • thenApply can create a nested CompletableFuture<CompletableFuture> — this is a bug
  • thenCompose unwraps nesting, returning CompletableFuture
  • thenCompose executes sequentially, thenCombine — in parallel

Frequent follow-up questions:

  • When thenApply, when thenCompose? — thenApply if the function returns a plain value, thenCompose if it returns a CompletableFuture
  • What happens if you use thenApply with an async function? — You get CompletableFuture<CompletableFuture> — nesting, need thenCompose
  • thenCompose vs thenCombine — when which? — thenCompose when the second CF depends on the first, thenCombine when they are independent
  • What is the overhead of thenCompose? — ~10 ns for CF unwrapping, plus possible thread switch

Red flags (DO NOT say):

  • “thenApply and thenCompose are interchangeable” — thenApply will create a nested CF
  • “thenCompose is faster than thenCombine” — thenCompose is sequential, thenCombine is parallel
  • “thenApply is always in the same thread” — thenApplyAsync switches threads

Related topics:

  • [[11. What is the difference between thenApply() and thenApplyAsync()]]
  • [[22. What is the difference between thenCombine() and thenCompose()]]
  • [[8. How to combine results of multiple CompletableFutures]]
  • [[1. What is CompletableFuture and how does it differ from Future]]