ArticlesProjectsCredentialsAbout
javaexception-handlingpatterns

Exception Handling Patterns in Java: Beyond Try-Catch-Finally

·5 min read

Exception Handling Patterns in Java: Beyond Try-Catch-Finally

Java introduced checked exceptions in 1995 — a controversial design decision that forced developers to explicitly handle or declare every exception that a method could throw. By 1998 the Java community had developed clear opinions about which exception handling patterns made codebases better and which made them worse.

At Motorola I had written enough Java to have seen most of the failure modes. Here is what we learnt.

Checked vs Unchecked

Java has two kinds of exceptions:

Checked — subclasses of Exception (not RuntimeException). The compiler requires you to catch them or declare them in the method signature with throws.

Unchecked — subclasses of RuntimeException or Error. The compiler says nothing. They propagate until caught or they crash the JVM.

The original Java design used checked exceptions for recoverable errors (file not found, connection refused) and unchecked for programming errors (null pointer, array index out of bounds).

// Checked — caller must handle or declare
public DeviceStatus getStatus(String ip) throws DeviceNotFoundException, IOException {
    // ...
}

// Unchecked — caller needs no special handling
public DeviceStatus getStatus(String ip) {
    if (ip == null) throw new IllegalArgumentException("ip must not be null");
    // ...
}

The Anti-Patterns

Swallowing exceptions:

try {
    conn = DriverManager.getConnection(url, user, pass);
} catch (SQLException e) {
    // TODO: handle
}
// conn is null here — NPE on next line

This was the most dangerous pattern. The exception was caught, the error was discarded, and the code continued in a broken state. The eventual failure — a NullPointerException two hundred lines later — had no connection to the actual cause.

Catching everything:

try {
    processTrap(trap);
} catch (Exception e) {
    e.printStackTrace();
}

catch (Exception e) catches programming errors (NullPointerException, ClassCastException) alongside expected errors. A NullPointerException is not a trap processing error — it is a bug. Catching it here means the bug continues silently, the trap is silently dropped, and the developer has no idea why the system is misbehaving.

Checked exception pollution:

// If every layer declares throws Exception, the exception handling adds no value
public void startServer() throws Exception {
    readConfig();      // throws IOException
    initDatabase();    // throws SQLException
    startListeners();  // throws SocketException
}

When everything throws Exception, callers cannot make meaningful distinctions. The checked exception system forces handling but the handler gets no useful information.

The Patterns That Worked

Catch specifically, as late as possible:

try {
    status = snmpClient.get(ip, OID.SYS_UPTIME);
} catch (SnmpTimeoutException e) {
    Logger.warn("DevicePoller", "Timeout polling " + ip + ", marking unreachable");
    registry.markUnreachable(ip);
} catch (SnmpAuthException e) {
    Logger.error("DevicePoller", "Auth failure for " + ip, e);
    alertEngine.raise(new AuthFailureAlert(ip));
}

Each exception type has a distinct response. The catch blocks contain meaningful logic, not e.printStackTrace().

Translate at layer boundaries:

public class DeviceRepository {
    public DeviceStatus find(String ip) throws DeviceNotFoundException {
        try {
            PreparedStatement ps = conn.prepareStatement(
                "SELECT * FROM devices WHERE ip = ?");
            ps.setString(1, ip);
            ResultSet rs = ps.executeQuery();
            if (!rs.next()) throw new DeviceNotFoundException(ip);
            return mapRow(rs);
        } catch (SQLException e) {
            // Translate infrastructure exception into domain exception
            // The caller should not need to know this uses a database
            throw new DeviceRepositoryException("Failed to load device " + ip, e);
        }
    }
}

The SQLException is an implementation detail of the persistence layer. Callers that use DeviceRepository should not need to handle SQL errors — they should handle repository errors. Wrap and re-throw with appropriate context, preserving the original cause.

Use unchecked exceptions for unrecoverable errors:

public class DeviceRegistry {
    private final Map<String, DeviceStatus> devices;

    public DeviceRegistry(Map<String, DeviceStatus> devices) {
        if (devices == null) {
            throw new IllegalArgumentException("devices map must not be null");
        }
        this.devices = devices;
    }
}

IllegalArgumentException is a RuntimeException. The caller passed a null where null is not allowed — this is a programming error, not a runtime condition. The appropriate response is to fail fast with a clear message, not to add throws IllegalArgumentException to the caller's signature.

Always log with context:

catch (SnmpException e) {
    // Good: includes context
    Logger.error("DevicePoller", "Failed to poll device " + ip +
                 " after " + retries + " attempts", e);
    // Bad: no context
    e.printStackTrace();
}

The stack trace alone does not tell you which device, how many retries, or what the state was at the time. Include the relevant context in the log message.

The finally Pattern

finally runs whether an exception was thrown or not. Use it for cleanup:

Connection conn = null;
try {
    conn = pool.acquire();
    return executeQuery(conn, sql, params);
} catch (SQLException e) {
    throw new RepositoryException("Query failed: " + sql, e);
} finally {
    if (conn != null) pool.release(conn); // always releases, even on exception
}

The finally block executes before the exception propagates. This guaranteed that the connection returned to the pool whether the query succeeded or not.

What Changed Later

Java 7's try-with-resources replaced the try/finally cleanup pattern for Closeable resources:

try (Connection conn = pool.acquire()) {
    return executeQuery(conn, sql, params);
}
// conn.close() called automatically — cleaner and less error-prone

The debate about checked vs unchecked exceptions continued. By the time of Java 8 and the rise of functional programming in Java, checked exceptions became genuinely problematic — they cannot be used inside lambdas without wrapping. Most modern Java libraries have moved to unchecked exceptions. The pattern of translating at boundaries and catching specifically remains correct regardless of which camp you are in.