Definition
- exceptionally(Function
) – catches exceptions and returns a recovery value; only invoked on failure; like try-catch returning a default. - handle(BiFunction
) – invoked on both success and failure; receives the result (or null) and exception (or null); returns a new transformed value. - whenComplete(BiConsumer
) – invoked on both success and failure for side effects (logging, metrics); does not change the result or recover from errors. - Pipeline integration – these methods integrate with thenApply, thenCompose, and thenCombine in async pipelines.
- exceptionallyCompose() – Java 12 addition that allows async recovery by returning a CompletableFuture instead of a plain value.
At National Bank of India: exceptionally is "if the fund transfer fails, return a default response: Transfer failed, try later." Only activates on error. handle is "whether the transfer succeeds or fails, create a StatusReport with the outcome." Always runs, always produces output. whenComplete is "whether it succeeds or fails, log the result to the audit trail." No transformation, just observation.
Understanding the Concepts
How does exceptionally() work?
exceptionally() catches exceptions in the pipeline and returns a recovery value. It is only called when the previous stage completed with an error. On success, it is skipped entirely.
package com.bank.service;
import java.util.concurrent.CompletableFuture;
public class ExceptionallyDemo {
public static void main(String[] args) {
// Success case: exceptionally NOT called
String success = CompletableFuture
.supplyAsync(() -> "SETTLED:TRD-001")
.exceptionally(ex -> "FAILED:UNKNOWN")
.join();
System.out.println(success); // SETTLED:TRD-001
// Failure case: exceptionally returns recovery value
String failure = CompletableFuture.<String>supplyAsync(() -> {
throw new RuntimeException("DB connection failed");
})
.exceptionally(ex -> {
System.out.println("Recovering: " + ex.getMessage());
return "FAILED:TRD-001";
})
.join();
System.out.println(failure); // FAILED:TRD-001
}
}
SETTLED:TRD-001
Recovering: java.lang.RuntimeException: DB connection failed
FAILED:TRD-001
How does handle() work?
handle() is called on both success and failure. It receives the result (or null if failed) and the exception (or null if succeeded). It returns a new value, transforming either case.
package com.bank.service;
import java.util.concurrent.CompletableFuture;
public class HandleDemo {
public static void main(String[] args) {
CompletableFuture<String> status = CompletableFuture
.supplyAsync(() -> {
if (Math.random() > 0.5)
throw new RuntimeException("Settlement failed");
return "SETTLED:TRD-001";
})
.handle((result, ex) -> {
if (ex != null) {
return "ERROR: " + ex.getCause().getMessage();
}
return "OK: " + result;
});
System.out.println(status.join());
}
}
How does whenComplete() work?
whenComplete() is called on both success and failure for side effects only. It does not change the result. The original value or exception passes through unchanged.
package com.bank.service;
import java.util.concurrent.CompletableFuture;
public class WhenCompleteDemo {
public static void main(String[] args) {
String result = CompletableFuture
.supplyAsync(() -> "SETTLED:TRD-001")
.whenComplete((r, ex) -> {
if (ex != null) {
System.out.println("[AUDIT] Failed: " + ex.getMessage());
} else {
System.out.println("[AUDIT] Success: " + r);
}
// No return! Side effect only.
})
.join(); // original result passes through
System.out.println(result); // SETTLED:TRD-001
}
}
[AUDIT] Success: SETTLED:TRD-001
SETTLED:TRD-001
How do the three methods compare?
| Method | Called when | Returns | Purpose |
|---|---|---|---|
| exceptionally | Error only | Recovery T | Recover from error |
| handle | Always | New U | Transform both cases |
| whenComplete | Always | Same T/error | Observe (log/metrics) |
How do you chain them in a pipeline?
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> settleTradeOrThrow("TRD-001")) // may throw
.thenApply(r -> "Confirmed: " + r) // transform
.exceptionally(ex -> "Pending: retry later") // recover
.whenComplete((r, ex) -> { // log
System.out.println("[METRIC] outcome=" + r);
});
On success: thenApply transforms, exceptionally is skipped, whenComplete logs.
On failure: thenApply is skipped, exceptionally provides a fallback, whenComplete logs the fallback.
What is the wrong way to handle errors?
// WRONG: using whenComplete to recover
cf.whenComplete((r, ex) -> {
if (ex != null) return "fallback"; // DOES NOTHING! No return value.
});
// whenComplete cannot change the result. Use exceptionally or handle.
// WRONG: swallowing exception in handle without logging
cf.handle((r, ex) -> {
if (ex != null) return defaultValue; // silently swallowed!
return r;
});
// At least log the error before recovering.
What about exceptionallyCompose (Java 12)?
exceptionallyCompose() allows async recovery by returning a CompletableFuture instead of a plain value.
// Java 12+
CompletableFuture<Integer> score = fetchCreditScore("SAV2001")
.exceptionallyCompose(ex -> fetchCachedScoreAsync("SAV2001"));
// If live score fails, fetch cached score asynchronously
What are common interview follow-up questions?
- Does exceptionally recover the pipeline for subsequent stages? Yes, the next thenApply receives the recovery value.
- Can handle() change the return type? Yes, handle returns BiFunction
where U can be different from T. - Is whenComplete called if exceptionally already recovered? If whenComplete is chained after exceptionally, it sees the recovered value, not the original error.
Visual Overview
CompletableFuture Pipeline with Error Handling
supplyAsync()
│
┌────┴────┐
│ Success │ Failure
▼ ▼
thenApply (skipped on failure)
│
┌────┴────┐
│ Success │ Failure
▼ ▼
exceptionally (skipped on success)
│
▼
whenComplete (always: log/metrics)
│
▼
Final result
exceptionally: error → recovery value
handle: always → new value
whenComplete: always → observe only
Key Takeaway
CompletableFuture provides three exception handling methods, each serving a distinct purpose. exceptionally() catches errors and returns a recovery value. It only runs on failure, like try-catch with a default return. handle() runs on both success and failure, transforming either case into a new value. It is the most flexible option, like a try-catch that always produces output. whenComplete() runs on both outcomes for side effects only: logging, metrics, alerting. It does not change the result.
The pipeline pattern is clean: supplyAsync for the initial task, thenApply for transformations, exceptionally for fallbacks, whenComplete for logging. On success, thenApply stages transform the data, exceptionally is skipped, and whenComplete logs the outcome. On failure, thenApply stages are skipped, exceptionally provides a fallback, and whenComplete logs the failure.
At National Bank of India, every payment pipeline uses exceptionally for cached fallbacks when the credit bureau is down, handle for creating StatusReport objects regardless of outcome, and whenComplete for incrementing Grafana counters.
Remember one sentence: exceptionally recovers, handle transforms both cases, whenComplete just observes.
---