CompletableFuture - thenApply vs thenCompose

thenApply and thenCompose are two ways to chain work after a CompletableFuture<T> completes. thenApply takes a function T → U and returns CompletableFuture<U>. thenCompose takes a function T → CompletableFuture<U> and returns CompletableFuture<U> by "flattening" the nested future. Use thenApply for synchronous mapping; use thenCompose when the next step is async and returns a future.

Overview

When you chain asynchronous operations, you often need to:

  1. Transform the result of one future (sync) → use thenApply
  2. Call another async operation that returns a future (async) → use thenCompose

If you use thenApply with a function that returns a CompletableFuture, you get CompletableFuture<CompletableFuture<U>> — a nested future. thenCompose flattens this to CompletableFuture<U>.

Rule of thumb: If the next step returns a valuethenApply. If the next step returns a CompletableFuturethenCompose.

Example

Example 1: thenApply — synchronous transformation

Java
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> "hello");
CompletableFuture<Integer> g = f.thenApply(s -> s.length());
// g completes with 5. The lambda returns Integer, not Future.

The function s -> s.length() is synchronous. It returns a plain value. thenApply wraps that value in a new CompletableFuture<Integer>.

Example 2: thenCompose — chaining async calls

Java
CompletableFuture<Order> orderFuture = getOrderId()
    .thenCompose(id -> fetchOrder(id));

// fetchOrder(id) returns CompletableFuture<Order>
// Without thenCompose: CompletableFuture<CompletableFuture<Order>>
// With thenCompose: CompletableFuture<Order>

fetchOrder(id) is async and returns CompletableFuture<Order>. Using thenApply would give CompletableFuture<CompletableFuture<Order>>. thenCompose subscribes to the inner future and flattens the result.

Example 3: Wrong use of thenApply with async

Java
// BAD: nested future
CompletableFuture<CompletableFuture<User>> bad = getUserId()
    .thenApply(id -> fetchUser(id));

// To get User you'd need: bad.thenCompose(inner -> inner)
// GOOD: flat future
CompletableFuture<User> good = getUserId()
    .thenCompose(id -> fetchUser(id));

Example 4: Combining thenApply and thenCompose

Java
CompletableFuture<String> result = getUserId()
    .thenCompose(id -> fetchUser(id))      // async: id → User
    .thenApply(user -> user.getName());    // sync: User → String

Example 5: thenCombine — combining two futures

Java
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> "World");
CompletableFuture<String> combined = a.thenCombine(b, (x, y) -> x + " " + y);
// combined completes with "Hello World"

thenCombine runs when both futures complete and applies a function to combine their results.

Example 6: allOf — wait for multiple futures

Java
CompletableFuture<Void> all = CompletableFuture.allOf(
    fetchUser(1),
    fetchUser(2),
    fetchUser(3)
);
all.join();  // blocks until all complete
MethodArgumentResultUse case
thenApplyT → UCompletableFuture<U>Sync transformation
thenComposeT → CompletableFuture<U>CompletableFuture<U>Chain async operation
thenCombine(T, U) → V (with other future)CompletableFuture<V>Combine two futures

Core Mechanism / Behavior

  • thenApply: Registers a callback. When the source future completes, the callback runs and its return value becomes the result of the new future. The callback executes on the default executor or a provided one.
  • thenCompose: Registers a callback that returns a future. When the source completes, the callback runs and returns a future. The composed future completes when that inner future completes. No blocking; it just wires the completion.
  • Exception handling: If the source future completes exceptionally, neither callback runs. Use exceptionally or handle to recover.

Key Rules

  • Use thenApply when the continuation returns a plain value. Use thenCompose when it returns a CompletableFuture to avoid nesting.
  • Prefer thenCompose for "async A, then async B with A's result" (e.g. get order ID, then fetch order). Use thenApply for "async A, then sync transform of A's result."
  • Always handle exceptions with exceptionally or handle so that failures do not go unnoticed.

What's Next

See Thread Pools and Core Parameters for choosing executors. See Volatile and Guarantees for visibility when sharing state between async stages.