Skip to content

Callable, Future, and CompletableFuture in Java

The Problem That Started Everything

Imagine you are a restaurant manager. You send a chef to the kitchen to cook a complex dish. The chef disappears into the kitchen and you just stand there with no idea whether the dish is ready, whether something went wrong, or when you can expect it. That is exactly the problem Java developers faced when they first started running tasks in background threads.

In the previous lesson you learned about ThreadPoolExecutor. You saw how you can submit a task to a thread pool and the main thread keeps running without waiting. That is asynchronous execution. But once you submitted that task, you were essentially flying blind. You had no way to ask: is the task done? Did it throw an exception? What was the result?

That is the exact gap that Future, Callable, and ultimately CompletableFuture were built to fill. This lesson covers all three, explains how they fit together, and walks you through every method you need to know for interviews and real projects.


Why Future Exists

When your main thread submits a task to a ThreadPoolExecutor, the pool grabs a thread and starts working on that task in the background. The main thread continues executing its own code. That background thread is working independently, producing some result or perhaps throwing an error.

At some later point in time, the main thread might want to know: has that task finished? What did it return? Did it fail? Before Future existed, there was simply no clean answer to these questions. The thread reference was gone and the main thread had moved on.

The Future interface solves this by giving the caller a handle on the asynchronous task. When you call the submit method on a ThreadPoolExecutor, instead of getting nothing back, you get a Future object. Think of it like a ticket at a dry cleaner. You drop off your clothes and they hand you a ticket. You walk away and live your life. Later you come back with the ticket and use it to ask: is my order ready? Can I pick it up now? The ticket is your Future object.


The Five Methods of Future

The Future interface gives you exactly five methods to interact with a background task.

cancel(boolean mayInterruptIfRunning)

This method asks the thread pool to cancel the task you submitted. You pass true to allow the running thread to be interrupted. If the task has already completed, cancel returns false because there is nothing left to cancel. If the task is waiting in the queue or still in progress, the pool will attempt to stop it and return true.

isCancelled()

This is a simple status check. It returns true only if the task was successfully cancelled before it completed normally. If the task finished on its own and then you called cancel, isCancelled returns false.

isDone()

This returns true if the task is finished in any way: normal completion, an exception was thrown, or the task was cancelled. The word "done" means the task is no longer running, regardless of how it ended. While the task is still in progress, isDone returns false.

get()

This is the blocking method. When your main thread calls get on a Future, the main thread stops and waits until the background task finishes. Only after the task is completely done does get return the result. If you have a task that takes twenty minutes, calling get will block your main thread for twenty minutes. That is a very important thing to keep in mind.

get(long timeout, TimeUnit unit)

This is the timeout variant of get. Instead of waiting forever, you tell the Future: I will wait at most three seconds. If the task finishes within those three seconds, great, you get the result. If the task is still running after three seconds, Java throws a TimeoutException. You catch that exception and decide what to do next. Maybe you log it. Maybe you try again later. The choice is yours.


Seeing Future in Action

Here is a concrete example that demonstrates all five methods:

java
import java.util.concurrent.*;

public class FutureExample {
    public static void main(String[] args) throws InterruptedException {
        // Create a thread pool with one thread
        ExecutorService pool = new ThreadPoolExecutor(
            1, 1, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>()
        );

        // Submit a Runnable task and hold the Future reference
        Future<?> futureObject = pool.submit(() -> {
            try {
                // This task sleeps for 7 seconds to simulate heavy work
                Thread.sleep(7000);
                System.out.println("Background task completed");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // Main thread checks status immediately after submitting
        System.out.println("Is task done? " + futureObject.isDone()); // prints false

        try {
            // Wait only 2 seconds. Task takes 7, so TimeoutException fires
            futureObject.get(2, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            System.out.println("Timeout exception happened after 2 seconds");
        } catch (ExecutionException e) {
            System.out.println("Task threw an exception: " + e.getMessage());
        }

        // Now wait indefinitely for the task to finish
        try {
            futureObject.get(); // blocks until task finishes
        } catch (ExecutionException e) {
            System.out.println("Task threw an exception: " + e.getMessage());
        }

        // Now check again
        System.out.println("Is task done now? " + futureObject.isDone()); // prints true
        System.out.println("Was it cancelled? " + futureObject.isCancelled()); // prints false

        pool.shutdown();
    }
}

Notice what happens here. The main thread submits the task and immediately checks isDone. The task sleeps for seven seconds so isDone returns false right away. Then main tries to get the result but only waits two seconds. The TimeoutException fires. Then main calls the no argument get and blocks until the full seven seconds are up. After that, isDone is true and isCancelled is false because the task completed normally.


How Future Works Under the Hood

When you call submit on a ThreadPoolExecutor, something interesting happens internally. Java wraps your Runnable inside a FutureTask object. FutureTask implements the RunnableFuture interface, which in turn extends both Runnable and Future. So FutureTask is simultaneously a Runnable that the thread pool can execute AND a Future that you can query for results.

FutureTask also maintains an internal state. The state goes through transitions like NEW, COMPLETING, NORMAL, EXCEPTIONAL, CANCELLED, and INTERRUPTING. The thread pool keeps updating this state as the task progresses. When you call isDone, it checks this state. When you call get, it waits for the state to reach a terminal value.

This is why you can hold a Future reference and check on it later. The FutureTask object lives on the heap, and both the background thread and your main thread have access to it.


Runnable vs Callable: The Critical Difference

Now you understand Future. But you may have noticed something. When you use a Runnable, the get method returns null. Every single time. That is because Runnable's run method has a void return type. It cannot return a value.

ThreadPoolExecutor actually has three overloaded versions of submit:

submit(Runnable task)
submit(Runnable task, T result)
submit(Callable<T> task)

Version 1: submit(Runnable)

Use this when you do not need a result back. The Future type becomes Future<?> with a wildcard because Java does not know what type to put there. When you call get on this Future, it always returns null. You can still use the Future to check isDone, isCancelled, and to block with get until the task completes.

java
Future<?> f = pool.submit(() -> System.out.println("Just doing work"));
Object result = f.get(); // result is always null

Version 2: submit(Runnable, T result)

This is a creative workaround for when you want to use a Runnable but still get something useful from get. You pass a shared object to the submit call. The Runnable updates that shared object during its execution. When the task finishes, get returns that same object.

java
List<Integer> sharedOutput = new ArrayList<>();

// Pass the shared object to the Runnable through the constructor
MyRunnable myTask = new MyRunnable(sharedOutput);

// The T result parameter is sharedOutput
Future<List<Integer>> future = pool.submit(myTask, sharedOutput);

// This blocks until the task finishes
List<Integer> result = future.get();
// result and sharedOutput point to the same list, now populated
System.out.println(result.get(0)); // prints 300

This works because Java passes the sharedOutput reference into the FutureTask. When the Runnable runs and mutates the list, it mutates the same list that the FutureTask holds a reference to. So get returns that same reference with its updated contents.

This is a workaround though. The cleaner solution is Callable.

Version 3: submit(Callable)

Callable is an interface with a single method called call. Unlike Runnable's run method, call has a return type. It also declares that it can throw a checked exception, which is a bonus because you do not need to wrap exceptions inside the task.

java
Future<List<Integer>> future = pool.submit(() -> {
    List<Integer> output = new ArrayList<>();
    output.add(300);
    return output; // Callable returns a value directly
});

List<Integer> result = future.get();
System.out.println(result.get(0)); // prints 300

Much cleaner. No shared objects, no workarounds, no confusion. The task computes a value and returns it. The Future holds that value. You call get and you receive it.

The one sentence summary: Runnable cannot return a value. Callable can return a value and can throw checked exceptions.


The Problem with Future

Future is useful, but it has a serious limitation: the only way to get the result is to call get, which blocks the calling thread. You cannot say "when the task is done, automatically call this function." You cannot chain operations together. If task A's output feeds into task B, you would need to block waiting for A, then submit B, then block waiting for B. Your main thread spends most of its time sleeping.

There is also no built in way to handle errors gracefully in a chain. If the background task throws an exception, it gets wrapped in an ExecutionException when you call get. You catch it there, but by that point you have already blocked.

Future also has no allOf or anyOf capability. If you have ten background tasks running in parallel, there is no clean way to say "notify me when all ten are done" without blocking on each one individually.

CompletableFuture was introduced in Java 8 specifically to solve all of these problems.


CompletableFuture: The Grown Up Future

CompletableFuture implements the Future interface. That means everything Future can do, CompletableFuture can do too. But it also implements CompletionStage, which gives it the ability to chain operations, handle errors, and combine multiple futures together without blocking.

Think of Future as a basic TV remote with only a power button. CompletableFuture is a universal remote with channels, volume, streaming apps, and programmable macros. Same basic idea, vastly more capability.

Here is the key mental model: every method on CompletableFuture that starts with "then" attaches a follow up action. When the previous stage completes, the next stage automatically starts. You never need to call get just to pass a result to the next step.


supplyAsync: Starting an Asynchronous Operation

supplyAsync is how you kick off an asynchronous computation with CompletableFuture. It takes a Supplier (a functional interface that takes nothing and returns a value) and runs it in a background thread.

java
ExecutorService pool = new ThreadPoolExecutor(
    2, 4, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()
);

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
# Wait, this is in a code block! 
# Let me re-read the file to make sure my hypothesis about prose versus code is right.
# Actually, looking at the grep output, some of those are in code blocks and some are likely in prose.
# Let's re-verify line 214.
    // This runs in a background thread from the pool
    System.out.println("Running in: " + Thread.currentThread().getName());
    return "Task Completed";
}, pool);

// You can still use get if you want to block and fetch the result
String result = future.get();
System.out.println(result); // prints Task Completed

If you do not pass an executor, CompletableFuture uses the common ForkJoinPool by default. The ForkJoinPool is a shared pool that dynamically sizes itself based on the number of available processors. The downside is you have no control over its size. If you need to control minimum and maximum thread counts, always pass your own executor.

runAsync: When You Do Not Need a Result

runAsync is the equivalent of supplyAsync but for tasks that do not return anything. You pass a Runnable instead of a Supplier. The resulting CompletableFuture is of type Void.

java
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Doing work with no return value");
}, pool);

future.get(); // blocks until done, returns null

thenApply: Transforming the Result

thenApply lets you transform the result of a CompletableFuture into something else. It works like the map operation on a stream. The previous stage finishes, hands you its result, you do something with it, and you return a new value. The whole thing returns a new CompletableFuture containing the transformed value.

java
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("supplyAsync on: " + Thread.currentThread().getName());
    return "Concept";
}, pool).thenApply(value -> {
    System.out.println("thenApply on: " + Thread.currentThread().getName());
    return value + "AndCoding"; // transforms "Concept" to "ConceptAndCoding"
});

System.out.println(future.get()); // prints ConceptAndCoding

Critical thread behavior: thenApply is synchronous with respect to the previous stage. The same thread that completed supplyAsync will continue and execute the thenApply function. Thread one does both jobs back to back.

thenApplyAsync: If you want a different thread to handle the thenApply step, use thenApplyAsync. This releases the thread that completed supplyAsync back to the pool and picks another thread for the next step.

java
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("supplyAsync on: " + Thread.currentThread().getName());
    return "Concept";
}, pool).thenApplyAsync(value -> {
    System.out.println("thenApplyAsync on: " + Thread.currentThread().getName());
    return value + "AndCoding";
}); // no pool passed, so ForkJoinPool picks up this step

Run this and you will see supplyAsync runs on pool one thread one, while thenApplyAsync runs on a ForkJoinPool thread. Two different threads handled the two stages.


thenAccept: Consuming Without Returning

thenAccept is designed to be the last step in a chain. It consumes the result of the previous stage but returns nothing. Under the hood it returns CompletableFuture<Void>. Because it returns Void, chaining more thenApply calls after it does not make sense since there is no value to transform.

java
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
    return "Final Result";
}, pool).thenAccept(value -> {
    System.out.println("Received and consuming: " + value);
    // No return statement. This is the end of the chain.
});

future.get(); // waits for everything to finish

Use thenAccept for side effects like saving to a database, logging, or sending a notification. After thenAccept there is nothing left to chain.

thenAcceptAsync follows the same rule as the other Async variants: a new thread handles this step instead of reusing the thread from the previous stage.


thenRun: Running After Completion Without the Result

thenRun is similar to thenAccept but even simpler. It does not receive the result of the previous stage at all. It just runs a Runnable after the previous stage finishes. Use this when you need to trigger an action after completion but you do not care what the result was.

java
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
    return "Some result";
}, pool).thenRun(() -> {
    // Does not receive the "Some result" value
    System.out.println("Previous stage is done. Cleaning up.");
});

future.get();

thenCompose: Ordering Dependent Async Operations

Here is where things get interesting. Suppose task B depends on the result of task A. You might think thenApply would work. And sometimes it does. But thenApply expects a Function that returns a plain value, not another CompletableFuture. If you try to use thenApply to start another async task, you end up with a CompletableFuture&lt;CompletableFuture&lt;String&gt;&gt;, which is a nested future and very awkward to work with.

thenCompose is the solution. It flattens that nesting. You pass a Function that returns a CompletableFuture, and thenCompose unwraps it for you so the result is a clean CompletableFuture&lt;String&gt;.

More importantly, thenCompose guarantees ordering. If you chain multiple thenCompose calls, they execute strictly in order: the first completes, then the second starts, then the third. This is different from having multiple independent supplyAsync calls which run in parallel with no ordering guarantee.

java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
    return "Hello";
}, pool).thenCompose(value -> {
    // This starts a NEW async operation that depends on "Hello"
    return CompletableFuture.supplyAsync(() -> {
        return value + " World";
    }, pool);
}).thenCompose(value -> {
    return CompletableFuture.supplyAsync(() -> {
        return value + " All";
    }, pool);
});

System.out.println(result.get()); // always prints Hello World All in that order

No matter how many thenCompose calls you chain, the ordering is always preserved. Internally CompletableFuture maintains a stack of dependent actions. Even if you use thenComposeAsync on each step, ordering is still maintained.

thenCompose vs thenApply: Use thenApply when the next step is a simple synchronous transformation. Use thenCompose when the next step is itself an asynchronous operation that returns a CompletableFuture.


thenCombine: Merging Two Independent Futures

Sometimes you have two completely independent asynchronous tasks running in parallel, and you want to combine their results when both are done. thenCombine is built for exactly this scenario.

java
// Task one runs independently, returns an Integer
CompletableFuture<Integer> taskOne = CompletableFuture.supplyAsync(() -> {
    // Simulate some work
    return 10;
}, pool);

// Task two runs independently, returns a String
CompletableFuture<String> taskTwo = CompletableFuture.supplyAsync(() -> {
    return "K";
}, pool);

// Combine both results when both are done
CompletableFuture<String> combined = taskOne.thenCombine(taskTwo, (intResult, strResult) -> {
    // intResult is 10, strResult is "K"
    return intResult + strResult; // produces "10K"
});

System.out.println(combined.get()); // prints 10K

Thread one works on taskOne, thread two works on taskTwo simultaneously. Neither waits for the other. When both complete, thenCombine fires the BiFunction, which receives both results and produces the final combined value.

thenCombineAsync spawns a new thread to run the combining function instead of reusing one of the completing threads.


allOf: Waiting for All Futures to Complete

If you have multiple CompletableFuture instances and you want to wait until all of them are done before proceeding, use allOf. It takes a varargs array of CompletableFuture objects and returns a CompletableFuture<Void> that completes only after every single one of the input futures completes.

java
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> "Result 1", pool);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> "Result 2", pool);
CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> "Result 3", pool);

CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);

// Block until all three are done
all.get();

// Now all futures are guaranteed to be complete
System.out.println(f1.get());
System.out.println(f2.get());
System.out.println(f3.get());

Note that allOf returns Void, so you cannot directly get a combined result from it. You call get on each individual future after the allOf completes.

A practical pattern is to collect results into a list after allOf finishes:

java
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.get();

List<String> results = List.of(f1.join(), f2.join(), f3.join());

anyOf: Racing Multiple Futures

anyOf is the opposite of allOf. It completes as soon as ANY one of the provided futures completes. You use it when you send the same request to multiple services and want to use whichever responds first.

java
CompletableFuture<Object> first = CompletableFuture.anyOf(f1, f2, f3);

// Completes as soon as the fastest of f1, f2, f3 finishes
Object result = first.get();
System.out.println("First to finish: " + result);

anyOf returns CompletableFuture<Object> because the futures might have different types. You will need to cast the result to the appropriate type.


exceptionally: Recovering From Errors

When a CompletableFuture stage throws an exception, that exception propagates through the chain. All following thenApply, thenAccept, and thenCompose stages are skipped. The exception arrives at your get call wrapped in an ExecutionException.

But what if you want to recover gracefully from an error mid chain? exceptionally lets you define a fallback function that runs only if an exception occurred. If no exception occurred, exceptionally is skipped entirely.

java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
    if (true) throw new RuntimeException("Something went wrong");
    return "Success";
}, pool).exceptionally(ex -> {
    System.out.println("Caught: " + ex.getMessage());
    return "Fallback Value"; // return a default instead of crashing
});

System.out.println(result.get()); // prints Fallback Value

Use exceptionally at the end of your chain or at any point where you want to insert a safety net.


handle: Processing Both Success and Failure

handle is more flexible than exceptionally. It always runs, regardless of whether the previous stage succeeded or failed. You receive two parameters: the result (null if an exception occurred) and the exception (null if everything succeeded). You then return a value based on whichever you have.

java
CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
    return "Success Result";
}, pool).handle((value, ex) -> {
    if (ex != null) {
        System.out.println("Error: " + ex.getMessage());
        return "Default";
    }
    return value.toUpperCase();
});

System.out.println(result.get()); // prints SUCCESS RESULT

Think of handle as a try catch finally block for a single stage in your chain. It gives you full control over the outcome regardless of what happened in the previous step.


Interview Questions You Must Know

Q: What is the difference between Runnable and Callable?

Both represent a task to be executed by a thread. The difference is that Runnable's run method has a void return type and cannot throw checked exceptions. Callable's call method has a generic return type and can throw a checked Exception. Use Callable when you need the background task to return a result.

Q: What does Future.get() do if the task throws an exception?

get throws an ExecutionException that wraps the original exception. You catch ExecutionException and call getCause on it to get the actual exception that the task threw.

Q: What is the difference between Future and CompletableFuture?

Future is a simple interface that lets you check status and retrieve results. Its only blocking mechanism is get. CompletableFuture extends Future and implements CompletionStage. It adds the ability to chain operations with thenApply, thenCompose, thenAccept, and others. It also supports error handling with exceptionally and handle, and parallel composition with allOf and anyOf. CompletableFuture does not force you to block; you can set up a chain that executes entirely asynchronously.

Q: What is the difference between thenApply and thenCompose?

thenApply takes a Function that returns a plain value. thenCompose takes a Function that returns a CompletableFuture. Use thenCompose when chaining dependent asynchronous operations to avoid getting a CompletableFuture<CompletableFuture<T>> nested result.

Q: What is the difference between thenApply and thenApplyAsync?

thenApply reuses the same thread that completed the previous stage. thenApplyAsync picks a different thread from the pool (or ForkJoinPool if no pool is provided) for the next step. The same Async vs non Async pattern applies to thenAccept, thenRun, thenCompose, and thenCombine.

Q: What thread does supplyAsync use if you do not provide an executor?

It uses the common ForkJoinPool, which is a shared pool that sizes itself based on available processors. You have no control over the thread count. Always pass your own executor when you need predictable resource management.

Q: Why should thenAccept be used at the end of a chain?

thenAccept accepts a Consumer that returns nothing. Its CompletableFuture return type is Void. Because there is no value to transform, chaining thenApply after thenAccept does not make logical sense. Use thenAccept as the terminal step for side effects like printing, saving, or sending a notification.

Q: What is the difference between thenCompose and thenCombine?

thenCompose is for sequential dependent operations: the second operation starts after the first, using the first operation's result. thenCombine is for two independent parallel operations: both run simultaneously and the combining function fires when both complete.

Q: What does allOf guarantee?

allOf returns a CompletableFuture<Void> that completes only after all the provided futures have completed, whether they succeeded or failed. It does not give you the combined results directly; you must call get or join on each future individually after allOf completes.

Q: What is the difference between get and join on CompletableFuture?

Both block until the future completes. get is declared to throw InterruptedException and ExecutionException, which are checked exceptions. join throws an unchecked CompletionException instead. join is generally preferred inside lambda expressions and stream pipelines because you do not need a try catch block.

Q: What happens to the chain if a stage throws an exception?

All subsequent thenApply, thenAccept, and thenCompose stages are skipped. The exception propagates to the end of the chain. If you have an exceptionally or handle callback, those run with the exception. Otherwise the exception surfaces when you call get.


Common Pitfalls

Not calling get or join when you need the result: CompletableFuture chains are lazy in the sense that they all run asynchronously. If your main method returns before get is called, the background threads may be killed by the JVM before they finish.

Using thenApply when you need thenCompose: If your thenApply function calls another async method that returns a CompletableFuture, you end up with CompletableFuture<CompletableFuture<T>>. Calling get on that gives you the inner future, not the result. Switch to thenCompose.

No executor with supplyAsync in production code: The common ForkJoinPool is shared by all code in the JVM including parallel streams. If your task is long running or blocking, it starves other unrelated code. Always use a dedicated executor in production systems.

Forgetting that get throws checked exceptions: Every call to get requires a try catch block for InterruptedException and ExecutionException. In lambda chains this gets verbose. Use join inside lambdas and save get for the outermost call in your main flow.

Calling get on a future that itself is blocked on another get: This can cause a deadlock if the thread pool is exhausted. Prefer the non blocking async chain style and use get only at the very end where you actually need the result to proceed.


Putting It All Together

Here is a real world style example that uses several CompletableFuture methods together. Imagine fetching user data and order data in parallel, combining them, and handling errors:

java
ExecutorService pool = Executors.newFixedThreadPool(4);

CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> {
    // Simulate fetching user from database
    return "User:Alice";
}, pool);

CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> {
    // Simulate fetching orders from database
    return "Order:12345";
}, pool);

// Combine both results when both are ready
CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
    (user, order) -> user + ", " + order
);

// Transform the combined result
CompletableFuture<String> processed = combined.thenApply(data -> {
    return "Processed: [" + data + "]";
});

// Handle any errors gracefully
CompletableFuture<String> safe = processed.exceptionally(ex -> {
    return "Error occurred: " + ex.getMessage();
});

// Print the final result
safe.thenAccept(System.out::println);

// Wait for everything to finish before exiting
safe.get();

pool.shutdown();

Both userFuture and orderFuture run simultaneously. thenCombine waits for both and merges them. thenApply transforms the merged string. exceptionally catches any failures. thenAccept prints the final result. The whole chain is non blocking until the final get at the end.


Summary

You started with the simple problem: how does the main thread know what happened to a background task? Future solved that by giving you a handle. Callable solved the problem of Runnable not being able to return a value. CompletableFuture then took everything further by eliminating the need to block and enabling you to build full async pipelines.

In real MNC codebases, supplyAsync with a get at the end is the most common usage. The chaining methods like thenApply, thenCompose, and thenCombine appear in frameworks and complex service orchestration layers. Knowing all of them deeply, understanding which thread runs each stage, and knowing when to use Async variants will set you apart in any Java interview.

Practice building chains, break them intentionally by throwing exceptions, and observe how exceptionally and handle behave. That hands on experience is the only thing that makes these abstractions click.