Java
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented Java
-
Section 4: Collections Framework
-
Section 5: Exception Handling
-
Section 6: Generics
-
Section 7: Functional Java
-
Section 8: Concurrency
-
Section 9: I/O and NIO
-
Section 10: JVM Internals
-
Section 11: Modern Java Features
-
Section 12: Build Tools and Project Structure
-
Section 13: Testing
-
Section 14: Databases and Persistence
-
Section 15: Networking
-
Section 16: Design and Best Practices
-
Section 17: Reflection and Annotations
-
Section 18: Logging and Diagnostics
-
Section 19: Date, Time, and Internationalization
-
Section 20: Java Platform Module System
-
Section 21: Security in Java
-
Section 22: Advanced Collections and Data Structures
-
Section 23: More Concurrency Patterns
-
Section 24: Compression, Files, and System Integration
-
Section 25: GUI Programming
-
Section 26: Practical Projects
-
Section 27: Data Structures and Algorithms
-
Section 28: Interview and Algorithm Practice
-
Section 29: JSON and Data Interchange
-
Section 30: More Concurrency Utilities
-
Section 31: More Collections and Streams Practice
-
Section 32: More File and System Programming
-
Section 33: Standard Library Deep Dive
-
Section 34: More Practice and Drills
-
Section 35: More Testing and Quality
-
Section 36: More Design Patterns and Architecture
-
Section 37: Career and Ecosystem
-
Section 38: More OOP and Architecture Practice
-
Section 39: More Enterprise Concepts
-
Section 40: Advanced JavaFX
-
Section 41: More Interview Practice
86: CompletableFuture for Async Code
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.supplyAsyncto fetch data from all three services in parallel. - The
UserServicecall must complete first; the other two calls should only start after you have the user's ID from theUserService(usethenCompose). - Combine the results into a single
UserSummaryPOJO usingthenCombineorCompletableFuture.allOf(). - Implement error handling: if the
LoyaltyServicefails, theUserSummaryshould simply show0points instead of throwing an exception. - Ensure the main thread does not block using
.get()until the very end of the program for demonstration purposes.
There are no comments for now.