8 minute read

1. Overview

Recently, while working on a software development project, I had the opportunity to think deeply about Exception Handling.

While integrating a third-party library, I encountered conflicts between the library’s guidelines and our application’s exception handling approach. This led me to reconsider exception handling practices, and I decided to revisit the concepts from Effective Java 3rd Edition - a true bible for Java developers.

As you can see from the table of contents, Exception Handling deserves an entire chapter rather than just a single item, highlighting its critical importance.

Let me explore Effective Java’s exception handling principles and reflect on how to solve the real-world problems I’ve encountered.

2. Exception Handling Principles

The opening statement sets the tone perfectly:

“Used properly, exceptions can improve a program’s readability, reliability, and maintainability. Used improperly, they can have the opposite effect.”

I completely agree with this statement, and I believe developers should always consider whether improper usage might occur.

2.1. Use Exceptions Only for Exceptional Conditions

Exceptions should be used only for truly exceptional situations that disrupt the normal flow of a program.

Using exceptions for situations that can be handled with simple conditional statements leads to performance degradation, reduced readability, and debugging difficulties.

Why shouldn’t exceptions be overused?

  1. Performance Issues
    • Throwing exceptions is relatively expensive
    • Frequent exceptions in loops can significantly impact performance
  2. Reduced Code Readability
    • Too much exception handling code makes normal logic hard to read
    • Overused exceptions = “control flow via exceptions” → not intuitive
  3. Debugging Difficulties
    • Unnecessary exceptions create longer stack traces, making actual problem identification harder

Proper Usage Examples

  • Situations for exception handling:
    • Unpredictable errors: network disconnection, missing files, DB connection failures
    • External system dependency failures
    • Programming contract violations (IllegalArgumentException within reasonable bounds)
  • Situations to avoid exception handling:
    • Simple conditional checks are sufficient
    • Normal situations that frequently occur in loops
// Wrong: Using exceptions for existence checks in loops
for (String s : list) {
    try {
        process(s);
    } catch (NoSuchElementException e) {
        // Simply because the list is empty
        // Conditional handling is much more efficient
    }
}

// Correct:
for (String s : list) {
    if (s != null) {
        process(s);
    }
}

// File reading where file doesn't exist -> exception appropriate
try {
    readFile("nonexistent.txt");
} catch (IOException e) {
    System.out.println("Error reading file: " + e.getMessage());
}

2.2. Use Checked Exceptions for Recoverable Conditions and Runtime Exceptions for Programming Errors

Java provides three types of throwables: checked exceptions, runtime exceptions, and errors. Here’s guidance on when to use each:

Recoverable conditionsChecked exceptions Programming errorsRuntime exceptions

  • Checked Exceptions
    • Force callers to handle exceptions
    • Use for situations where the program can recover
  • Unchecked Exceptions
    • Subclasses of RuntimeException
    • Callers don’t need to handle them
    • Represent programming errors that should be fixed through code changes
flowchart TD A["Should throw an exception?"] --> B{"Normal flow?"} B -->|Yes| C["Use conditionals
if / for etc."] B -->|No| D["Use exceptions"] D --> E{"Exception type selection"} E -->|Recoverable situation| F["Checked Exception
try-catch or throws required
Example: File not found, Network error"] E -->|Programming error| G["Runtime Exception
Code fix required
Example: IndexOutOfBounds, NullPointer"]

2.3. Avoid Unnecessary Checked Exceptions

Checked exceptions force callers to handle exceptions. However, if it’s not truly a recoverable situation, using checked exceptions hurts API usability and makes code messy.

Why is this problematic?

  1. Messy caller code
    try {     
     obj.action(); 
    } catch (SomeCheckedException e) {     
     // Actually no recovery method available     
     throw new RuntimeException(e); 
    }
    

    → Callers inevitably end up with unnecessary “catch and rethrow” code.

  2. Reduced API usability
    • Developers always have to write try-catch blocks
    • APIs become unnecessarily complex

Correct Design Principles

  • Use runtime exceptions (Unchecked Exception) instead of checked exceptions if no recovery is possible
  • Use checked exceptions only when clients must respond
  • Optional / null returns might be better in some cases

Examples

  • Wrong approach
    // Caller cannot actually recover
    public void connect() throws IOException {     
      // IOException thrown on connection failure
    }
    
try {
    service.connect();
} catch (IOException e) {
    // Cannot recover but must catch and rethrow or just log
}
  • Improved approach
    // Not recoverable → change to runtime exception
    public void connect() {
      if (/* failure */) {
          throw new IllegalStateException("Cannot connect to server");
      }
    }
    
// No recovery needed → provide state check method
if (service.canConnect()) {
    service.connect();
}

2.4. Favor the Use of Standard Exceptions

Java provides well-defined standard exception classes. Rather than defining new exception classes, using appropriate standard exceptions is advantageous for consistency, readability, and maintainability.

Standard exceptions should be the first choice, and creating new exceptions should be a last resort.

Why use standard exceptions?

  1. Consistency
    • All Java developers can easily understand the meaning
    • Names like NullPointerException, IllegalArgumentException are self-explanatory
  2. Avoiding unnecessary duplication
    • No need to create custom exceptions with the same functionality as existing ones
  3. API simplification
    • Prevents unnecessary proliferation of exception classes → easier maintenance

Commonly Used Standard Exceptions

Exception Usage
IllegalArgumentException When arguments are invalid
IllegalStateException When object state is inappropriate for method call
NullPointerException When null arguments are not allowed
IndexOutOfBoundsException When index is out of range
ConcurrentModificationException When concurrent modification is prohibited
UnsupportedOperationException When called method is not supported

Examples

  • Using standard exceptions
    public void setAge(int age) {
      if (age < 0) {
          throw new IllegalArgumentException("Age cannot be negative: " + age);
      }
      this.age = age;
    }
    
  • Unnecessary custom exception
    // Actually IllegalArgumentException is sufficient
    public class InvalidAgeException extends RuntimeException {
      public InvalidAgeException(String message) {
          super(message);
      }
    }
    

2.5. Throw Exceptions Appropriate to the Abstraction

Exceptions thrown by a method should match the abstraction level of that method.

Lower-level implementation details should not leak through exceptions - they should be translated to exceptions appropriate for the higher abstraction level.

Why is this important?

  1. Maintaining Encapsulation
    • External APIs shouldn’t change when internal implementation technology changes
    • Exposing internal exceptions leaks implementation details
  2. Consistent API
    • Callers only need to think at the method’s abstraction level
    • “What situations can cause this method to fail?” is all they need to understand
  3. Maintenance ease
    • If internal technology changes (e.g., DB → file storage), client code shouldn’t need updates if API exceptions change

Wrong Example (Exposing Implementation Exceptions)

// Internal library code
public List<String> readNames() throws SQLException {
    // DB access logic
}
  • Problem: Clients depend on SQLException → API changes needed when DB is replaced

Correct Example (Matching Abstraction Level)

// Abstracted API
public List<String> readNames() throws DataAccessException {
    try {
        // DB access
    } catch (SQLException e) {
        throw new DataAccessException("Database read failed", e);
    }
}
  • Clients only need to understand “data cannot be read” in abstract terms
  • Internal implementation (DB vs file) can change without affecting the API

Exception Translation

  • Convert lower-level exceptions → higher-level abstraction exceptions
  • Methods:
    • Exception Translation: Wrap lower exceptions in higher-level exceptions
    • Exception Chaining: Pass lower exception as cause (new MyException("msg", cause))
try {
    // DB access
} catch (SQLException e) {
    throw new DataAccessException("Data access failed", e); // include cause
}

3. Conclusion: Applying Lessons to Real-World Problems

After studying the theory, let me share how I resolved the library integration problem mentioned in the overview.

3.1. Problem Situation: Two Conflicting Exception Handling Approaches

The third-party library we were integrating validates requests before they reach application controllers. The library guidelines were:

  • The checkPermission() method throws a single AuthorizationException on failure
  • This exception contains an ErrorCode indicating the failure reason (TOKEN_EXPIRED, INSUFFICIENT_PERMISSIONS)
  • The GlobalExceptionHandler should catch this AuthorizationException, check the internal ErrorCode, and use if/else branching to return different HTTP status codes (401, 403, etc.)

[Library-recommended approach]

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<ErrorResponse> handleAuthorizationException(AuthorizationException e) {
        // 😫 Logic to analyze the cause inside ExceptionHandler
        if (e.getErrorCode() == ErrorCode.TOKEN_EXPIRED) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(new ErrorResponse("Token has expired."));
        } else if (e.getErrorCode() == ErrorCode.INSUFFICIENT_PERMISSIONS) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(new ErrorResponse("Access denied."));
        }
        // ... various other error code branches
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("Unknown authentication error."));
    }
}

However, this conflicted with our project’s exception handling principles:

  • ExceptionHandler should have simple responsibility - only converting exception types to appropriate HTTP responses
  • Throw clear custom exceptions matching the root cause (e.g., ProductNotFoundException, InvalidOrderException)

3.2. Finding Solutions with Effective Java Principles

The Effective Java principles I just summarized provided clear direction:

  • Item 73: Throw exceptions appropriate to the abstraction
    • The interceptor’s role is the abstract concept of ‘authentication/authorization’. Exposing AuthorizationException with library implementation details is inappropriate. TOKEN_EXPIRED should be translated to UnauthorizedException, and INSUFFICIENT_PERMISSIONS to ForbiddenException at a higher abstraction level.
  • Item 75: Exception Translation
    • Instead of exposing lower-level exceptions directly, I decided to apply ‘exception translation’ by wrapping them in appropriate higher-level exceptions. The Interceptor would act as an ‘adapter’, catching the library’s AuthorizationException and converting it to project-compliant exceptions.

3.3. Final Solution: Maintaining Architectural Consistency Through Exception Translation

1. Define Project-Appropriate Custom Exceptions

First, I defined exceptions appropriate for our project’s abstraction level:

// 401 Unauthorized
public class UnauthorizedException extends RuntimeException {
    public UnauthorizedException(String message) { super(message); }
}

// 403 Forbidden
public class ForbiddenException extends RuntimeException {
    public ForbiddenException(String message) { super(message); }
}

2. Apply Exception Translation in Interceptor

Then, I modified the AuthInterceptor to catch the library’s exceptions and translate them to appropriate custom exceptions:

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final AuthenticationService authService; // External library service

    // ... constructor omitted ...

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        try {
            String token = request.getHeader("Authorization");
            authService.checkPermission(token); // This method throws AuthorizationException
        } catch (AuthorizationException e) {
            // ✨ Exception Translation happens here
            if (e.getErrorCode() == ErrorCode.TOKEN_EXPIRED) {
                throw new UnauthorizedException("Authentication token is invalid.");
            } else if (e.getErrorCode() == ErrorCode.INSUFFICIENT_PERMISSIONS) {
                throw new ForbiddenException("You don't have permission to access this resource.");
            }
        }
        return true;
    }
}

3. Simplified ExceptionHandler

As a result, the GlobalExceptionHandler returned to its clean original role of ‘simple conversion based on exception type’:

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 👍 Handler with clear roles and responsibilities
    @ExceptionHandler(UnauthorizedException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public ErrorResponse handleUnauthorized(UnauthorizedException e) {
        log.info(e.getMessage());
        return new ErrorResponse(e.getMessage());
    }

    @ExceptionHandler(ForbiddenException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleForbidden(ForbiddenException e) {
        log.warn(e.getMessage());
        return new ErrorResponse(e.getMessage());
    }
    
    // ... other handlers ...
}

4. Final Thoughts

While library and framework guidelines are important, when they conflict with our application’s overall design principles and consistency, it may be better to integrate them non-intrusively by adding an ‘adapter’ layer as shown above.

Ultimately, good exception handling goes beyond simply catching errors - it’s an important design activity that enhances code readability, maintainability, and overall system stability.


This article is based on real work experiences, with specific system names and configuration values generalized for security purposes.

References

  • Bloch, J. (2018). Effective Java (3rd Edition). Addison-Wesley Professional.
  • Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.
  • Fowler, M. (2018). Refactoring: Improving the Design of Existing Code (2nd Edition). Addison-Wesley Professional.
  • Oracle. (2021). The Java™ Tutorials - Exception Handling. Oracle Documentation.
  • Spring Framework Documentation. (2023). Exception Handling in Spring MVC. VMware, Inc.

Leave a comment