Skip to Content
Course content

261: Completable Pipelines with CompletableFuture Chaining

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've likely used CompletableFuture to fire off a single task in the background. That's fine for simple things, but in a real production environment, we rarely just "do one thing" asynchronously. Usually, we have a sequence of dependent events: you fetch a user, then you use that user's ID to fetch their orders, and then you use those orders to calculate a loyalty discount.

If you do this naively with .join() or .get() at every step, you're essentially turning your asynchronous code back into synchronous code, blocking your threads and defeating the whole purpose. Instead, we build a pipeline. I'll show you how to do this by building a small "Travel Booking" sequence.

Defining our asynchronous building blocks

First, let's imagine we have three services: one for users, one for flights, and one for hotels. To keep this focused on the chaining, I'm going to simulate these as methods that return a CompletableFuture after a short delay. I'm using supplyAsync here because these would normally be network calls.

public CompletableFuture<String> fetchUser(String userId) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(100); // Simulate network lag
        return "User: " + userId + " (Premium Member)";
    });
}

public CompletableFuture<String> findFlight(String user) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(100);
        return "Flight LH123 for " + user;
    });
}

public CompletableFuture<String> bookHotel(String flight) {
    return CompletableFuture.supplyAsync(() -> {
        sleep(100);
        return "Hotel Hilton booked for " + flight;
    });
}

Connecting the dots with thenCompose

Now, here is where it gets interesting. I want to chain these together. If I use thenApply, I'm saying "Take the result of the first stage and transform it into something else." But since findFlight returns another CompletableFuture, using thenApply would give me a CompletableFuture<CompletableFuture<String>>. That's a nesting nightmare.

To flatten this, we use thenCompose. Think of it as the "flatMap" of the asynchronous world. It tells Java: "The next function returns a future; please wait for that future to complete and just give me the final result."

CompletableFuture<String> pipeline = fetchUser("Alice")
    .thenCompose(user -> findFlight(user));
// Now 'pipeline' is a CompletableFuture<String> containing the flight info.

The 'Nested Future' mistake I almost made

I'll be honest—when I first started with these, I constantly reached for thenApply because it's more intuitive. I tried to add the hotel booking like this:

// WRONG WAY
CompletableFuture<CompletableFuture<String>> wrong = pipeline
    .thenApply(flight -> bookHotel(flight)); 

I noticed immediately that the return type was wrong. I didn't want a future that contained another future; I just wanted the hotel confirmation. I had to realize that because bookHotel initiates its own asynchronous process, it must be composed, not applied. I corrected it back to thenCompose to keep the pipeline flat.

Closing the loop with thenAccept

Usually, the end of your pipeline isn't another async call, but an action—like logging the result or updating a UI. For this, we use thenAccept. This method consumes the final value and returns CompletableFuture<Void>, effectively signaling the end of the chain.

Here is the complete, corrected pipeline:

CompletableFuture<Void> travelBooking = fetchUser("Alice")
    .thenCompose(user -> findFlight(user))
    .thenCompose(flight -> bookHotel(flight))
    .thenAccept(confirmation -> {
        System.out.println("Booking Complete: " + confirmation);
    });

// Since this is a demo, we block the main thread so the program doesn't exit
travelBooking.join();

Notice how the data flows naturally: User → Flight → Hotel → Console. No blocking calls happened until the very end, meaning the threads were free to do other work while the simulated network calls were pending.




📋 Practical Task

Build a Multi-Stage Order Fulfillment Pipeline

Create a small Java program that simulates an e-commerce order process using CompletableFuture chaining. You must implement the following logic without using .join() or .get() until the very end of the chain:

  • Stage 1: verifyInventory(String productId) - Returns a CompletableFuture<Boolean> indicating if the item is in stock.
  • Stage 2: processPayment(Boolean inStock) - If in stock, returns a CompletableFuture<String> (the Transaction ID). If not in stock, it should complete exceptionally or return a "Payment Failed" message.
  • Stage 3: generateShippingLabel(String transactionId) - Returns a CompletableFuture<String> containing the tracking number.
  • Stage 4: A final thenAccept block that prints: "Order complete! Tracking number: [trackingNumber]".

Constraint: Ensure you use thenCompose for the stages that return new futures to avoid the "Nested Future" (CompletableFuture<CompletableFuture<T>>) problem.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.