Skip to main content

Week 4 Day 2 — throw, throws, and catch

Goal

Today I want the mechanics of raising and handling an exception, including the cause chain.

Main questions:

  1. What does throw do to the stack?
  2. What does throws declare?
  3. How does catch choose a block?
  4. How do I wrap with a cause?
  5. When is catching the wrong move?

1. throw unwinds the stack

public Order get(long id) {
return orders.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}

throw stops the method. Frames unwind until a matching catch or the thread’s default handler. Locals in abandoned frames are gone. Heap objects remain until they are unreachable.

The object I throw is a heap object like any other. It holds a stack trace captured at throw (roughly: fillInStackTrace). That trace is the story of where it was thrown, not where it is caught.

Memory sentence:

throw abandons frames until something catches. The stack trace is from the throw site.


2. throws is the checked contract

public String load(Path path) throws IOException {
return Files.readString(path);
}

throws IOException is required because readString is checked. For RuntimeException subtypes I may write throws as documentation. I usually do not; the type of the exception class is enough.

Overriding: a child method cannot add new checked exceptions the parent did not declare. It can throw fewer. That is substitution (Week 3): callers of the parent type must still compile.

interface Loader {
String load() throws IOException;
}

class CachedLoader implements Loader {
public String load() { // OK: no extra checked exceptions
return cached;
}
}

3. catch matching

Java picks the first catch whose type is assignment-compatible with the thrown object.

try {
load(path);
} catch (FileNotFoundException e) {
// more specific
} catch (IOException e) {
// the rest of I/O
}

Specific before general. catch (IOException e) before catch (FileNotFoundException e) does not compile: the second is unreachable.

Multi-catch when the handling is the same:

catch (IllegalArgumentException | IllegalStateException e) {
throw new BadRequestException(e.getMessage(), e);
}

The variable e is final in multi-catch.

Never catch (Exception e) { } empty. Almost never catch (Exception e) in the middle of a service. The right place for a wide catch is a boundary: servlet filter, @ControllerAdvice, message listener, thread pool wrapper — somewhere that can log, map, or retry.


4. Wrap with a cause

When I translate, I keep the original as the cause:

try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException("Failed to read " + path, e);
}

e is the cause. getCause() and the printed stack show both. Losing the cause is how production incidents become “something failed” with no root.

new OrderNotFoundException(id); // no cause — that is fine if this IS the root
new PaymentFailedException("stripe", e); // always pass e when wrapping

Rethrow the same object when I have not added meaning:

catch (IOException e) {
log.debug("retryable", e);
throw e; // same object, same trace
}

throw e vs throw new IOException(e): wrapping adds a frame and a type. Use a new type when the layer’s language changed (SQL → domain). Use the same e when I only logged.


5. Spring connection

  • orElseThrow(() -> new OrderNotFoundException(id)) is the usual repository-to-service line.
  • Rest controllers should not have five catch blocks. One advice class maps types.
  • Wrapping JDBC’s checked SQLException is what JdbcTemplate already did.

6. Common traps

Trap 1: catch (Exception e) { log.error(e); } without rethrow.
The caller thinks the operation succeeded.

Trap 2: e.printStackTrace() as the only handling.
Goes to stderr, not the app log, and the failure still needs a decision.

Trap 3: catch (FileNotFoundException | IOException e) — invalid, because FileNotFoundException is already an IOException.

Trap 4: Building a new exception without the cause: throw new RuntimeException(e.getMessage()).
The original type and trace are gone.

Trap 5: Catching NullPointerException as flow control.
Fix the null. Do not branch on NPE.


Practice Questions and Answers

Question 1

What is the difference between throw and throws?

Answer:

throw actually raises an exception object and starts unwinding. throws declares that a method may pass a checked exception to its caller. One is runtime control flow. The other is a compile-time contract.


Question 2

Why must the more specific catch come first?

Answer:

Matching is first-fit. A general catch (IOException e) would already handle FileNotFoundException, so a later specific catch would be dead code. The compiler rejects that.


Question 3

How do I wrap an exception without losing the root?

Answer:

Pass the original as the cause: new DomainException("payment failed", e). Logs and getCause() keep the chain. Do not copy only e.getMessage().


Question 4

Can an override add throws IOException if the parent has none?

Answer:

No. Callers using the parent type never declared that checked exception. Adding it would break substitution. The child can throw unchecked types, or catch and translate.


Question 5

When is catch (Exception e) acceptable?

Answer:

At a process boundary that must not kill a thread silently: a message listener, a scheduled job wrapper, @ControllerAdvice. Even there I log, map to a response or retry, and I do not swallow. Inside OrderService.place I catch the specific types I can compensate for, or I let it propagate.


Memory sentences

throw unwinds. throws declares a checked contract.

Catch specific before general. Empty catch is a production bug.

Wrap with the cause. Do not keep only the message.

Next: Week 4 Day 3 — try-with-resources and finally