Exceptions Should Be Exceptional: Error Handling Best Practices


When I learned Java at university, exceptions were presented as the standard way to handle errors. You declare which exceptions a method throws, you wrap risky code in try/catch blocks, and the compiler checks that you have handled everything. It felt rigorous. It felt safe.

Years later, I see exceptions very differently. Kotlin, which is essentially modern Java, dropped checked exceptions entirely. That decision reflects a broader shift in how the industry thinks about error handling.

My position comes down to one sentence:

Exceptions should be exceptional.

The corollary is that most errors should not be exceptions. When you treat every failure as an exception, you end up with methods that throw for routine conditions, callers that catch for reasons unrelated to their own logic, and a codebase where control flow is scattered across try/catch blocks instead of being visible in the types.

When Throwing an Exception Is the Right Call

Exceptions are appropriate in exactly two situations: when the operation is inherently unreliable and when the failure is unrecoverable.

The first category covers interactions with anything outside your process. Network calls, disk I/O, database connections, and third-party APIs can fail for reasons your code cannot control or predict. The network drops a packet. The disk fills up. The database rejects a connection. These failures are not logic errors in your code, they are environmental failures that can happen even when every input is valid.

The second category covers failures where there is no reasonable recovery path. A user tries to access a resource they do not have permission to view. A required configuration file is missing at startup. The application runs out of memory. In these cases, the correct response is to abort the current operation or crash the process, not to ask the caller to figure out a fallback.

Some developers argue that exceptions should never be used for unreliable code either, preferring to model every possible failure in the return type. That is a valid position, and languages like Rust and Haskell take it. But in the Java ecosystem, the standard libraries throw exceptions for I/O and network failures. You will encounter them regardless of your preference, and fighting the standard library’s exception model often creates more friction than it removes.

How to Handle Exceptions from Unreliable Code

When you call something that throws for environmental reasons, handle the exception as close to the source as possible. The method that makes the network call should catch the exception, translate it into a meaningful return type, and let the rest of the codebase work with normal values.

public Result<User, Error> fetchUser(String userId) {
    try {
        User user = apiClient.getUser(userId);
        return Result.ok(user);
    } catch (NetworkException e) {
        log.warn("Failed to fetch user {}: {}", userId, e.getMessage());
        return Result.err(Error.NETWORK_FAILURE);
    }
}

The caller of fetchUser never sees a checked exception. It sees a Result type that makes both the success and failure paths explicit. The error handling is visible in the method signature, not hidden in a throws declaration.

This pattern requires that your language or library supports a way to represent success-or-failure in the type system. Java does not have a built-in Result type, but libraries like vavr provide one, and you can write a simple sealed class yourself. Kotlin has Result in its standard library, and its nullable type with the safe call operator (?.) provides a lighter-weight alternative for cases where you only need to propagate a null on failure.

fun fetchUser(userId: String): User? {
    return try {
        apiClient.getUser(userId)
    } catch (e: NetworkException) {
        log.warn("Failed to fetch user $userId: ${e.message}")
        null
    }
}

// Caller uses safe call operator
val email = fetchUser("123")?.email

The key property of both approaches is that the caller must explicitly handle the failure case. There is no way to forget a catch block because the type checker enforces it. Compare this with a checked exception, where a caller can add throws to its own signature and pass the problem up the stack without ever deciding what the failure means for its own logic.

How to Handle Exceptions from Unrecoverable Cases

Unrecoverable exceptions should propagate to a single handler at the boundary of your system. In a web application, that is usually a controller advice or middleware that catches the exception, logs it, and returns an appropriate HTTP response. In a batch job, it might be a top-level try/catch in the main method that logs the failure and exits with a non-zero code.

The important rule is that no code between the source of the exception and the boundary handler should catch it. If a service method calls a repository that throws ForbiddenException when the user lacks permissions, the service should not try to catch that exception and switch to an alternative code path. The exception means the request cannot proceed, and attempting to recover from it in the middle of the stack introduces implicit control flow that makes the code harder to reason about.

Here is what this looks like in a typical web framework:

// In the controller or handler
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable String id) {
    return orderService.findById(id);
}

// At the boundary
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ForbiddenException.class)
    public ResponseEntity<ErrorResponse> handleForbidden(ForbiddenException e) {
        return ResponseEntity
            .status(HttpStatus.FORBIDDEN)
            .body(new ErrorResponse("Access denied"));
    }
}

The service never catches ForbiddenException. The controller never catches it either. It passes straight through to the handler, which maps it to an HTTP 403 response. The error path is linear and easy to follow.

What Not to Do: Exceptions for Control Flow

The most common misuse of exceptions is using them to implement control flow. This happens when a developer throws an exception to signal a routine condition and then catches it in a caller to branch logic.

Consider this example from the C2 wiki:

try {
    processOrder(order);
} catch (InsufficientInventoryException e) {
    // This is not exceptional; it is a routine business check
    backorder(order);
}

The insufficient inventory case is a normal business outcome. It should be modeled as a return value, not as an exception. The caller should check the result of processOrder and decide what to do based on the returned state, not based on which exception was thrown.

Using exceptions for control flow has real costs:

No exception handling should appear in business logic. If you find a try/catch in a method that implements a domain rule, that is a sign that the error should be modeled as a return type and handled explicitly by the caller.

Exceptions in Practice

The guidelines in this note leave room for judgement. There are cases where a system constraint makes the clean approach impractical. A legacy library that throws checked exceptions for everything may force you to catch exceptions in places you would rather not. A large existing codebase that already uses exceptions for error signaling may not be worth rewriting.

When you do need to deviate from these rules, document the decision and make the exception handling visible. A single well-commented catch block is better than silent propagation that hides the error from the caller. But as a default, if you design your error handling around return types for business logic and exceptions only for truly exceptional conditions, your codebase will be more explicit, easier to test, and simpler to reason about.