Checkout calls charge(order). The gateway times out, so the helper returns null, or a dummy Order whose id is "FAILED", or false next to an otherwise normal-looking total. Every caller now has two jobs: interpret the dummy, and remember to look.

Throw when the operation failed; return a value — including empty Optional — when you have an answer.

This post owns checked vs unchecked vs Error, try-with-resources, and the wrap-at-lambda-boundary policy. SAM and target typing live on the FI hub. Absence — no row for this id — is Optional, not a thrown miss and not a null Order. Generics made List<Order> mean a list of orders; this contract is what callers do when work fails.

Mental model

Java has one throwable hierarchy and three jobs:

KindExtendsJob
ErrorThrowableThe JVM is dying (OutOfMemoryError, StackOverflowError). Do not catch it in application code.
UncheckedRuntimeExceptionA programming bug or a domain failure you will not make every caller declare.
CheckedException minus RuntimeExceptionA recoverable failure the compiler forces the caller to handle or declare.

Throwable is the root. You throw Exception subtypes, not Error. RuntimeException is an Exception the compiler does not track. That is the whole split.

A return is an answer: an Order, an empty List, an empty Optional. A throw is “this did not complete.” Mixing them — returning a sentinel because throwing felt loud — hides the failure until a later NPE or a FAILED id in a paid-orders report.

Absence is not failure. findOrder(id) returning Optional.empty() means there is no row. The lookup worked. A connection timeout is failure. Use Optional for the miss; throw (or wrap) for the timeout.

Throw vs return

The dishonest charge turns a gateway failure into a value the rest of checkout will treat as success-shaped:

Order charge(Order order) {
    try {
        gateway.charge(order); // side effect: money moves, or it does not
        return order;
    } catch (Exception e) {
        return null; // or Order.FAILED
    }
}

Callers write if (charged != null) and still forget a path. Tests pass with a dummy. Production posts a paid event for an order that never left the wallet.

Throw, and keep the cause:

void charge(Order order) {
    try {
        gateway.charge(order);
    } catch (GatewayException e) {
        throw new PaymentFailedException("charge failed for " + order.id(), e);
    }
}

PaymentFailedException can be unchecked if checkout already treats a failed charge as a stop, or checked if some callers retry and others abort. Either way, the method does not return an Order that did not get charged.

Contrast the finder. No row is a normal answer:

Optional<Order> findOrder(String id) {
    return Optional.ofNullable(orders.get(id));
}

Order requireOrder(String id) {
    return findOrder(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
}

findOrder returns absence. requireOrder throws because this caller already decided the row must exist. Optional owns that unwrap; do not invent return Order.MISSING to avoid orElseThrow.

Note: orElseThrow is still a throw. Use it when empty here is a bug in the caller’s assumptions, not when empty is a valid display (“no orders yet”).

Checked, unchecked, and Error

Pick the kind by what you want the caller to be forced to do.

Unchecked for arguments and states that should not happen if the caller is correct:

public record LineItem(String sku, int quantity, BigDecimal unitPrice) {
    public LineItem {
        Objects.requireNonNull(sku, "sku");
        Objects.requireNonNull(unitPrice, "unitPrice");
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive: " + quantity);
        }
    }
}

IllegalArgumentException and IllegalStateException stay unchecked so every new LineItem(...) does not grow a throws clause. Same for NullPointerException from Objects.requireNonNull, and for domain runtime exceptions like PaymentFailedException when nobody at the call site has a real recovery besides fail the request.

Checked when the caller has a recovery the compiler should not let them skip — typically I/O. Files.readString throws IOException because the file can be missing, unreadable, or truncated, and the caller might fall back, retry, or fail the job. That API is the next post: java.nio.file. This post is why that throws is not optional.

String receipt(Order order) throws IOException {
    return Files.readString(receiptPath(order.id()));
}

A method that calls receipt must catch IOException or declare it. That is the feature. It is also why lambdas hurt — Function apply does not declare checked exceptions. The wrap policy below is the answer; hiding IOException behind return null is not.

Error is not your protocol. Do not catch OutOfMemoryError to “keep going.” Do not declare throws Error. If you are writing a top-level handler that logs a crash, catch RuntimeException or a domain type, not Throwable, unless you are the last frame before the process dies.

Custom types are worth a name when callers already say the word (PaymentFailedException, OrderNotFoundException). Reuse IllegalArgumentException for “this argument is garbage.” Always pass the cause into the wrapper constructor so the original stack is not deleted.

Close resources with try-with-resources

A resource that must be closed implements AutoCloseable. Try-with-resources closes it on both the success path and the throw path — including when close() itself throws.

String loadReceipt(Path path) throws IOException {
    try (BufferedReader reader = Files.newBufferedReader(path)) {
        return reader.readLine();
    }
}

No finally. No null-check before close(). If read throws and close throws, the read is the primary exception and close is suppressed on it (getSuppressed()). That is why TWR beats a hand-rolled finally that overwrites the original failure.

Multiple resources close in reverse order of declaration:

void copyReceipt(Path src, Path dst) throws IOException {
    try (var in = Files.newBufferedReader(src);
         var out = Files.newBufferedWriter(dst)) {
        in.transferTo(out);
    }
}

An already-open AutoCloseable can enter the try (Java 9+): the variable must be final or effectively final.

BufferedReader reader = Files.newBufferedReader(path);
try (reader) {
    return reader.readLine();
}

Write your own closeable when you own a session, not when you only want a finally. A PaymentGateway that holds a connection should be AutoCloseable; a charge method that already returns should just throw.

Note: Virtual-thread executors and FFM arenas already show a TWR one-liner — see virtual threads and FFM. Those posts assume this rule; they are not a second exceptions lecture. Unnamed _ can occupy a catch slot you will not inspect. It does not make an empty body legal.

Wrap at lambda boundaries

This is the policy Function and Streams advanced point at. You own it here.

Function.apply, Consumer.accept, and the other java.util.function SAMs do not declare checked exceptions. A lambda that calls Files.readString cannot just throw IOException. At that boundary you have three honest options:

  1. Wrap — catch the checked exception and throw an unchecked one that keeps the cause.
  2. Preprocess — do the I/O in a method that can declare throws, then map over data that is already in memory.
  3. Don’t use that SAM — write a loop, or a method that declares throws. (Callable.call is the rare JDK SAM that does declare Exception; that is a reason to pick it, not a reason to empty-catch inside apply.)

Wrap when the pipeline is the right shape and failure should abort it:

List<String> receiptsFor(List<Order> orders) {
    return orders.stream()
            .map(order -> {
                try {
                    return Files.readString(receiptPath(order.id()));
                } catch (IOException e) {
                    throw new UncheckedIOException("receipt " + order.id(), e);
                }
            })
            .toList();
}

UncheckedIOException exists for this wrap. A domain RuntimeException with the same cause is fine when the rest of checkout already catches that type. The cause is mandatory. A wrap that new RuntimeException(e.getMessage()) without e is a stack-trace amputation.

Preprocess when you can declare throws on the enclosing method and would rather keep map stupid:

List<String> receiptsFor(List<Order> orders) throws IOException {
    List<String> receipts = new ArrayList<>();
    for (Order order : orders) {
        receipts.add(Files.readString(receiptPath(order.id())));
    }
    return receipts;
}

The loop is not a failure of nerve. It is option 3: this work was never a pure Function.

Empty catch is a bug — including catch (IOException e) { return null; } and catch (Exception ignored) {}. You did not wrap, preprocess, or pick another SAM. You deleted the failure. Streams will happily map that null into the list and the paid-orders report will look fine.

Logging then swallowing is the same bug with a log line. Log and wrap, or declare throws, or stop using the SAM.

Pitfalls

Sentinel returns. null, Order.FAILED, -1, false meaning “threw but we caught it” — each one is a second protocol beside the type system. Throw, or return Optional / an empty list when the honest answer is absence.

Exceptions as control flow. Do not throw to skip a row, leave a nested loop, or mean “not in the map.” Catching your own exception in the same method is an if with a stack trace:

// don't
for (Order order : orders) {
    try {
        if (!order.active()) {
            throw new InactiveOrderException(order.id());
        }
        charge(order);
    } catch (InactiveOrderException e) {
        continue;
    }
}

// do
for (Order order : orders) {
    if (!order.active()) {
        continue;
    }
    charge(order);
}

break, return, Optional, and a boolean check are cheaper and honest. Catching NumberFormatException around every token is a parser written as a crash handler.

Catching Exception or Throwable. You will eat NullPointerException, IllegalArgumentException, and (with Throwable) OutOfMemoryError. Catch the type you can recover from. A framework boundary may catch RuntimeException to turn it into a 500; that is one place, not every map.

Lost cause. throw new PaymentFailedException("failed") after catch (GatewayException e) drops e. Pass e as the cause.

finally that closes. It still works. It is also easy to overwrite the original exception if close() throws. Prefer TWR. Use finally for cleanup that is not AutoCloseable (clear a ThreadLocal, restore interrupt status).

Checked exceptions on a public SAM you control. If you are designing the slot, a checked throws on your own interface is allowed — and then lambdas that fill it must handle it, which is why java.util.function refused that design. Do not “fix” Function by wrapping it in ThrowingFunction that every helper re-declares. Wrap, preprocess, or don’t use that SAM.

When not to throw

Skip a throw when:

  • The miss is normal — return Optional<Order> or List.of().
  • The question is yes/no — return boolean (or a Predicate in a filter slot).
  • You are still deciding control flow inside one method — if / switch / return, not throw + catch in the same stack frame.
  • The failure is an Error you did not cause and cannot usefully handle.

Throw when the method cannot keep its promise: the charge did not happen, the file did not read, the LineItem quantity is garbage, the order this request required is not there. Do not catch just to return a dummy that looks like success.

Cheat sheet

Return     an answer: Order, Optional.empty(), List.of()
Throw      the method could not keep its promise

Error      don't catch in app code
Unchecked  RuntimeException — bugs, bad args, domain stops
Checked    Exception minus RuntimeException — caller must handle or declare

TWR        try (AutoCloseable x = ...) { }  — close on success and throw
           close() failure is suppressed on the primary exception

Lambda     Function / Consumer / Stream.map do not declare checked exceptions
           wrap (UncheckedIOException + cause) | preprocess | don't use that SAM
           empty catch is a bug

Next       java.nio.file for Paths, Files, and IOException in practice

Do:

  • Throw on failure; return Optional / empty collections for absence.
  • Close AutoCloseable with try-with-resources; pass the cause when you wrap.
  • At a lambda boundary, wrap, preprocess, or pick a loop / throws method.

Don’t:

  • Return null or Order.FAILED because throwing felt loud.
  • Swallow checked exceptions inside apply / map.
  • Use exceptions to break loops or to mean “not found.”
  • Catch Exception / Throwable in ordinary business code.

Wrap-up

An exception is how a method says it did not complete. Checked types force a decision at the call site; unchecked types flag bugs and domain stops without a throws tax on every helper; Error is the JVM’s problem. Return Optional when the lookup worked and the row is missing. Close resources with try-with-resources so close() cannot erase the failure you actually care about.

At a Function or Stream lambda, the compiler will not let a checked exception out. Wrap with a cause, preprocess, or don’t use that SAM. Empty catch is how production maps swallow a failed charge and keep going.

I/O is the usual checked exception you will actually write. Paths, copies, and walks — and the IOException they throw — are the next post.

Next optional step in the series Paths, copies, and walks inside the JVM — not a second Linux find tutorial. java.nio.file: Paths, Copies, and Walks Without the Old IO Soup