Pipelines with CompletableFuture

Priya can fan out price checks and wait for results. But a full order is a chain of steps: validate the cart, fetch prices, charge the card, send a confirmation email. With Future, each step means another blocking get(). The main thread sits idle, waiting. Priya wants a pipeline — like an assembly line where each station hands work to the next without everyone standing still.

Why CompletableFuture?

Plain Future has limits:

  • No easy way to chain “do A, then B, then C.”
  • Exception handling is clunky.
  • Combining many futures takes boilerplate.
  • Everything blocks on get().

CompletableFuture adds a fluent, chainable API. Think of it as pipes: output from one step flows into the next.

Creating CompletableFuture

Static factory methods

// Already completed with a value
CompletableFuture<String> future = CompletableFuture.completedFuture("Result");

// Already completed with an exception
CompletableFuture<String> failed = CompletableFuture.failedFuture(
    new RuntimeException("Supplier down")
);

// Run async work that returns a value (uses common ForkJoin pool by default)
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Result";
});

// Run async work with your own executor (better for I/O)
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return fetchPrice(productId);
}, executor);

// Run async work with no return value
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Task executing");
});

Manual completion

Sometimes another thread finishes the work later:

CompletableFuture<String> future = new CompletableFuture<>();

new Thread(() -> {
    String result = fetchPriceFromSupplier("SKU-42");
    future.complete(result);
}).start();

String result = future.get(); // Blocks until complete() is called

thenApply — transform the result

thenApply takes the result and transforms it synchronously — like a map step:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Hello")
    .thenApply(s -> s + " World")
    .thenApply(String::toUpperCase);

String result = future.join(); // "HELLO WORLD"

Use join() like get() but without checked exceptions. At the end of a pipeline it is fine. Prefer callbacks over blocking in the middle when you can.

thenCompose — chain another async step

When the next step is also async (another API call), use thenCompose, not thenApply:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Hello")
    .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"));

Difference:

  • thenApply — synchronous transformation, returns a plain value.
  • thenCompose — asynchronous transformation, returns another CompletableFuture.

Getting this wrong — using thenApply when the inner function returns a CompletableFuture — gives you a nested mess: CompletableFuture<CompletableFuture<String>>.

thenAccept and thenRun — side effects

thenAccept runs code with the result but does not change the pipeline value:

CompletableFuture
    .supplyAsync(() -> "Order confirmed")
    .thenAccept(msg -> sendEmail(msg));

thenRun runs after completion, with no access to the result:

CompletableFuture
    .supplyAsync(() -> processPayment())
    .thenRun(() -> System.out.println("Payment step finished"));

Async variants

By default, thenApply runs on the same thread that completed the previous step. To run on a thread pool:

ExecutorService executor = Executors.newFixedThreadPool(10);

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Hello", executor)
    .thenApplyAsync(s -> s + " World", executor)
    .thenApplyAsync(String::toUpperCase, executor);

Use thenApplyAsync / thenComposeAsync when you want each step on a worker thread.

QuickCart order pipeline

Priya wires her order flow as a pipeline:

public CompletableFuture<String> processOrder(String orderId, ExecutorService executor) {
    return CompletableFuture
        .supplyAsync(() -> validateCart(orderId), executor)
        .thenCompose(cart -> CompletableFuture
            .supplyAsync(() -> fetchTotalPrice(cart), executor))
        .thenCompose(total -> CompletableFuture
            .supplyAsync(() -> chargeCard(orderId, total), executor))
        .thenApply(chargeId -> "Order " + orderId + " confirmed. Charge: " + chargeId)
        .exceptionally(ex -> "Order failed: " + ex.getMessage());
}

Each step runs when the previous one finishes. The main thread does not block between steps — it registers what to do next.

Pipeline pattern

CompletableFuture<String> pipeline = CompletableFuture
    .supplyAsync(() -> fetchCart(orderId))
    .thenApply(cart -> validateCart(cart))
    .thenCompose(validated -> saveOrder(validated))
    .thenApply(saved -> buildConfirmation(saved))
    .exceptionally(ex -> handleError(ex));

Combining futures

thenCombine — merge two results

When two independent checks must finish before you proceed:

CompletableFuture<String> priceCheck = CompletableFuture.supplyAsync(() -> "Rs 799");
CompletableFuture<String> stockCheck = CompletableFuture.supplyAsync(() -> "In stock");

CompletableFuture<String> combined = priceCheck.thenCombine(stockCheck,
    (price, stock) -> price + " — " + stock);

String result = combined.join(); // "Rs 799 — In stock"

allOf — wait for all

When Priya checks delivery for ten products at once:

CompletableFuture<Boolean> f1 = CompletableFuture.supplyAsync(() -> checkDelivery("A"));
CompletableFuture<Boolean> f2 = CompletableFuture.supplyAsync(() -> checkDelivery("B"));
CompletableFuture<Boolean> f3 = CompletableFuture.supplyAsync(() -> checkDelivery("C"));

CompletableFuture<Void> allDone = CompletableFuture.allOf(f1, f2, f3);

allDone.thenRun(() -> {
    boolean a = f1.join();
    boolean b = f2.join();
    boolean c = f3.join();
    System.out.println("All checks done: " + a + ", " + b + ", " + c);
});

allOf returns CompletableFuture<Void>. After it completes, call join() on each individual future to read its result.

anyOf — first one wins

CompletableFuture<String> gateway1 = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(3000);
    return "Gateway 1";
});
CompletableFuture<String> gateway2 = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(1000);
    return "Gateway 2"; // Fastest
});

CompletableFuture<Object> anyFuture = CompletableFuture.anyOf(gateway1, gateway2);
String result = (String) anyFuture.get(); // "Gateway 2"

Error handling

exceptionally — recover from errors

If any step throws, the pipeline breaks unless you handle it:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> {
        throw new RuntimeException("Payment gateway down");
    })
    .exceptionally(ex -> {
        return "Recovered: " + ex.getMessage();
    });

String result = future.join(); // "Recovered: Payment gateway down"

exceptionally only runs on failure. It returns a fallback value so the pipeline can continue.

handle — success and failure in one place

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> {
        if (Math.random() > 0.5) {
            throw new RuntimeException("Error");
        }
        return "Success";
    })
    .handle((result, exception) -> {
        if (exception != null) {
            return "Default value";
        }
        return result;
    });

String result = future.get(); // Either "Success" or "Default value"

whenComplete — side effect, does not change the value

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> "Result")
    .whenComplete((result, exception) -> {
        if (exception != null) {
            System.err.println("Error: " + exception.getMessage());
        } else {
            System.out.println("Success: " + result);
        }
    });

QuickCart: parallel delivery checks with error recovery

Priya checks whether each product can ship to a pincode. If one check fails, she uses a safe default:

public class DeliveryChecker {
    private final ExecutorService executor = Executors.newFixedThreadPool(50);

    public Map<String, Boolean> checkAll(Map<String, String> productToPincode) {
        Map<String, CompletableFuture<Boolean>> futures = new HashMap<>();

        for (Map.Entry<String, String> entry : productToPincode.entrySet()) {
            String productId = entry.getKey();
            String pincode = entry.getValue();

            CompletableFuture<Boolean> future = CompletableFuture
                .supplyAsync(() -> checkDelivery(productId, pincode), executor)
                .exceptionally(ex -> {
                    System.err.println("Delivery check failed for " + productId);
                    return false; // Default on error
                });

            futures.put(productId, future);
        }

        // Wait for all with timeout
        CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0]))
            .orTimeout(5, TimeUnit.SECONDS)
            .join();

        Map<String, Boolean> results = new HashMap<>();
        for (Map.Entry<String, CompletableFuture<Boolean>> entry : futures.entrySet()) {
            results.put(entry.getKey(), entry.getValue().join());
        }
        return results;
    }

    private boolean checkDelivery(String productId, String pincode) {
        // Call delivery partner API...
        return true;
    }
}

Timeout and fallback

orTimeout

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> slowSupplierCall())
    .orTimeout(5, TimeUnit.SECONDS)
    .exceptionally(ex -> {
        if (ex instanceof TimeoutException) {
            return "Timeout — using cached price";
        }
        return "Error occurred";
    });

Fallback from a secondary source

CompletableFuture<String> primary = CompletableFuture.supplyAsync(() -> primaryPriceApi());
CompletableFuture<String> fallback = CompletableFuture.supplyAsync(() -> cachedPrice());

CompletableFuture<String> result = primary
    .exceptionally(ex -> fallback.join());

Full checkout example

Stock checks run in parallel. When all finish, the order pipeline starts:

public void checkout(String orderId, List<String> productIds, ExecutorService executor) {
    List<CompletableFuture<Boolean>> stockChecks = productIds.stream()
        .map(id -> CompletableFuture
            .supplyAsync(() -> checkStock(id), executor)
            .exceptionally(ex -> false))
        .toList();

    CompletableFuture.allOf(stockChecks.toArray(new CompletableFuture[0]))
        .thenCompose(v -> processOrder(orderId, executor))
        .thenAccept(msg -> System.out.println(msg));
}

Errors in any stock check default to false instead of crashing everything.

What to remember

  • CompletableFuture chains async steps like a pipeline.
  • supplyAsync starts work. thenApply transforms. thenCompose chains another async call.
  • thenCombine merges two futures. allOf waits for all. anyOf takes the first result.
  • exceptionally gives a fallback when something fails. handle handles both success and failure.
  • whenComplete logs or records side effects without changing the result.
  • Pass a custom executor for I/O-heavy QuickCart work — do not overload the common pool.
  • Use orTimeout for long-running supplier calls.
  • Prefer chaining over blocking on get() in the middle of your code.

What Priya does next: parallel orders work great until two workers try to update the same sold-count at the same time — and the numbers stop adding up.