← Back to Multithreading Course

Class 39: Chaining and Combining CompletableFuture Tasks

Part 7 — Asynchronous Programming and Modern Java

In the previous class, we learned how to create asynchronous tasks using CompletableFuture.

We explored runAsync() for tasks that do not return a result and supplyAsync() for tasks that produce a result.

But real applications rarely perform just one operation. Often, one task depends on the result of another task.

For example, imagine an online shopping application:

  1. First, fetch customer details.
  2. Use the customer details to fetch their orders.
  3. Calculate the total order amount.
  4. Display the final result.

We could write all these operations inside one large task. But CompletableFuture gives us a cleaner way to connect asynchronous operations.

Today, we will learn how to chain and combine CompletableFuture tasks.

1. What Is Chaining?

Chaining means connecting multiple operations so that one operation can execute after another.

Let's understand this with a simple example.

Imagine that we want to calculate the price of a product and then add tax to it.

First, we calculate the product price. Once that result is available, we calculate the final price after adding tax.

These are two connected operations:

Calculate Product Price
          |
          v
Add Tax to Product Price
          |
          v
Display Final Price

CompletableFuture provides methods that allow us to build this kind of workflow.

2. Understanding thenApply()

Let's start with thenApply().

We use thenApply() when we want to take the result of one CompletableFuture operation, transform it, and produce a new result.

Consider this example:

import java.util.concurrent.CompletableFuture;

public class ThenApplyExample {

    public static void main(String[] args) {

        CompletableFuture<Integer> future =
                CompletableFuture.supplyAsync(() -> {

                    System.out.println("Calculating price");

                    return 1000;

                }).thenApply(price -> {

                    System.out.println("Adding tax");

                    return price + (price * 18 / 100);
                });

        Integer finalPrice = future.join();

        System.out.println("Final Price: " + finalPrice);
    }
}

Let's understand what happens.

First, supplyAsync() calculates the product price and returns 1000.

After that, thenApply() receives this value through its parameter called price.

.thenApply(price -> {

    return price + (price * 18 / 100);

});

It adds 18% tax and returns the new value.

The result of the entire chain is another CompletableFuture, containing the final price.

We can visualize it like this:

supplyAsync()
    |
    | Returns 1000
    v
thenApply()
    |
    | Adds 18% tax
    v
Returns 1180

Notice that we did not call get() or join() between these two operations.

CompletableFuture manages the connection between them.

3. Understanding thenAccept()

Now imagine that we have calculated the final price. We simply want to print it.

We do not need another calculated result. We just want to consume the existing result.

This is where thenAccept() is useful.

import java.util.concurrent.CompletableFuture;

public class ThenAcceptExample {

    public static void main(String[] args) {

        CompletableFuture<Integer> future =
                CompletableFuture.supplyAsync(() -> {

                    return 1000;

                }).thenApply(price -> {

                    return price + 200;

                }).thenAccept(finalPrice -> {

                    System.out.println(
                        "Final Price: " + finalPrice
                    );
                });

        future.join();
    }
}

Let's break this down.

Notice that thenAccept() does not return the value received by its action.

Therefore, the resulting CompletableFuture has type CompletableFuture<Void>.

Remember:

4. Understanding thenRun()

What if we want to execute another operation after a CompletableFuture completes, but we do not need its result at all?

We can use thenRun().

import java.util.concurrent.CompletableFuture;

public class ThenRunExample {

    public static void main(String[] args) {

        CompletableFuture<Integer> future =
                CompletableFuture.supplyAsync(() -> {

                    System.out.println("Processing order");

                    return 500;

                }).thenRun(() -> {

                    System.out.println("Order processing completed");

                });

        future.join();
    }
}

Here, supplyAsync() returns 500.

Once that operation completes, thenRun() executes.

However, thenRun() does not receive the previous result. It simply executes the supplied action.

The resulting CompletableFuture is of type Void.

5. thenApply() vs. thenAccept() vs. thenRun()

These three methods are very important. Let's compare them.

Method Receives Previous Result? Returns New Result?
thenApply() Yes Yes
thenAccept() Yes No
thenRun() No No

A simple way to remember:

6. Understanding thenCompose()

Now let's explore another useful method.

Imagine that we have two operations.

  1. Find a customer's ID using their email address.
  2. Use that ID to fetch the customer's order details.

The second operation depends on the result of the first.

Sometimes, the next operation itself returns a CompletableFuture.

In such situations, thenCompose() helps us connect the operations without creating a nested CompletableFuture.

import java.util.concurrent.CompletableFuture;

public class ThenComposeExample {

    public static void main(String[] args) {

        CompletableFuture<String> future =
                CompletableFuture.supplyAsync(() -> {

                    System.out.println("Finding customer ID");

                    return 101;

                }).thenCompose(customerId ->

                    CompletableFuture.supplyAsync(() -> {

                        System.out.println(
                            "Fetching orders for customer "
                            + customerId
                        );

                        return "Order details for customer "
                                + customerId;
                    })
                );

        System.out.println(future.join());
    }
}

Let's understand the flow.

The important point is that thenCompose() connects two asynchronous operations where the second operation itself returns a CompletableFuture.

We do not end up with a nested structure like CompletableFuture of CompletableFuture.

7. Understanding thenCombine()

So far, we have chained operations where one task depends on the result of the previous task.

But what if two tasks are independent?

Imagine that an online shopping application needs to fetch two things:

These two operations do not depend on one another. They can execute independently.

Once both tasks complete, we want to combine their results.

We can use thenCombine() for this.

import java.util.concurrent.CompletableFuture;

public class ThenCombineExample {

    public static void main(String[] args) {

        CompletableFuture<Integer> productPrice =
                CompletableFuture.supplyAsync(() -> {

                    System.out.println("Fetching product price");

                    return 1000;

                });

        CompletableFuture<Integer> deliveryCharge =
                CompletableFuture.supplyAsync(() -> {

                    System.out.println("Fetching delivery charge");

                    return 100;

                });

        CompletableFuture<Integer> finalAmount =
                productPrice.thenCombine(
                    deliveryCharge,
                    (price, delivery) -> price + delivery
                );

        System.out.println(
            "Final Amount: " + finalAmount.join()
        );
    }
}

Let's understand what happens.

  1. The first task fetches the product price.
  2. The second task fetches the delivery charge.
  3. Both tasks can execute independently.
  4. thenCombine() waits for both results to become available.
  5. It combines the two values and produces the final amount.

In this example, the final amount is 1100.

Remember: thenCombine() is useful when we have two independent CompletableFuture operations and want to combine their results.

8. Understanding thenAcceptBoth()

What if we want to receive the results of two independent tasks but do not need to return another result?

We can use thenAcceptBoth().

import java.util.concurrent.CompletableFuture;

public class ThenAcceptBothExample {

    public static void main(String[] args) {

        CompletableFuture<String> customer =
                CompletableFuture.supplyAsync(() -> "Ankit");

        CompletableFuture<Integer> orderCount =
                CompletableFuture.supplyAsync(() -> 5);

        CompletableFuture<Void> result =
                customer.thenAcceptBoth(
                    orderCount,
                    (name, count) -> {

                        System.out.println(
                            name + " has placed "
                            + count + " orders."
                        );
                    }
                );

        result.join();
    }
}

Here, thenAcceptBoth() receives the results of both tasks and performs an action.

It does not return a combined value.

This is similar to thenCombine(), but the purpose is different:

9. Understanding allOf()

Sometimes, we have several independent asynchronous tasks and simply want to wait until all of them complete.

For example, imagine that a dashboard needs to fetch:

We can start all three operations independently and use CompletableFuture.allOf() to represent their combined completion.

import java.util.concurrent.CompletableFuture;

public class AllOfExample {

    public static void main(String[] args) {

        CompletableFuture<String> customer =
                CompletableFuture.supplyAsync(() -> {
                    return "Customer details";
                });

        CompletableFuture<String> orders =
                CompletableFuture.supplyAsync(() -> {
                    return "Order details";
                });

        CompletableFuture<String> payments =
                CompletableFuture.supplyAsync(() -> {
                    return "Payment details";
                });

        CompletableFuture<Void> allTasks =
                CompletableFuture.allOf(
                    customer,
                    orders,
                    payments
                );

        allTasks.join();

        System.out.println(customer.join());
        System.out.println(orders.join());
        System.out.println(payments.join());

        System.out.println("Dashboard data is ready");
    }
}

allOf() completes when all the supplied futures complete.

Notice that allOf() returns a CompletableFuture<Void>.

It does not automatically return a collection of all the individual results.

We retrieve each result from its own CompletableFuture after allOf() completes.

10. Understanding anyOf()

Now imagine that we have started the same type of operation using two different services.

We only need the result from whichever service completes first.

We can use CompletableFuture.anyOf().

import java.util.concurrent.CompletableFuture;

public class AnyOfExample {

    public static void main(String[] args) {

        CompletableFuture<String> service1 =
                CompletableFuture.supplyAsync(() -> {

                    return "Response from Service 1";

                });

        CompletableFuture<String> service2 =
                CompletableFuture.supplyAsync(() -> {

                    return "Response from Service 2";

                });

        CompletableFuture<Object> result =
                CompletableFuture.anyOf(service1, service2);

        System.out.println(result.join());
    }
}

anyOf() completes when any one of the supplied futures completes.

Its result type is CompletableFuture<Object>, because the supplied futures can have different result types.

In this example, the result will be the value produced by whichever service completes first.

An important point: anyOf() does not automatically cancel the other tasks. They may continue running.

11. Quick Comparison of Important Methods

Method Purpose
thenApply() Transform one result into another.
thenAccept() Consume one result without returning a value.
thenRun() Run an action after completion without receiving the result.
thenCompose() Connect operations where the next operation returns a CompletableFuture.
thenCombine() Combine the results of two independent tasks.
thenAcceptBoth() Consume the results of two independent tasks.
allOf() Complete when all supplied futures complete.
anyOf() Complete when any supplied future completes.

We have covered several methods today. Do not try to memorize all of them immediately.

Focus on understanding the purpose of each method and practise the examples.

Test Your Understanding

Let's see how well you understood this class!

Question 1: Which method transforms the result of one CompletableFuture into another result?

Question 2: Which method consumes a result but does not return another result?

Question 3: When is thenCompose() particularly useful?

Question 4: Which method combines the results of two independent CompletableFuture tasks into a new result?

Question 5: What is the difference between allOf() and anyOf()?

Class Summary

Let's quickly revise what we learned today.

  • Chaining connects multiple asynchronous operations.
  • thenApply() transforms a result and returns a new result.
  • thenAccept() consumes a result without returning another value.
  • thenRun() executes an action without receiving the previous result.
  • thenCompose() connects dependent asynchronous operations.
  • thenCombine() combines the results of two independent tasks.
  • thenAcceptBoth() consumes the results of two independent tasks.
  • allOf() completes when all supplied futures complete.
  • anyOf() completes when any one supplied future completes.

Remember: Use chaining when one task depends on another. Use combining when independent tasks need to contribute to a common result.

Next Class: Exception Handling with CompletableFuture

We have learned how to create, chain, and combine asynchronous operations.

But what happens when one of these operations fails?

In real applications, a service may be unavailable, a database call may fail, or a calculation may throw an exception.

In the next class, we will learn how CompletableFuture handles exceptions and how we can recover from failures using methods such as exceptionally(), handle(), and whenComplete().

See you in Class 40!

0% Completed

You have completed 39 out of 46 classes.