Introduction

When I first started wiring together a handful of REST calls in a service layer, the code quickly turned into a nest of callbacks and Future.get() blocks that defeated the purpose of non‑blocking I/O. Java 8 introduced CompletableFuture, and the real power shows up when you chain stages with thenCompose and thenCombine. The result is a readable pipeline that still runs concurrently under the hood.

The Problem

Imagine a checkout flow that needs to:

  • 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. Doing them sequentially wastes time; doing them in parallel without coordination loses the ordering guarantees (you can’t charge before reservation). The classic ExecutorService + Future approach forces you to block on each get(), turning async into sync.

The Solution: CompletableFuture Chaining

CompletableFuture lets you describe what should happen next without dictating when it runs. The key methods are:

  • thenCompose – flattens a stage that returns another CompletableFuture.
  • thenCombine – merges two independent futures when both complete.
  • exceptionally / handle – centralised error handling.

Because each stage returns a new CompletableFuture, the compiler checks the types end‑to‑end, giving you the same safety you’d get from a synchronous call chain.

Code Walkthrough


import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.Supplier;

public class CheckoutService {
    private final InventoryClient inventory;
    private final PaymentClient payment;
    private final EventPublisher events;
    private final Executor ioExecutor;          // dedicated thread pool for blocking HTTP calls

    public CheckoutService(InventoryClient inventory,
                           PaymentClient payment,
                           EventPublisher events,
                           Executor ioExecutor) {
        this.inventory = inventory;
        this.payment = payment;
        this.events = events;
        this.ioExecutor = ioExecutor;
    }

    /**
     * Orchestrates the full checkout without ever blocking a caller thread.
     */
    public CompletableFuture checkout(Cart cart) {
        // 1️⃣ Validate & reserve – runs on the I/O executor
        CompletableFuture reservation = CompletableFuture.supplyAsync(
                () -> inventory.reserve(cart), ioExecutor);

        // 2️⃣ When reservation succeeds, charge the payment.
        //    thenCompose flattens the nested future returned by charge().
        CompletableFuture charge = reservation.thenCompose(res ->
                CompletableFuture.supplyAsync(() -> payment.charge(res), ioExecutor));

        // 3️⃣ Combine reservation + charge to build the confirmation object.
        CompletableFuture confirmation = reservation
                .thenCombine(charge, (res, chg) -> new OrderConfirmation(res, chg));

        // 4️⃣ Fire‑and‑forget event publishing – we don’t need its result downstream.
        confirmation.thenAccept(conf -> events.publish(new OrderPlaced(conf)));

        // 5️⃣ Unified error handling – logs and converts to a domain exception.
        return confirmation.exceptionally(ex -> {
            log.error("Checkout failed for cart {}" , cart.id(), ex);
            throw new CheckoutException(ex);
        });
    }
}

Notice the ioExecutor. By supplying a bounded thread pool you keep the number of concurrent HTTP connections under control, something a plain ForkJoinPool.commonPool() can’t guarantee.

Why This Beats the Alternatives

Readability – The flow reads top‑to‑bottom, mirroring the business narrative.

Type safety – Each thenCompose step enforces that the upstream future produces the exact input type the downstream function expects.

Back‑pressure friendly – Because you control the executor, you can size it to match downstream capacity (e.g., a connection pool of 50).

Compared to reactive libraries (Project Reactor, RxJava), CompletableFuture requires zero extra dependencies and works on any JDK 8+. For services that only need a handful of async hops, it’s often the pragmatic sweet spot.

Error Handling in Depth

The exceptionally stage at the end catches any failure from the preceding chain. If you need richer recovery (retry, fallback), replace it with handle((result, ex) -> …) which gives you both the successful value and the throwable. You can also attach whenComplete for side‑effects like metrics without altering the result.

Testing the Pipeline

Unit tests become trivial because every stage is a pure function of its input. Mock the clients, supply completed futures, and assert the final OrderConfirmation.


@Test
void checkout_success() {
    Cart cart = Cart.of("sku-1", 2);
    Reservation reservation = new Reservation("res-123");
    ChargeResult charge = new ChargeResult("chg-456");

    when(inventory.reserve(cart)).thenReturn(reservation);
    when(payment.charge(reservation)).thenReturn(charge);

    OrderConfirmation result = service.checkout(cart).join();

    assertEquals(reservation, result.reservation());
    assertEquals(charge, result.charge());
    verify(events).publish(any(OrderPlaced.class));
}

Because the test uses .join() on the returned future, it runs synchronously in the test thread—no flaky sleeps.

Conclusion

Chaining CompletableFuture with thenCompose and thenCombine gives you a lightweight, type‑safe way to express complex async orchestrations. It keeps the code linear, makes failure handling explicit, and lets you tune concurrency through a custom executor. Next time you reach for a CountDownLatch or a nest of callbacks, give this pattern a try—you’ll thank yourself during the next production incident review.