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:

  1. Validate the user’s cart against the inventory service.
  2. Reserve the items.
  3. Charge the payment gateway.
  4. 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 with CompletableFuture>.
  • 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 thenCompose publishes the event; because the event bus returns a future, we stay in the same pipeline and can still apply a terminal exceptionally for logging.
Tip: Keep each stage pure — no shared mutable state. If you need context (e.g., correlation IDs), thread‑local Context propagation libraries such as Micrometer’s ContextSnapshot work nicely with CompletableFuture.

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.