Composable Async Pipelines with Java CompletableFuture
Why CompletableFuture?
When I first started stitching together calls to downstream services, I reached for Future and blocked on get(). The code worked, but it turned every asynchronous boundary into a synchronous bottleneck. CompletableFuture changed that by letting me describe the whole workflow as a data‑flow graph: each stage declares what it needs, and the runtime schedules the work without me managing threads explicitly.
Real‑world scenario: orchestrating service calls
Imagine a checkout flow that must:
- Validate the user’s cart against the inventory service.
- Reserve the items.
- Charge the payment gateway.
- Emit a confirmation event to the messaging bus.
Each step is a remote call with its own latency and failure mode. Blocking sequentially would add up the latencies; firing everything in parallel would ignore the data dependencies (you can’t charge before reservation). CompletableFuture lets us express the dependency chain while still running independent steps concurrently.
Production‑ready pipeline
public CompletableFuture checkout(Cart cart) {
// 1 Validate & reserve – run in parallel, then combine
CompletableFuture reservation = inventoryClient.validate(cart)
.thenCompose(validated -> inventoryClient.reserve(validated));
// 2 Payment – starts only after reservation succeeds
CompletableFuture payment = reservation
.thenCompose(r -> paymentGateway.charge(r.getOrderId(), cart.total()))
.exceptionally(ex -> {
// compensate reservation on payment failure
inventoryClient.release(r.getReservationId()).join();
throw new CompletionException(ex);
});
// 3 Confirmation – fire‑and‑forget event publish
return payment.thenCompose(p -> eventBus.publish(new OrderPlaced(p.getOrderId())))
.thenApply(event -> new OrderConfirmation(p.getOrderId(), event.getTimestamp()))
.exceptionally(ex -> {
log.error("Checkout failed for cart {}", cart.getId(), ex);
throw new CompletionException(ex);
});
}
Breaking down the pipeline
- thenCompose chains a stage that returns another
CompletableFuture, flattening the result so we never end up withCompletableFuture.> - exceptionally provides a recovery hook *per stage*. In the payment step we roll back the reservation before re‑throwing, keeping the compensation logic close to the failure point.
- The final
thenComposepublishes the event; because the event bus returns a future, we stay in the same pipeline and can still apply a terminalexceptionallyfor logging.
Tip: Keep each stage pure — no shared mutable state. If you need context (e.g., correlation IDs), thread‑localContextpropagation libraries such as Micrometer’sContextSnapshotwork nicely withCompletableFuture.
Common pitfalls and how to avoid them
1. Blocking inside a stage – calling .join() or .get() defeats the purpose and can deadlock the common ForkJoinPool. Use only non‑blocking combinators (thenApply, thenCompose, thenAcceptBoth, …).
2. Swallowing exceptions – a bare exceptionally that returns a default value hides failures downstream. Prefer re‑throwing a CompletionException after any compensating action.
3. Unbounded parallelism – the default pool is shared across the JVM. For I/O‑heavy work, supply a dedicated executor: supplyAsync(() -> client.call(), ioExecutor).
When to reach for something else
If the workflow involves complex state machines, long‑running human tasks, or needs durable persistence, a dedicated orchestration engine (Camunda, Temporal) is cleaner. For straightforward request‑response chains like the checkout example, CompletableFuture gives you a lightweight, type‑safe, and testable solution without extra infrastructure.