Use thenApply for a plain transform (A -> B), thenCompose when the transform itself returns a CompletableFuture (flat-map, A -> CF<B>), and thenCombine to merge two independent futures into one result.
thenApply — map
thenApply takes the result and returns a value. It is map: Function<T, R>.
CompletableFuture<Integer> len =
CompletableFuture.supplyAsync(() -> "hello")
.thenApply(String::length); // String -> int
If your function returns a CompletableFuture, thenApply will happily wrap it, leaving you with CompletableFuture<CompletableFuture<R>> — a nested mess.
thenCompose — flatMap
thenCompose is for when the next step is itself asynchronous. The function returns a CompletionStage<R>, and thenCompose flattens it: Function<T, CompletionStage<R>>.
CompletableFuture<Profile> profile =
findUserId(name) // CF<Long>
.thenCompose(id -> loadProfile(id)); // id -> CF<Profile>, flattened
// result is CF<Profile>, NOT CF<CF<Profile>>
Rule of thumb: if your callback returns a future, you want thenCompose. This is the same map vs flatMap distinction you know from Optional and Stream.
thenCombine — zip two independent futures
thenCombine waits for two futures that run in parallel and combines their results with a BiFunction. The two futures do not depend on each other.
CompletableFuture<Price> price = CompletableFuture.supplyAsync(this::fetchPrice);
CompletableFuture<Double> rate = CompletableFuture.supplyAsync(this::fetchFxRate);
CompletableFuture<Double> total =
price.thenCombine(rate, (p, r) -> p.amount() * r);
Both fetchPrice and fetchFxRate run concurrently; the combiner fires once both are done.
thenApply: [A] ──► f(a) ──► B (sync transform)
thenCompose: [A] ──► f(a)=>[B] ──► B (chained async, flattened)
thenCombine: [A] ─┐
├─► combine(a,b) ──► C (two parallel inputs)
[B] ─┘