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
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
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 applicativeResult<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 monadCompletableFuture<Result<LandRecord, RegistryError>> land = citizen.thenCompose(c -> c.fold( error -> CompletableFuture.completedFuture(Result.failure(error)), facts -> Futures.supplyAsyncVirtual(IO.delay(() -> land.loadLandRecord(facts.citizenId())))));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.