Exception Handling Best Practices: Lessons from Effective Java
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?
- Performance Issues
- Throwing exceptions is relatively expensive
- Frequent exceptions in loops can significantly impact performance
- Reduced Code Readability
- Too much exception handling code makes normal logic hard to read
- Overused exceptions = “control flow via exceptions” → not intuitive
- 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 conditions → Checked exceptions Programming errors → Runtime 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
- Subclasses of
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?
- 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.
- 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?
- Consistency
- All Java developers can easily understand the meaning
- Names like
NullPointerException,IllegalArgumentExceptionare self-explanatory
- Avoiding unnecessary duplication
- No need to create custom exceptions with the same functionality as existing ones
- 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?
- Maintaining Encapsulation
- External APIs shouldn’t change when internal implementation technology changes
- Exposing internal exceptions leaks implementation details
- 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
- 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 singleAuthorizationExceptionon failure - This exception contains an
ErrorCodeindicating the failure reason (TOKEN_EXPIRED,INSUFFICIENT_PERMISSIONS) - The
GlobalExceptionHandlershould catch thisAuthorizationException, check the internalErrorCode, and useif/elsebranching 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:
ExceptionHandlershould 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
AuthorizationExceptionwith library implementation details is inappropriate.TOKEN_EXPIREDshould be translated toUnauthorizedException, andINSUFFICIENT_PERMISSIONStoForbiddenExceptionat a higher abstraction level.
- The interceptor’s role is the abstract concept of ‘authentication/authorization’. Exposing
- 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
Interceptorwould act as an ‘adapter’, catching the library’sAuthorizationExceptionand converting it to project-compliant exceptions.
- Instead of exposing lower-level exceptions directly, I decided to apply ‘exception translation’ by wrapping them in appropriate higher-level exceptions. The
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