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:
- First, fetch customer details.
- Use the customer details to fetch their orders.
- Calculate the total order amount.
- 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.
- supplyAsync() returns 1000.
- thenApply() adds 200 and returns 1200.
- thenAccept() receives 1200 and prints it.
Notice that thenAccept() does not return the value received by its action.
Therefore, the resulting CompletableFuture has type
CompletableFuture<Void>.
Remember:
- thenApply(): Receives a result and produces a new result.
- thenAccept(): Receives a result and performs an action without returning a result.
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:
- thenApply() transforms a value.
- thenAccept() consumes a value.
- thenRun() simply runs another action.
6. Understanding thenCompose()
Now let's explore another useful method.
Imagine that we have two operations.
- Find a customer's ID using their email address.
- 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 first supplyAsync() returns customer ID 101.
- thenCompose() receives 101.
- It starts another asynchronous operation using that ID.
- The final CompletableFuture represents the result of the second operation.
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:
- Product details.
- Delivery charges.
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.
- The first task fetches the product price.
- The second task fetches the delivery charge.
- Both tasks can execute independently.
- thenCombine() waits for both results to become available.
- 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:
- thenCombine() combines two results and returns a new result.
- thenAcceptBoth() consumes two results and performs an action.
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:
- Customer details.
- Order details.
- Payment details.
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.
You have completed 39 out of 46 classes.