Series overview
Part 18 of 2090% complete
2026-04-27•20 min read

Async boundaries

The earlier chapters made a deliberate split: flatMap for dependent steps, map2 for independent ones. At the async boundary that split stops being theoretical — independent registry lookups can run concurrently, and dependent ones cannot. Java 21 gives you the machinery: CompletableFuture for composition, virtual threads for cheap blocking. jfp deliberately does not build an async runtime; it bridges to the one you have.

The bridge

interop/Futures.java
public static <T> CompletableFuture<T> supplyAsyncVirtual(IO<T> io) {
return CompletableFuture.supplyAsync(io::unsafeRun,
Executors.newVirtualThreadPerTaskExecutor());
}
public static <T> CompletableFuture<Result<T, Throwable>> captureResult(
CompletableFuture<T> future) {
return future.handle((value, error) ->
error == null ? Result.success(value) : Result.failure(error));
}

supplyAsyncVirtual runs an IO on a virtual thread — each lookup gets a cheap thread, so ten concurrent registry calls do not need a tuned pool. captureResult is the important half: a failed future becomes Failure(error) at the boundary, which keeps the async execution failure separate from the domain errors inside the Result values the futures carry.

Independent versus dependent, made visible

AsyncBoundaryTest.java
CompletableFuture<Result<CitizenFacts, RegistryError>> citizen =
Futures.supplyAsyncVirtual(IO.delay(() -> citizens.loadCitizen(citizenId)));
CompletableFuture<Result<IncomeRecord, RegistryError>> income =
Futures.supplyAsyncVirtual(IO.delay(() -> incomeRegistry.load(citizenId)));
// independent: both are already in flight — thenCombine is the applicative
Result<Tuple2<CitizenFacts, IncomeRecord>, RegistryError> both =
citizen.thenCombine(income,
(c, i) -> c.flatMap(f -> i.map(r -> new Tuple2<>(f, r)))).join();
// dependent: the land lookup needs facts first — thenCompose is the monad
CompletableFuture<Result<LandRecord, RegistryError>> land =
citizen.thenCompose(c -> c.fold(
error -> CompletableFuture.completedFuture(Result.failure(error)),
facts -> Futures.supplyAsyncVirtual(IO.delay(() ->
land.loadLandRecord(facts.citizenId())))));

facts verified

eligibility request

citizen lookup

income lookup

combineResults

land lookup

pure policy evaluation

facts verified

eligibility request

citizen lookup

income lookup

combineResults

land lookup

pure policy evaluation

thenCombine is the applicative at the async boundary — the two lookups proceed in parallel because neither consumes the other’s output. thenCompose is the monad — the land lookup cannot start until the citizen facts exist. If you wrote the whole flow as flatMap chains, you would serialize lookups that could have overlapped; if you made independent calls thenCompose, same mistake. The algebra from Parts 8–9 is literally a concurrency guide.

Failure-mode note: CompletableFuture cancellation does not interrupt the underlying work — a cancelled future marks itself complete-exceptionally while the virtual thread keeps running. For registry calls with real cost, prefer a timeout (orTimeout) over cancellation, and treat a timed-out future as a Timeout error at the boundary rather than an abandoned thread you pretend is gone.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind