Core Java

Java Exception Handling Best Practices: Checked vs Unchecked, try-with-resources and Custom Exceptions

Complete guide to Java exception handling: checked vs unchecked exceptions debate, try-with-resources with AutoCloseable, exception chaining and wrapping, creating domain-specific custom exceptions, global exception handler in Spring Boot, and the anti-patterns that kill production reliability.

Md Sanwar Hossain April 8, 2026 18 min read Java Best Practices
Java Exception Handling Best Practices
TL;DR: Checked exceptions enforce handling at compile time but pollute APIs — prefer unchecked RuntimeException subclasses for most business logic. Always use try-with-resources for closeable resources. Never swallow exceptions silently. Build a clear exception hierarchy for your domain.

Table of Contents

  1. The Exception Class Hierarchy: Error, Exception and RuntimeException
  2. Checked vs Unchecked: The Great Debate
  3. try-with-resources and AutoCloseable
  4. Exception Chaining: Cause and Suppressed Exceptions
  5. Designing Custom Exception Hierarchies
  6. Global Exception Handling in Spring Boot
  7. Logging Exceptions: What to Log and How
  8. Exception Anti-patterns That Cause Production Incidents
  9. Multi-catch and Pattern Matching for instanceof
  10. Conclusion and Exception Handling Checklist

1. The Exception Class Hierarchy: Error, Exception and RuntimeException

Java's exception model is rooted in java.lang.Throwable. Every object that can be thrown or caught must extend Throwable, which splits into two major branches: Error and Exception. Understanding this hierarchy is the foundation for making the right design decisions in your application.

Error — JVM-Level Problems

Error signals conditions that are typically unrecoverable at the application level. They originate from the JVM itself — OutOfMemoryError, StackOverflowError, VirtualMachineError. You should never catch Errors in normal application code because the JVM is in an undefined state and any recovery attempt is almost certainly futile.

Checked Exceptions — Descendants of Exception (not RuntimeException)

Any class that extends Exception but does not extend RuntimeException is a checked exception. The compiler enforces that callers either declare them in throws or handle them in a catch block. Common examples:

Unchecked Exceptions — RuntimeException and Its Subtypes

RuntimeException and all its descendants are unchecked. The compiler does not require you to handle or declare them. These typically represent programming errors or conditions that the caller cannot reasonably recover from at the point of the call. Common examples:

// Exception Hierarchy Overview
Throwable
├── Error                            // JVM-level, do NOT catch
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── AssertionError
└── Exception
    ├── IOException                  // checked
    ├── SQLException                 // checked
    ├── ParseException               // checked
    └── RuntimeException             // unchecked (and all subclasses)
        ├── NullPointerException
        ├── IllegalArgumentException
        ├── IllegalStateException
        ├── ClassCastException
        ├── IndexOutOfBoundsException
        │   └── ArrayIndexOutOfBoundsException
        └── UnsupportedOperationException

// Demonstrating hierarchy in code
try {
    processFile("config.json");
} catch (IOException e) {          // checked — must handle or declare
    log.error("Config file error", e);
    throw new AppStartupException("Cannot load configuration", e);
}

// RuntimeException — no forced handling
public void validateAge(int age) {
    if (age < 0 || age > 150) {
        throw new IllegalArgumentException(
            "Age must be between 0 and 150, got: " + age);
    }
}

2. Checked vs Unchecked: The Great Debate

Few topics in Java generate more disagreement than when to use checked versus unchecked exceptions. Let's examine both sides and land on a pragmatic, production-proven stance.

The Case for Checked Exceptions

Checked exceptions force callers to acknowledge that an operation may fail. This is valuable when: (1) the caller can meaningfully recover — for example, retrying a network call after a timeout, or presenting a "file not found" message to the user; (2) the error is expected and part of the method's contract, not a programming mistake. Joshua Bloch's Effective Java rule: "Use checked exceptions for conditions from which the caller can reasonably be expected to recover."

The Case Against Checked Exceptions (Why Modern Frameworks Avoid Them)

Checked exceptions pollute method signatures up the call stack, forcing every intermediate layer to either catch-and-ignore or add throws declarations it doesn't care about. They also completely break lambda expressions and functional interfaces — you cannot throw a checked exception from a Function<T,R> without wrapping it. Adding a checked exception to a public API is a breaking change that violates the Open/Closed Principle.

The Spring Framework made the deliberate choice to wrap all SQLException instances in the unchecked DataAccessException hierarchy precisely to avoid this pollution. Most modern Java frameworks — Hibernate, Micronaut, Quarkus — follow the same pattern.

Practical Rule of Thumb

// ❌ Checked exception leaks into every layer
public interface UserRepository {
    User findById(long id) throws SQLException;  // every caller must handle this
}

// ✅ Wrap at the boundary, propagate unchecked
public class JdbcUserRepository implements UserRepository {

    @Override
    public User findById(long id) {
        String sql = "SELECT * FROM users WHERE id = ?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, id);
            ResultSet rs = ps.executeQuery();
            if (!rs.next()) {
                throw new UserNotFoundException("User not found: " + id);
            }
            return mapRow(rs);
        } catch (SQLException e) {
            // Wrap checked exception — preserve original cause
            throw new DataAccessException("Failed to fetch user " + id, e);
        }
    }
}

// Lambda-friendly: no checked exception escaping the closure
List<User> users = userIds.stream()
    .map(id -> userRepository.findById(id))  // clean — no throws needed
    .collect(Collectors.toList());
Java Exception Handling Diagram
Java exception hierarchy and handling flow

3. try-with-resources and AutoCloseable

Introduced in Java 7, try-with-resources is the idiomatic way to manage any resource that implements AutoCloseable (or its subinterface Closeable). The JVM guarantees that close() is called on every declared resource, in reverse declaration order, whether the try block completes normally or throws an exception.

Multiple Resources & Reverse-Order Closing

When multiple resources are declared in a single try statement, they are closed in the reverse of the order they were declared. This mirrors the natural LIFO stack discipline — the resource opened last is closed first, preventing resource leaks in layered wrappers.

// ✅ try-with-resources — all three closed in reverse order (rs → ps → conn)
public List<Order> findOrdersByUser(long userId) {
    String sql = "SELECT * FROM orders WHERE user_id = ?";
    List<Order> orders = new ArrayList<>();

    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql);
         ResultSet rs = executeQuery(ps, userId)) {

        while (rs.next()) {
            orders.add(mapRow(rs));
        }
    } catch (SQLException e) {
        throw new DataAccessException("Failed to query orders for user " + userId, e);
    }
    return orders;
}

private ResultSet executeQuery(PreparedStatement ps, long userId) throws SQLException {
    ps.setLong(1, userId);
    return ps.executeQuery();
}

Suppressed Exceptions

If both the try body and a close() call throw exceptions, the body exception is the primary exception. The exception from close() is automatically attached as a suppressed exception via Throwable.addSuppressed(). You can retrieve them with getSuppressed(). This was a common source of silent data loss in pre-Java-7 code using manual finally blocks.

// Inspecting suppressed exceptions
try {
    processData();
} catch (Exception primary) {
    log.error("Primary exception: {}", primary.getMessage(), primary);
    for (Throwable suppressed : primary.getSuppressed()) {
        log.warn("Suppressed during close: {}", suppressed.getMessage(), suppressed);
    }
}

// ❌ Old pattern — close() exception silently overwrites body exception!
Connection conn = null;
try {
    conn = dataSource.getConnection();
    // ... work ...
} finally {
    if (conn != null) conn.close();  // if this throws, original exception is LOST
}

Custom AutoCloseable Example

Any class can implement AutoCloseable to participate in try-with-resources. This pattern is powerful for timers, database transaction wrappers, MDC context managers, and more.

// Custom AutoCloseable: transaction scope wrapper
public class TransactionScope implements AutoCloseable {
    private final Connection connection;
    private boolean committed = false;

    public TransactionScope(DataSource dataSource) throws SQLException {
        this.connection = dataSource.getConnection();
        this.connection.setAutoCommit(false);
    }

    public Connection getConnection() { return connection; }

    public void commit() throws SQLException {
        connection.commit();
        committed = true;
    }

    @Override
    public void close() throws SQLException {
        try {
            if (!committed) {
                connection.rollback();  // auto-rollback on any exception
            }
        } finally {
            connection.close();
        }
    }
}

// Usage
try (TransactionScope tx = new TransactionScope(dataSource)) {
    insertOrder(tx.getConnection(), order);
    updateInventory(tx.getConnection(), order.getItems());
    tx.commit();
}  // auto-rollback + close if any step throws

// Custom AutoCloseable: operation timer
public class OperationTimer implements AutoCloseable {
    private final String operationName;
    private final long startNanos = System.nanoTime();

    public OperationTimer(String name) { this.operationName = name; }

    @Override
    public void close() {
        long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000;
        log.info("Operation '{}' completed in {} ms", operationName, elapsedMs);
    }
}

// Usage
try (OperationTimer ignored = new OperationTimer("processPayment")) {
    paymentService.process(payment);
}

4. Exception Chaining: Cause and Suppressed Exceptions

Exception chaining is one of the most important — and most violated — practices in Java. When you catch an exception and throw a new one, you must pass the original as the cause. Failing to do so destroys the stack trace and makes debugging production incidents an exercise in frustration.

Always Preserve the Original Cause

// ❌ WRONG — original stack trace is lost forever
try {
    userRepository.save(user);
} catch (SQLException e) {
    throw new ServiceException("Failed to save user");  // cause is swallowed!
}

// ✅ CORRECT — original cause is preserved in the chain
try {
    userRepository.save(user);
} catch (SQLException e) {
    throw new ServiceException("Failed to save user " + user.getId(), e);
}

// Accessing the chain programmatically
try {
    orderService.processOrder(orderId);
} catch (AppException e) {
    Throwable cause = e;
    while (cause.getCause() != null) {
        cause = cause.getCause();
    }
    log.error("Root cause: {}", cause.getMessage());
}

// Exception constructors that accept a cause
public class ServiceException extends RuntimeException {
    public ServiceException(String message) { super(message); }
    public ServiceException(String message, Throwable cause) { super(message, cause); }
    public ServiceException(Throwable cause) { super(cause); }
}

// Inspecting suppressed exceptions added manually
Exception primary = new RuntimeException("Primary failure");
Exception secondary = new RuntimeException("Cleanup failed");
primary.addSuppressed(secondary);

for (Throwable t : primary.getSuppressed()) {
    log.warn("Suppressed: ", t);
}

Cause Chain in Stack Traces

When an exception with a cause is printed, the JVM renders the full chain using Caused by: annotations. Each level gives you a different layer of context — your service layer tells you what was attempted; the database layer tells you why it failed. This chain is invaluable during incident investigation.

5. Designing Custom Exception Hierarchies

A well-designed exception hierarchy is a form of domain modeling. It allows exception handlers to operate at the right level of specificity and enables API responses to carry meaningful error codes rather than generic 500 messages.

Base Application Exception

// Error codes enum — maps exceptions to HTTP status and client-facing codes
public enum ErrorCode {
    // User domain
    USER_NOT_FOUND("USR-001", 404, "User not found"),
    USER_ALREADY_EXISTS("USR-002", 409, "User already exists"),
    USER_INACTIVE("USR-003", 403, "User account is inactive"),

    // Order domain
    ORDER_NOT_FOUND("ORD-001", 404, "Order not found"),
    ORDER_ALREADY_PROCESSED("ORD-002", 409, "Order has already been processed"),
    ORDER_VALIDATION_FAILED("ORD-003", 422, "Order validation failed"),

    // Payment domain
    PAYMENT_DECLINED("PAY-001", 402, "Payment was declined"),
    PAYMENT_GATEWAY_ERROR("PAY-002", 502, "Payment gateway unavailable"),

    // Generic
    INTERNAL_ERROR("GEN-001", 500, "An internal error occurred");

    private final String code;
    private final int httpStatus;
    private final String defaultMessage;

    ErrorCode(String code, int httpStatus, String defaultMessage) {
        this.code = code;
        this.httpStatus = httpStatus;
        this.defaultMessage = defaultMessage;
    }

    public String getCode() { return code; }
    public int getHttpStatus() { return httpStatus; }
    public String getDefaultMessage() { return defaultMessage; }
}

// Base exception with error code and context
public class AppException extends RuntimeException {
    private final ErrorCode errorCode;
    private final Map<String, Object> context;

    public AppException(ErrorCode errorCode) {
        super(errorCode.getDefaultMessage());
        this.errorCode = errorCode;
        this.context = new HashMap<>();
    }

    public AppException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
        this.context = new HashMap<>();
    }

    public AppException(ErrorCode errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
        this.context = new HashMap<>();
    }

    public AppException withContext(String key, Object value) {
        this.context.put(key, value);
        return this;
    }

    public ErrorCode getErrorCode() { return errorCode; }
    public Map<String, Object> getContext() { return Collections.unmodifiableMap(context); }
}

// Domain-specific exceptions
public class BusinessException extends AppException {
    public BusinessException(ErrorCode errorCode, String message) {
        super(errorCode, message);
    }
}

public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(long userId) {
        super(ErrorCode.USER_NOT_FOUND, "User not found: " + userId);
        withContext("userId", userId);
    }
}

public class PaymentDeclinedException extends BusinessException {
    public PaymentDeclinedException(String transactionId, String reason) {
        super(ErrorCode.PAYMENT_DECLINED, "Payment declined: " + reason);
        withContext("transactionId", transactionId);
        withContext("reason", reason);
    }
}

public class OrderValidationException extends BusinessException {
    private final List<String> violations;

    public OrderValidationException(long orderId, List<String> violations) {
        super(ErrorCode.ORDER_VALIDATION_FAILED,
              "Order " + orderId + " failed validation: " + violations);
        withContext("orderId", orderId);
        this.violations = List.copyOf(violations);
    }

    public List<String> getViolations() { return violations; }
}

// Usage in service layer
public Order createOrder(CreateOrderRequest request) {
    User user = userRepository.findById(request.getUserId())
        .orElseThrow(() -> new UserNotFoundException(request.getUserId()));

    if (!user.isActive()) {
        throw new AppException(ErrorCode.USER_INACTIVE, "User " + user.getId() + " is not active")
            .withContext("userId", user.getId())
            .withContext("userStatus", user.getStatus());
    }

    List<String> violations = orderValidator.validate(request);
    if (!violations.isEmpty()) {
        throw new OrderValidationException(request.getOrderId(), violations);
    }

    return orderRepository.save(new Order(request));
}

6. Global Exception Handling in Spring Boot

Rather than duplicating exception-to-HTTP-response mapping across every controller, Spring Boot provides @RestControllerAdvice as the centralized place to handle exceptions and return consistent, structured error responses.

// Error response DTO
public record ErrorResponse(
    Instant timestamp,
    int status,
    String errorCode,
    String message,
    String path,
    Map<String, String> fieldErrors
) {
    public static ErrorResponse of(int status, String errorCode,
                                   String message, String path) {
        return new ErrorResponse(Instant.now(), status, errorCode,
                                 message, path, null);
    }

    public static ErrorResponse withFieldErrors(int status, String errorCode,
                                                String message, String path,
                                                Map<String, String> fieldErrors) {
        return new ErrorResponse(Instant.now(), status, errorCode,
                                 message, path, fieldErrors);
    }
}

// Global exception handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // Handle our domain exceptions
    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(
            AppException ex, HttpServletRequest request) {

        ErrorCode code = ex.getErrorCode();
        log.error("AppException [{}] on {}: {}",
                  code.getCode(), request.getRequestURI(), ex.getMessage());

        if (!ex.getContext().isEmpty()) {
            log.debug("Exception context: {}", ex.getContext());
        }

        ErrorResponse body = ErrorResponse.of(
            code.getHttpStatus(), code.getCode(),
            ex.getMessage(), request.getRequestURI());

        return ResponseEntity.status(code.getHttpStatus()).body(body);
    }

    // Handle OrderValidationException specifically
    @ExceptionHandler(OrderValidationException.class)
    public ResponseEntity<ErrorResponse> handleOrderValidation(
            OrderValidationException ex, HttpServletRequest request) {

        log.warn("Order validation failed: {}", ex.getViolations());
        ErrorCode code = ex.getErrorCode();

        Map<String, String> fieldErrors = new LinkedHashMap<>();
        for (int i = 0; i < ex.getViolations().size(); i++) {
            fieldErrors.put("violation[" + i + "]", ex.getViolations().get(i));
        }

        ErrorResponse body = ErrorResponse.withFieldErrors(
            code.getHttpStatus(), code.getCode(),
            ex.getMessage(), request.getRequestURI(), fieldErrors);

        return ResponseEntity.status(code.getHttpStatus()).body(body);
    }

    // Handle Spring Validation (Bean Validation @Valid)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        Map<String, String> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
                (a, b) -> a,
                LinkedHashMap::new
            ));

        log.warn("Validation failed for {}: {}", request.getRequestURI(), fieldErrors);

        ErrorResponse body = ErrorResponse.withFieldErrors(
            422, "VAL-001", "Request validation failed",
            request.getRequestURI(), fieldErrors);

        return ResponseEntity.unprocessableEntity().body(body);
    }

    // Catch-all for unexpected exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(
            Exception ex, HttpServletRequest request) {

        log.error("Unhandled exception on {}", request.getRequestURI(), ex);

        ErrorResponse body = ErrorResponse.of(
            500, "GEN-001", "An internal error occurred",
            request.getRequestURI());

        return ResponseEntity.internalServerError().body(body);
    }
}

7. Logging Exceptions: What to Log and How

Poor exception logging is one of the leading causes of slow incident resolution. Log too little and you can't diagnose the problem. Log too much and the signal is buried in noise.

Core Logging Rules

// ❌ WRONG — message only, no stack trace
log.error("Payment failed: " + e.getMessage());

// ❌ WRONG — toString() loses the stack trace
log.error("Payment failed: " + e);

// ✅ CORRECT — SLF4J logs full exception including stack trace
log.error("Payment failed for orderId={}", orderId, e);

// Log at the boundary where you HANDLE, not everywhere it propagates
// ❌ Double-logging anti-pattern:
// Service layer logs AND controller advice logs the same exception

// ✅ Let it propagate; only the @RestControllerAdvice logs it

// MDC for correlation ID tracing
@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain)
            throws ServletException, IOException {

        String correlationId = Optional
            .ofNullable(req.getHeader("X-Correlation-Id"))
            .orElse(UUID.randomUUID().toString());

        MDC.put("correlationId", correlationId);
        res.addHeader("X-Correlation-Id", correlationId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();  // always clean up MDC in finally!
        }
    }
}

// logback configuration to include correlationId in every log line
// %d{ISO8601} [%thread] [%X{correlationId}] %-5level %logger{36} - %msg%n

// What NOT to log
public void processPayment(PaymentRequest request) {
    // ❌ NEVER log PII or sensitive data
    // log.info("Processing payment for card: {}", request.getCardNumber());
    // log.debug("User SSN: {}", user.getSsn());

    // ✅ Log only safe identifiers
    log.info("Processing payment for orderId={}, userId={}",
             request.getOrderId(), request.getUserId());
}

Log Level Guidelines

8. Exception Anti-patterns That Cause Production Incidents

These are the patterns that silently corrupt data, mask real problems, and send engineers on multi-hour debugging adventures at 2 AM.

Anti-pattern 1: Swallowing Exceptions

// ❌ NEVER do this — exception is silently discarded
try {
    cache.invalidate(key);
} catch (Exception e) {
    // TODO: handle later (it never gets handled)
}

// ✅ At minimum, log and handle gracefully
try {
    cache.invalidate(key);
} catch (CacheException e) {
    log.warn("Cache invalidation failed for key={}, continuing without cache", key, e);
    // proceed — cache miss is acceptable, but we need visibility

Anti-pattern 2: Catching Throwable or Error

// ❌ Catching Throwable masks OutOfMemoryError, StackOverflowError, etc.
try {
    processRequest();
} catch (Throwable t) {
    log.error("Something failed", t);
}

// ✅ Catch specific exceptions; let Errors propagate to the JVM
try {
    processRequest();
} catch (BusinessException e) {
    handleBusinessError(e);
} catch (DataAccessException e) {
    handleDatabaseError(e);
} catch (Exception e) {
    log.error("Unexpected exception processing request", e);
    throw new InternalServerException("Request processing failed", e);
}

Anti-pattern 3: Exceptions for Flow Control

// ❌ Using exceptions for normal flow — creating stack traces is expensive!
public boolean userExists(String email) {
    try {
        userRepository.findByEmail(email);
        return true;
    } catch (UserNotFoundException e) {
        return false;  // exception as boolean — terrible pattern
    }
}

// ✅ Use Optional or explicit null/boolean check
public boolean userExists(String email) {
    return userRepository.existsByEmail(email);
}

// Or return Optional from repository
public Optional<User> findByEmail(String email) {
    // ...
}

Anti-pattern 4: Rethrowing Without Cause

// ❌ Stack trace from SQLException is permanently lost
try {
    db.execute(query);
} catch (SQLException e) {
    throw new ServiceException("DB error");  // cause argument missing!
}

// ✅ Always chain the original exception
try {
    db.execute(query);
} catch (SQLException e) {
    throw new ServiceException("DB error executing: " + query, e);
}

Anti-pattern 5: Overly Broad Catch Block

// ❌ Catching Exception when you only expect IOException — masks bugs
try {
    Files.readAllBytes(path);
} catch (Exception e) {
    log.error("File read failed", e);
}

// ✅ Catch only what you expect; let unexpected exceptions propagate
try {
    Files.readAllBytes(path);
} catch (NoSuchFileException e) {
    throw new ConfigNotFoundException("Config file missing: " + path, e);
} catch (IOException e) {
    throw new ConfigLoadException("Cannot read config: " + path, e);
}

Anti-pattern 6: Return from Finally

// ❌ return in finally silently swallows any exception from try/catch block!
public String getValue() {
    try {
        return riskyOperation();
    } catch (Exception e) {
        throw new ServiceException("Failed", e);
    } finally {
        return "default";  // this return DISCARDS the ServiceException — never do this
    }
}

// ✅ Never use return, break, or continue inside a finally block
public String getValue() {
    try {
        return riskyOperation();
    } catch (Exception e) {
        throw new ServiceException("Failed", e);
    } finally {
        cleanup();  // only side-effect operations in finally
    }
}

9. Multi-catch and Pattern Matching for instanceof

Modern Java has progressively improved exception handling syntax, reducing boilerplate while increasing expressiveness.

Multi-catch (Java 7+)

When multiple unrelated exceptions require identical handling, you can combine them in a single catch with the pipe operator. The caught variable is effectively final in a multi-catch block — the compiler prevents reassignment to avoid ambiguity.

// ❌ Duplicated handling — verbose and error-prone
try {
    externalApiCall();
} catch (IOException e) {
    log.error("IO error calling external API", e);
    throw new IntegrationException("External API failed", e);
} catch (TimeoutException e) {
    log.error("IO error calling external API", e);  // copied incorrectly!
    throw new IntegrationException("External API failed", e);
}

// ✅ Multi-catch — DRY, effectively final variable
try {
    externalApiCall();
} catch (IOException | TimeoutException e) {
    // 'e' is effectively final here — cannot reassign
    log.error("External API call failed", e);
    throw new IntegrationException("External API failed", e);
}

// Multi-catch with different types at different levels
try {
    processOrder(orderId);
} catch (UserNotFoundException | OrderNotFoundException e) {
    return ResponseEntity.notFound().build();
} catch (PaymentDeclinedException e) {
    return ResponseEntity.status(402).body(e.getMessage());
} catch (AppException e) {
    return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(e.getMessage());
}

Pattern Matching for instanceof (Java 16+)

Java 16 standardized pattern matching for instanceof, eliminating the cast boilerplate when inspecting exception types. The pattern variable is scoped only to the branch where the pattern matches.

// ❌ Old instanceof + manual cast
if (ex instanceof UserNotFoundException) {
    UserNotFoundException une = (UserNotFoundException) ex;
    log.warn("User not found, userId={}", une.getContext().get("userId"));
}

// ✅ Pattern matching for instanceof (Java 16+)
if (ex instanceof UserNotFoundException une) {
    log.warn("User not found, userId={}", une.getContext().get("userId"));
}

// Pattern matching in exception handlers
} catch (AppException ex) {
    if (ex instanceof OrderValidationException ove) {
        return buildValidationErrorResponse(ove.getViolations());
    } else if (ex instanceof PaymentDeclinedException pde) {
        return buildPaymentErrorResponse(pde);
    }
    return buildGenericErrorResponse(ex);
}

// Java 21: Pattern matching in switch (preview → standard in Java 21)
static String describeException(Throwable t) {
    return switch (t) {
        case UserNotFoundException e  -> "User missing: " + e.getContext().get("userId");
        case PaymentDeclinedException e -> "Payment declined: " + e.getContext().get("reason");
        case AppException e -> "App error [" + e.getErrorCode().getCode() + "]: " + e.getMessage();
        case SQLException e  -> "Database error, SQLState=" + e.getSQLState();
        default              -> "Unknown error: " + t.getMessage();
    };
}

// Rethrowing in multi-catch with generic type inference
public <T extends Exception> void rethrowIfPresent(Exception e, Class<T> type) throws T {
    if (type.isInstance(e)) {
        throw type.cast(e);
    }
}

10. Conclusion and Exception Handling Checklist

Java exception handling is not just a language mechanic — it is a core part of your system's reliability contract with its users. The patterns covered in this guide are the difference between a system that fails gracefully and communicates clearly, and one that silently loses data while producing cryptic error messages at 3 AM.

The key insight across all sections: exceptions are part of your API. Design them with the same care you give to your domain model. Name them clearly, carry meaningful context, preserve the causal chain, and handle them at the right layer.

✓ Exception Handling Checklist

#Java #ExceptionHandling #SpringBoot #CoreJava #JavaBestPractices #CleanCode #TryWithResources

Leave a Comment

Related Posts

Core Java

Java Optional Best Practices

10 min read
Core Java

Clean Code in Java and Spring: Principles for Maintainable Backends

14 min read
Core Java

Java Interview Questions 2026: Senior Engineer Edition

20 min read
Core Java

SOLID Principles in Java with Spring Boot Examples

16 min read
Md Sanwar Hossain
Md Sanwar Hossain

Senior Software Engineer specializing in Java, Spring Boot, Kubernetes, and AWS. Passionate about clean architecture, production reliability, and sharing practical engineering knowledge through writing.

Back to Blog

Last updated: April 8, 2026