Java 31: CompletableFuture Composition
easy⏱ 5 mincoursejava
The core operators
thenApply(fn) = Promise .then with sync fn. thenCompose(fn) = .then with async fn (no nesting). thenAcceptBoth(other, biFn)/thenCombine = join two. exceptionally(fn) = .catch. handle(biFn) = access both value and throwable. allOf(cfs...) = Promise.all. anyOf(cfs...) = Promise.race.
CompletableFuture<User> u = fetchUser(id);
CompletableFuture<Report> r = u
.thenCompose(user -> fetchOrders(user.id()).thenApply(orders -> new Report(user, orders)))
.exceptionally(ex -> Report.empty());
thenApply vs thenCompose
thenApply(fn) — fn returns a plain value. If you pass a function returning another CompletableFuture, you get CompletableFuture<CompletableFuture<T>> (nested, bad). thenCompose unwraps it: fn returns a CF, thenCompose returns a plain CF. Use thenCompose for async steps.
Exception propagation
If any stage throws, downstream stages skip to the next exceptionally/handle. exceptionally(ex -> default) only runs on failure; handle((val, ex) -> ...) always runs. CompletionException wraps the real cause — unwrap with cause.getCause().
Build a user-report pipeline
Simulate async fetchUser(id), fetchOrders(userId), fetchPrefs(userId) returning CFs (use Promise). Compose: fetch user → fetch orders + prefs in parallel → combine into a report. Add .exceptionally to fall back to an empty report. Test the happy path and a failure path.
Always use thenXxxAsync for CPU work
thenApply runs on the completing thread — if that thread is an I/O thread, your CPU work blocks it. Use thenApplyAsync(fn, executor) to dispatch CPU-heavy transforms to a dedicated pool.
Quiz: get() vs join()
Both block until the future completes. Difference? get() throws checked ExecutionException + InterruptedException; join() throws unchecked CompletionException. join is nicer inside stream lambdas (no try/catch). Both are still blocking — avoid in hot paths.