Composing CompletableFutures
Composing CompletableFutures
In the previous lesson you saw the basics of CompletableFuture: how to start an async computation with supplyAsync and transform its result with thenApply. Real programs, however, rarely have a single isolated async step. You almost always need to chain one async call after another, combine results from two independent calls, or wait for a group of calls to finish before moving on. That is what composition is about — and doing it correctly is what separates async code that is clean and correct from async code that silently deadlocks or swallows exceptions.
thenCompose — Flat-Mapping Async Steps
thenCompose is the async equivalent of flatMap on a stream: it takes the result of one CompletableFuture, passes it to a function that returns another CompletableFuture, and flattens the two stages into one.
Why not just use thenApply? If your mapping function itself returns a CompletableFuture, then thenApply gives you a CompletableFuture<CompletableFuture<T>> — a nested future that is useless until you unwrap it manually. thenCompose does that unwrapping for you.
thenCompose (without the Async suffix), the continuation runs on whatever thread completed the previous stage — often a thread from the common fork-join pool. Use thenComposeAsync with an explicit Executor when you want to control which thread pool handles the next step, for example to keep blocking I/O off the common pool.
thenCombine — Merging Two Independent Futures
When two async operations do not depend on each other you should run them in parallel and combine their results only after both finish. thenCombine does exactly this: it takes a second CompletableFuture and a BiFunction, waits for both to complete, then merges the two results.
fetchUsdRate().thenCompose(_ -> fetchQuantity()) you would serialize two independent calls and waste wall-clock time. Use thenCombine (or allOf) so both start immediately and you pay only the cost of the slower one.
allOf — Waiting for Many Futures
CompletableFuture.allOf(futures...) returns a CompletableFuture<Void> that completes when every supplied future has completed. Because the result type is Void, you typically retrieve the individual results yourself after allOf finishes.
allOf itself will complete exceptionally, but calling join() on the failing future is how you get the actual cause. Handle exceptions on each individual future if you need per-task error reporting.
Exception Handling in Composed Pipelines
Exceptions travel through a CompletableFuture chain as a failed stage. Every subsequent stage that has not registered an error handler is skipped, and the exception surfaces at the terminal get() / join() call wrapped in a CompletionException. You have three recovery strategies:
exceptionally(fn)— runs only when the stage failed; replaces the exception with a default value.handle(BiFunction)— runs whether the stage succeeded or failed; lets you inspect both the result and the exception in one place.whenComplete(BiConsumer)— likehandlebut does not transform the result; useful for logging or cleanup.
thenCompose chain and one step can fail, attach exceptionally or handle at the point where you can meaningfully recover — typically right after the stage that might fail, not just at the very end. A handler at the end catches everything but gives you less context about which step failed.
Putting It All Together — A Realistic Pipeline
The following example combines thenCompose, thenCombine, and exceptionally in a single pipeline that (a) fetches a product ID asynchronously, (b) concurrently loads the product details and the live price, and (c) falls back gracefully on any failure:
Key Takeaways
- Use
thenComposewhen the next step is itself async (avoid double-wrapping). - Use
thenCombineto merge two independent futures without serializing them. - Use
allOfto fan out over a dynamic collection and collect all results afterward. - Attach
exceptionallyorhandleclose to the stage that can fail for precise recovery. - Always pick up the right Async variant with an explicit executor when blocking I/O must not touch the common pool.