skip to content

How do exceptions flow through a chain of thenApply/thenCompose stages, and how is that hidden by the returned types?

level: seniorimportance: must knowfreq 65%

answer

  1. Transforms fire only on success; failure skips them and propagates
  2. First failure short-circuits the rest of the chain
  3. get() → ExecutionException; join() → CompletionException; unwrap getCause()
  4. exceptionally = failure-only fallback; handle = both outcomes+map; whenComplete = side effect, result unchanged
  5. No checked exceptions in signatures — failure is carried as completion state

basics

~10 s

If any stage throws or its upstream failed, that stage completes exceptionally and every following thenApply/thenCompose is skipped — the error 'short-circuits' to the end. You handle it with exceptionally, handle, or whenComplete.

solid answer

~40 s

Single-dependency transforms run only on normal completion. If the upstream completed exceptionally, the transform's function is skipped and the same failure propagates to the next stage. If the function itself throws, that stage completes exceptionally instead of normally. So a long thenApply/thenCompose chain short-circuits on the first failure — like an unchecked exception bubbling up. The original exception is wrapped: blocking via get() throws ExecutionException(cause), join() throws CompletionException(cause), and downstream handlers (exceptionally, handle, whenComplete) usually receive a CompletionException wrapping your real cause, so unwrap getCause(). To recover or branch, insert exceptionally(fn) to substitute a fallback value, handle(result, throwable) to map both outcomes, or whenComplete to observe without altering the result. Crucially, the transform methods don't expose checked exceptions — failures travel as the future's completion state, which is why people forget to handle them.

code

java · 13 lines
java
CompletableFuture<Integer> result =
    fetchCount()                                   // CompletableFuture<Integer>
        .thenApply(n -> 100 / n)                    // may throw ArithmeticException if n==0
        .thenApply(n -> n + 1)                      // skipped if the prior stage failed
        .exceptionally(ex -> {                      // recover ONLY on failure
            Throwable cause = (ex instanceof CompletionException) ? ex.getCause() : ex;
            log.warn("falling back", cause);
            return -1;                              // restores normal completion
        });

// handle: see BOTH outcomes and map them
fetchCount().handle((value, ex) ->
        ex == null ? "ok:" + value : "err:" + ex.getCause().getMessage());

go deeper

for a junior

Knows a failure skips later transforms and you recover with exceptionally; can attach a fallback.

for a middle

Explains short-circuiting and the get/join wrapping (ExecutionException vs CompletionException) and unwraps getCause().

for a senior

Chooses correctly among exceptionally/handle/whenComplete, places recovery at the right chain point, and handles thenCompose's inner-future failures.

for a principal

Designs error-handling conventions (where to recover, how to wrap/translate domain errors, swallowed-failure detection) and reasons about observability of failed stages across services.

## Completion has two outcomes, callbacks see one A stage completes either **normally** (a value) or **exceptionally** (a `Throwable`). The single-dependency transforms — `thenApply`, `thenAccept`, `thenRun`, `thenCompose` — are wired to the **normal** outcome only. So two rules drive everything: 1. **Upstream failed → transform skipped, same failure propagates.** The function never runs; the next stage inherits the exceptional completion. 2. **Function throws → that stage completes exceptionally.** If your `thenApply` lambda throws (or, for `thenCompose`, the inner future fails), the resulting stage is now in the failed state. ## Short-circuiting: like an exception bubbling up Because every transform forwards failure, a chain such as: ```java a.thenApply(f1).thenApply(f2).thenCompose(f3).thenApply(f4) ``` **short-circuits** at the first failure: if `f2` throws, `f3` and `f4` never run, and the final stage is exceptional with `f2`'s error. Conceptually it behaves like a synchronous `try` block where an exception jumps straight to the bottom — except it's carried as data in the future, not thrown on the stack. ## The wrapping you must know The original `Throwable` gets **wrapped** depending on how you observe it: - `future.get()` → throws **`ExecutionException`** whose `getCause()` is your real error (also declares checked `InterruptedException`). - `future.join()` → throws **`CompletionException`** (unchecked) wrapping your error. - Inside `exceptionally`, `handle`, `whenComplete` → the `Throwable` argument is usually a **`CompletionException`** wrapping the original. **Always unwrap with `getCause()`** before instanceof-matching, or you'll miss your own exception type. (One wrap, not many: the runtime avoids re-wrapping an already-wrapped CompletionException across stages, but defensive `getCause()` is still the safe habit.) ## The three recovery operators - **`exceptionally(Function<Throwable, T>)`** — runs **only on failure**; returns a fallback value, turning the stage back to normal. Doesn't see the success value. - **`handle(BiFunction<T, Throwable, R>)`** — runs on **both** outcomes; exactly one of `(value, throwable)` is non-null. Can map a failure to a value *or* transform a success — the most general recovery point. - **`whenComplete(BiConsumer<T, Throwable>)`** — runs on **both** outcomes for **side effects** (logging, metrics, cleanup); it **does not change** the result — the original value/exception passes through unchanged. ## Why this is easy to get wrong The transform signatures declare **no checked exceptions** — failure is invisible in the type. So code compiles and 'looks fine' while silently swallowing errors if no `exceptionally`/`handle` is attached and nobody ever calls `join()`/`get()`. A common bug: attaching `whenComplete` to log and assuming it 'handled' the error — it doesn't; the stage is still failed. Place a real recovery (`exceptionally`/`handle`) where you intend the chain to continue, and put it **at the right point** (a late `exceptionally` recovers failures from *all* earlier stages, an early one only from those before it). ## thenCompose specifics For `thenCompose`, failure can come from two places: the upstream stage, or the **inner** future the function returns. Either way the composed stage completes exceptionally with that cause, so the same propagation and unwrapping rules apply.

  • Difference between exceptionally, handle, and whenComplete?
    exceptionally runs only on failure and substitutes a fallback value. handle runs on both outcomes (value, throwable) and can map either to a new result — the general recovery. whenComplete runs on both outcomes for side effects only and passes the original result/exception through unchanged.
  • You call join() and get a CompletionException. How do you get the real cause?
    Call getCause() on it. join() (and downstream handlers) wrap the original Throwable in a CompletionException, so unwrap before instanceof-checking. get() wraps in ExecutionException instead; same getCause() approach.

saying these in an interview costs you the question

  • Thinking whenComplete 'handles' the failure — it only observes; the stage stays failed.
  • instanceof-checking the Throwable without unwrapping the CompletionException/ExecutionException.
  • Assuming a thenApply after a failed stage still runs.
  • Putting exceptionally too early so later-stage failures escape unhandled.
  • Believing failures surface at the call site rather than as completion state.

context