Hire Me
← All Writing Java

CompletableFuture Composition — thenCompose, thenCombine, and allOf in Practice

How to compose async operations with CompletableFuture — chaining with thenCompose, combining results with thenCombine, waiting for all with allOf, and handling errors without blocking.

CompletableFuture is Java’s primary tool for non-blocking async composition. The API is large and the naming is not always intuitive, but four methods cover the majority of real use cases: thenApply, thenCompose, thenCombine, and allOf. Understanding when to reach for each, and how to handle errors without .get() on the main thread, makes async code readable rather than a callback nest.

thenApply: transform the result

thenApply is map for a CompletableFuture. It applies a function to the result when it arrives:

CompletableFuture<String> marketId = fetchMarketId();
CompletableFuture<String> url = marketId.thenApply(id -> "/markets/" + id);

The function runs in the same thread that completed the previous stage, or the calling thread if the future is already complete. For non-blocking transformations — parsing, mapping, building URLs — thenApply is correct.

thenCompose: chain async operations

thenCompose is flatMap. Use it when the transformation itself returns a CompletableFuture:

// Wrong: produces CompletableFuture<CompletableFuture<MarketBook>>
CompletableFuture<CompletableFuture<MarketBook>> nested =
    fetchMarketId().thenApply(id -> fetchMarketBook(id));

// Correct: produces CompletableFuture<MarketBook>
CompletableFuture<MarketBook> flat =
    fetchMarketId().thenCompose(id -> fetchMarketBook(id));

thenCompose unwraps the nested future, producing a flat chain:

CompletableFuture<OrderResult> placed = fetchMarketId()
    .thenCompose(id -> fetchMarketBook(id))
    .thenCompose(book -> validateAndPlace(book, orderRequest));

Each step returns a CompletableFuture and the chain stays flat.

thenCombine: merge two independent futures

When two async operations can run in parallel and you need both results:

CompletableFuture<MarketBook> book   = fetchMarketBook(marketId);
CompletableFuture<AccountFunds> funds = fetchAccountFunds();

CompletableFuture<OrderDecision> decision = book.thenCombine(funds,
    (marketBook, accountFunds) -> computeOrderDecision(marketBook, accountFunds));

Both fetchMarketBook and fetchAccountFunds start immediately and run concurrently. The combiner function runs when both complete.

allOf: wait for multiple futures

allOf waits for an array of futures to all complete. It returns CompletableFuture<Void>, so you retrieve results separately:

List<String> marketIds = List.of("1.234567", "1.234568", "1.234569");

List<CompletableFuture<MarketBook>> futures = marketIds.stream()
    .map(id -> fetchMarketBook(id))
    .collect(Collectors.toList());

CompletableFuture<Void> all = CompletableFuture.allOf(
    futures.toArray(new CompletableFuture[0]));

CompletableFuture<List<MarketBook>> books = all.thenApply(v ->
    futures.stream()
        .map(CompletableFuture::join)   // safe — all are complete at this point
        .collect(Collectors.toList()));

The join() calls inside thenApply are safe because allOf ensures all futures are complete before the callback runs.

anyOf: first result wins

For competitive requests — try two sources and use whichever responds first:

CompletableFuture<MarketBook> primary   = fetchFromPrimary(marketId);
CompletableFuture<MarketBook> secondary = fetchFromSecondary(marketId);

CompletableFuture<Object> first = CompletableFuture.anyOf(primary, secondary);

anyOf returns CompletableFuture<Object> — cast carefully. This is a niche pattern; for most use cases, retry with backoff is cleaner than racing two requests.

Error handling: exceptionally and handle

Avoid .get() which throws checked exceptions and blocks:

// thenApply on a failed future propagates the exception — no crash
CompletableFuture<MarketBook> result = fetchMarketBook(marketId)
    .exceptionally(ex -> {
        log.error("Failed to fetch market book", ex);
        return MarketBook.empty();   // fallback value
    });

handle receives both the result and the exception (one will be null):

CompletableFuture<MarketBook> result = fetchMarketBook(marketId)
    .handle((book, ex) -> {
        if (ex != null) {
            log.error("Market fetch failed", ex);
            return MarketBook.empty();
        }
        return book;
    });

For propagating errors into the next stage:

CompletableFuture<OrderResult> order = fetchMarketBook(marketId)
    .thenCompose(book -> {
        if (!book.isOpen()) {
            return CompletableFuture.failedFuture(
                new MarketClosedException(marketId));
        }
        return placeOrder(book, request);
    });

Specifying a thread pool

By default, thenApply and thenCompose run on the completing thread or the ForkJoinPool.commonPool(). For I/O-bound operations, use a dedicated executor:

Executor ioExecutor = Executors.newVirtualThreadPerTaskExecutor();   // Java 21

CompletableFuture<MarketBook> result =
    CompletableFuture.supplyAsync(() -> fetchMarketBook(marketId), ioExecutor)
        .thenApplyAsync(book -> enrichWithRunners(book), ioExecutor);

thenApplyAsync runs the callback on the executor rather than the completing thread — important if the completing thread is a Netty event loop or another thread you must not block.

A realistic example

Fetch market data and account funds concurrently, validate, then place order — all non-blocking:

public CompletableFuture<OrderResult> executeStrategy(String marketId, OrderRequest request) {
    CompletableFuture<MarketBook> bookFuture   = fetchMarketBook(marketId);
    CompletableFuture<AccountFunds> fundsFuture = fetchAccountFunds();

    return bookFuture
        .thenCombine(fundsFuture, (book, funds) -> {
            riskService.validate(book, funds, request);
            return book;
        })
        .thenCompose(book -> orderService.placeOrder(book, request))
        .exceptionally(ex -> {
            log.error("Strategy execution failed for {}", marketId, ex);
            return OrderResult.failed(ex.getMessage());
        });
}

No blocking, no thread management, no nested callbacks. The chain reads like a sequential description of the steps.

If you’re working on async Java services and want a review of concurrency design, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.