The CompletableFutureMonad:
Asynchronous Computations with CompletableFuture
- How to compose asynchronous operations functionally
- Using MonadError capabilities for async error handling and recovery
- Building non-blocking workflows with
map,flatMap, andhandleErrorWith - Integration with EitherT for combining async operations with typed errors
- Real-world patterns for resilient microservice communication
The Problem: Async Composition Without Structure
Java's CompletableFuture is powerful, but composing multiple async operations quickly becomes unwieldy. Consider a typical microservice scenario — fetching a user, looking up their subscription, then calculating a discount:
// Traditional CompletableFuture chaining
CompletableFuture<Discount> result =
userService.findUser(userId)
.thenCompose(user ->
subscriptionService.getSubscription(user.subscriptionId())
.thenCompose(sub ->
pricingService.calculateDiscount(user, sub)
.exceptionally(ex -> Discount.none())));
Each level of thenCompose indents further. Error handling with exceptionally or handle dangles awkwardly at the end, applies only to the innermost stage, and loses type information — every error becomes a generic Throwable. If you need different recovery strategies for different failures, you end up with nested try/catch inside your lambdas, and the functional style collapses.
The CompletableFutureMonad gives you a structured alternative: standard map, flatMap, and handleErrorWith operations that compose cleanly, and the ability to write generic code that works across any monadic type — not just CompletableFuture.
Core Components
The simulation for CompletableFuture involves these key pieces:
| Component | Role |
|---|---|
CompletableFuture<A> | Standard Java async computation |
CompletableFutureKind<A> | HKT marker (Kind<CompletableFutureKind.Witness, A>) — enables generic type class programming |
CompletableFutureKindHelper | Bridge utilities: widen(), narrow(), and join() for blocking extraction |
CompletableFutureMonad | Implements MonadError<CompletableFutureKind.Witness, Throwable> — provides map, flatMap, of, ap, raiseError, and handleErrorWith |
The type class operations correspond directly to CompletableFuture methods you already know:
| Type Class Operation | CompletableFuture Equivalent |
|---|---|
map(f, fa) | thenApply(f) |
flatMap(f, fa) | thenCompose(f) |
ap(ff, fa) | thenCombine(ff, (a, f) -> f.apply(a)) |
of(value) | completedFuture(value) |
raiseError(ex) | failedFuture(ex) |
handleErrorWith(fa, handler) | exceptionallyCompose(handler) |
The difference is that these operations work through the Kind abstraction, so your code becomes reusable across any monadic type — swap CompletableFuture for IO, Either, or VTask without changing the logic.
Working with CompletableFutureMonad
The following examples build on a running scenario: an async service that fetches user data, validates subscriptions, and handles failures gracefully.
public void createExample() {
// Get the MonadError instance
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad = Instances.monadError(completableFuture());
// --- Lift a pure value into an already-completed future ---
Kind<CompletableFutureKind.Witness, String> successKind = futureMonad.of("Success!");
// --- Create a failed future from an exception ---
RuntimeException error = new RuntimeException("Something went wrong");
Kind<CompletableFutureKind.Witness, String> failureKind = futureMonad.raiseError(error);
// --- Wrap an existing CompletableFuture ---
CompletableFuture<Integer> existingFuture = CompletableFuture.supplyAsync(() -> {
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) { /* ignore */ }
return 123;
});
Kind<CompletableFutureKind.Witness, Integer> wrappedExisting = FUTURE.widen(existingFuture);
CompletableFuture<Integer> failedExisting = new CompletableFuture<>();
failedExisting.completeExceptionally(new IllegalArgumentException("Bad input"));
Kind<CompletableFutureKind.Witness, Integer> wrappedFailed = FUTURE.widen(failedExisting);
// Unwrap back to CompletableFuture at system boundaries
CompletableFuture<String> unwrappedSuccess = FUTURE.narrow(successKind);
CompletableFuture<String> unwrappedFailure = FUTURE.narrow(failureKind);
}
These examples show how the type class instance composes async operations — the same map/flatMap vocabulary you use with Either, IO, or any other monad.
public void monadExample() {
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad = Instances.monadError(completableFuture());
// --- map: transform the result when it completes ---
Kind<CompletableFutureKind.Witness, Integer> initialValueKind = futureMonad.of(10);
Kind<CompletableFutureKind.Witness, String> mappedKind = futureMonad.map(
value -> "Result: " + value,
initialValueKind
);
System.out.println("Map Result: " + FUTURE.join(mappedKind)); // Output: Result: 10
// --- flatMap: sequence async operations that depend on previous results ---
Function<String, Kind<CompletableFutureKind.Witness, String>> asyncStep2 =
input -> FUTURE.widen(
CompletableFuture.supplyAsync(() -> input + " -> Step2 Done")
);
Kind<CompletableFutureKind.Witness, String> flatMappedKind = futureMonad.flatMap(
asyncStep2,
mappedKind // "Result: 10" feeds into asyncStep2
);
System.out.println("FlatMap Result: " + FUTURE.join(flatMappedKind));
// Output: Result: 10 -> Step2 Done
// --- ap: apply a function from one future to a value from another ---
Kind<CompletableFutureKind.Witness, Function<Integer, String>> funcKind =
futureMonad.of(i -> "FuncResult:" + i);
Kind<CompletableFutureKind.Witness, Integer> valKind = futureMonad.of(25);
Kind<CompletableFutureKind.Witness, String> apResult = futureMonad.ap(funcKind, valKind);
System.out.println("Ap Result: " + FUTURE.join(apResult)); // Output: FuncResult:25
// --- map2: combine two independent futures ---
Kind<CompletableFutureKind.Witness, Integer> f1 = futureMonad.of(5);
Kind<CompletableFutureKind.Witness, String> f2 = futureMonad.of("abc");
BiFunction<Integer, String, String> combine = (i, s) -> s + i;
Kind<CompletableFutureKind.Witness, String> map2Result = futureMonad.map2(f1, f2, combine);
System.out.println("Map2 Result: " + FUTURE.join(map2Result)); // Output: abc5
}
This is where CompletableFutureMonad shines. Unlike exceptionally which returns a plain value, handleErrorWith returns a new Kind — so recovery itself can be asynchronous.
public void errorHandlingExample(){
MonadError<CompletableFutureKind.Witness, Throwable> futureMonad = Instances.monadError(completableFuture());
Kind<CompletableFutureKind.Witness, String> failedKind =
futureMonad.raiseError(new IllegalStateException("Processing Failed"));
// Recovery handler — inspect the error and return a new async computation
Function<Throwable, Kind<CompletableFutureKind.Witness, String>> recoveryHandler =
error -> {
if (error instanceof IllegalStateException) {
// Recovery can itself be async
return FUTURE.widen(CompletableFuture.supplyAsync(() ->
"Recovered from: " + error.getMessage()));
}
// Re-raise anything we can't handle
return futureMonad.raiseError(
new RuntimeException("Recovery failed", error));
};
// Apply the handler — transforms the failed future into a recovered one
Kind<CompletableFutureKind.Witness, String> recovered =
futureMonad.handleErrorWith(failedKind, recoveryHandler);
System.out.println(FUTURE.join(recovered));
// Output: Recovered from: Processing Failed
// Success values pass through untouched — the handler is never called
Kind<CompletableFutureKind.Witness, String> successKind = futureMonad.of("All Good");
Kind<CompletableFutureKind.Witness, String> handledSuccess =
futureMonad.handleErrorWith(successKind, recoveryHandler);
System.out.println(FUTURE.join(handledSuccess));
// Output: All Good
}
The handler receives the cause of the failure, unwrapped from CompletionException when necessary. This lets you pattern-match on specific exception types and choose recovery strategies — retry, fallback, or re-raise — all within the same compositional pipeline.
Back to the One-Liner
The Foundations one-liner is synchronous, but the same skeleton works unchanged when the repository operations are asynchronous:
FUTURE.widen(repo.findAsync(id))
.toEitherPath()
.focus().attributes().at(key)
.modify(spec::validateAndCoerce)
.flatMap(node -> FUTURE.widen(repo.saveAsync(node)));
CompletableFutureMonad.flatMap is the layer dispatched here, and it sequences the asynchronous steps without us writing a single thenCompose by hand. Failures (a CompletionException carrying our domain error, or a network blip) flow through MonadError recovery the same way Either errors do in the synchronous version. One mental model, two execution profiles.
See One Line, Six Layers for the wider picture and VTask when structured concurrency on virtual threads is the better fit.
When to Use CompletableFutureMonad
| Scenario | Use |
|---|---|
| Writing generic code that works across monads | CompletableFutureMonad — your logic programs against Kind<F, A> |
| Composing async workflows with typed error propagation | Combine with EitherT — see the Order Workflow |
| Straightforward async pipelines in application code | Prefer CompletableFuturePath for fluent API |
| Virtual-thread concurrency | Consider VTaskPath instead |
CompletableFutureMonadimplementsMonadError<CompletableFutureKind.Witness, Throwable>, giving youmap,flatMap,of,ap,raiseError, andhandleErrorWithover async computations.handleErrorWithis the key differentiator — recovery can itself be asynchronous, unlikeexceptionallywhich forces synchronous fallback.- Use
CompletableFutureKindHelper.join()to block and extract results at system boundaries (tests,mainmethods). Avoid calling it mid-pipeline. - For the HKT bridge:
widen()wraps aCompletableFutureintoKind,narrow()unwraps it back. Both are low-cost cast operations.
For most application-level use cases, prefer CompletableFuturePath which wraps CompletableFuture and provides:
- Fluent composition with
map,via,recover - Seamless integration with the Focus DSL for structural navigation
- A consistent API shared across all effect types
// Instead of manual Kind chaining:
Kind<CompletableFutureKind.Witness, User> user = FUTURE.widen(findUser(id));
Kind<CompletableFutureKind.Witness, Order> order = futureMonad.flatMap(
u -> FUTURE.widen(createOrder(u)), user);
// Use CompletableFuturePath for cleaner composition:
CompletableFuturePath<Order> order = CompletableFuturePath.fromFuture(findUser(id))
.via(u -> CompletableFuturePath.fromFuture(createOrder(u)));
See Migration Cookbook: Recipe 4 for a complete before/after walkthrough, and Effect Path Overview for the complete guide.
CompletableFuture has dedicated JMH benchmarks measuring async composition overhead, error recovery, and chain depth. Key expectations:
of/raiseErrorare very fast — they wrap already-completed futures with no thread schedulingflatMapchains add minimal overhead beyond the underlyingthenComposecosthandleErrorWithmatchesexceptionallyComposeperformance while providing stronger composition guarantees
./gradlew :hkj-benchmarks:jmh --includes=".*CompletableFutureBenchmark.*"
See Benchmarks & Performance for full details, expected ratios, and how to interpret results.
Previous: Supported Types Next: Either