Skip to Content
Course content

86: CompletableFuture for Async Code

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

I've seen a lot of developers transition into asynchronous programming by reaching for the Future interface. On the surface, it makes sense: you kick off a task in a separate thread, and you get a handle to the result. But here is the catch—Future is fundamentally passive. If you want to know if the task is done, you either poll it with isDone() or you call get(). The moment you call get(), your current thread stops dead in its tracks until the result arrives. You've essentially turned your asynchronous call back into a synchronous one, just with more boilerplate.

The Blocking Trap

Imagine we're building a travel booking dashboard. To give a user a total price, we need to fetch a flight price, a hotel price, and a car rental price from three different external APIs. If we use the old-school Future approach, it looks something like this:

Future<Integer> flightPrice = executor.submit(() -> fetchFlightPrice());
Future<Integer> hotelPrice = executor.submit(() -> fetchHotelPrice());
Future<Integer> carPrice = executor.submit(() -> fetchCarPrice());

// This is where it all falls apart
int total = flightPrice.get() + hotelPrice.get() + carPrice.get(); 

At first glance, this looks parallel. And it is—the three requests are flying across the network simultaneously. But look at that last line. The main thread is now blocked. If the flight API is lagging, your entire application hangs at flightPrice.get(), even if the hotel and car prices came back in milliseconds. We've traded a few milliseconds of network latency for a complete freeze of the executing thread. In a high-traffic web server, doing this across thousands of requests is a recipe for a thread-exhaustion disaster.

Pipelines instead of Waiting Rooms

This is why CompletableFuture is a game-changer. It allows us to treat asynchronous results as a pipeline. Instead of saying "wait here until this is done," we say "when this completes, do that." It moves us from a pull-based model to a push-based model.

If we rewrite our travel dashboard logic using CompletableFuture, we can chain the operations. I personally prefer using thenCombine when I have two independent results that need to be merged, or thenCompose when the second task depends on the result of the first. For our total price calculation, it looks like this:

CompletableFuture<Integer> flightFuture = CompletableFuture.supplyAsync(() -> fetchFlightPrice());
CompletableFuture<Integer> hotelFuture = CompletableFuture.supplyAsync(() -> fetchHotelPrice());
CompletableFuture<Integer> carFuture = CompletableFuture.supplyAsync(() -> fetchCarPrice());

CompletableFuture<Integer> totalFuture = flightFuture
    .thenCombine(hotelFuture, (flight, hotel) -> flight + hotel)
    .thenCombine(carFuture, (subtotal, car) -> subtotal + car);

// Now we can handle the result whenever it's ready, without blocking the main thread
totalFuture.thenAccept(total -> System.out.println("Total trip cost: " + total)); 

The beauty here is that the main thread never stops. It defines the workflow and then moves on. When the APIs eventually respond, the JVM triggers the chain of callbacks. I'll be honest: the stack traces for CompletableFuture can be a nightmare because they don't follow a linear path, but the performance gain from not blocking your threads is worth the headache.

Handling the Inevitable Failure

In the naive Future approach, you're forced to wrap every get() call in a try-catch block for ExecutionException. It's noisy and ugly. CompletableFuture handles this much more elegantly with exceptionally(). You can attach a failure handler at the end of your chain, acting like a catch block for the entire asynchronous pipeline.

If the car rental API crashes, you probably don't want the whole trip quote to fail. You can just return a default value or a zero. By adding .exceptionally(ex -> 0) to the carFuture, you ensure that a single failing dependency doesn't tank the entire user experience. This kind of resilience is nearly impossible to implement cleanly using standard Future handles without writing a mountain of boilerplate code.




📋 Practical Task

Build a Resilient User Profile Aggregator

You are tasked with creating a system that aggregates a user's profile from three different microservices: UserService (basic info), OrderService (last purchase date), and LoyaltyService (reward points).

Requirements:

  • Use CompletableFuture.supplyAsync to fetch data from all three services in parallel.
  • The UserService call must complete first; the other two calls should only start after you have the user's ID from the UserService (use thenCompose).
  • Combine the results into a single UserSummary POJO using thenCombine or CompletableFuture.allOf().
  • Implement error handling: if the LoyaltyService fails, the UserSummary should simply show 0 points instead of throwing an exception.
  • Ensure the main thread does not block using .get() until the very end of the program for demonstration purposes.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.