Java Exception Handling Best Practices

In this guide, I would like to explain Java Exception Handling Best Practices. We can follow these best practices in the day-to-day project work. This post belongs to the Java Best Practices Series category.

Let's discuss each of the Exception Handling Best Practices with examples.

1. Clean up Resources in a Finally Block or Use a Try-With-Resource Statement

Ensure that resources (like streams or database connections) are closed in the finally block or utilize the try-with-resources statement for auto-closable resources.

For Example:

public void doNotCloseResourceInTry() {
    FileInputStream inputStream = null;
     try {
          File file = new File("./tmp.txt");
          inputStream = new FileInputStream(file);

          // use the inputStream to read a file

          // do NOT do this
          inputStream.close();
        } catch (FileNotFoundException e) {
            LOGGER.error(e.getMessage());
        } catch (IOException e) {
            LOGGER.error(e.getMessage());
        }
 }
The problem is that this approach seems to work perfectly fine as long as no exception gets thrown. All statements within the try block will get executed, and the resource gets closed. The problem is when an exception occurs within a try block and you might not reach the end of the try block. And as a result, you will not close the resources. You should, therefore, put all your clean-up code into the finally block or use a try-with-resources statement.
Let's write some simple examples to demonstrate this:

Use finally block

public void closeResourceInFinally() {
    FileInputStream inputStream = null;
    try {
        File file = new File("./tmp.txt");
        inputStream = new FileInputStream(file);

        // use the inputStream to read a file

       } catch (FileNotFoundException e) {
           LOGGER.error(e.getMessage());
       } finally {
            if (inputStream != null) {
                try {
                   inputStream.close();
                } catch (IOException e) {
                    LOGGER.error(e.getMessage());
                }
            }
       }
}

Using Try-With-Resource Statement

Syntax:
try(// open resources here){
    // use resources
} catch (FileNotFoundException e) {
 // exception handling
}
// resources are closed as soon as try-catch block is executed.
Example:
public void automaticallyCloseResource() {
 
     // Example 1
        File file = new File("./tmp.txt");
        try (FileInputStream inputStream = new FileInputStream(file);) {
        // use the inputStream to read a file
  
        } catch (FileNotFoundException e) {
             LOGGER.error(e.getMessage());
        } catch (IOException e) {
            LOGGER.error(e.getMessage());
        }
 
        // Example 2
       try (BufferedReader br = new BufferedReader(new FileReader(
                                                  "C:\\ramesh.txt"))) {
                System.out.println(br.readLine());
       } catch (IOException e) {
               e.printStackTrace();
       }
}
The try with resources benefits:
  • More readable code and easy to write.
  • Automatic resource management.
  • The number of lines of code is reduced.
  • No need to finally block just to close the resources.
  • We can open multiple resources in a try-with-resources statement separated by a semicolon. 

2. Throw Specific Exception

Always prefer to throw a specific exception and don't through a generic exception. 
Instead of:
throw new Exception("File not found");
Use:
throw new FileNotFoundException("Config file not found");
In the throws clause also use specific exceptions.
Instead of:
public void doNotDoThis() throws Exception { ... }
Use:
public void doThis() throws NumberFormatException { ... }

3. Do Not Catch the Exception or Throwable Class Rather Catch Specific Subclasses

Instead Of:
try {
    // some code
} catch (Exception e) {
    // handle exception
}
Use:
try {
    // some code
} catch (FileNotFoundException | SQLException e) {
    // handle specific exception
}

4. Don't Swallow Exceptions

Never leave an empty catch block. At the very least, log the exception so that you're aware it occurred and can troubleshoot if necessary.

Bad practice:
try {
    // some code
} catch (SomeException e) {
    // Empty catch block
}
Better:
try {
    // some code
} catch (SomeException e) {
    log.error("Exception occurred:", e);
}
One more example:
// avoid
public void doNotIgnoreExceptions() {
     try {
         // do something
     } catch (NumberFormatException e) {
         // this will never happen
     }
}
Log the exception:
public void logAnException() {
    try {
         // do something
    } catch (NumberFormatException e) {
         log.error("This should never happen: " + e);
    }
}

5. Don’t Use printStackTrace() Statement or Similar Methods

Never leave printStackTrace() after finishing your code. Chances are one of your fellow colleagues will get one of those stack traces eventually and have exactly zero knowledge as to what to do with it because it will not have any contextual information appended to it.
For example:
Avoid using printStackTrace() method call:
} catch (IOException e) {
      e.printStackTrace();
}
Instead, you can log the error where required:
    } catch (NumberFormatException e) {
         log.error("This should never happen: " + e);
    }

6. Always Document Exceptions with JavaDoc

If your method throws an exception (checked or unchecked), always document it using the @throws or @exception tag in its JavaDoc. 

For example:
/**
 * @throws FileNotFoundException if the file does not exist
 */
public void readFile(String path) throws FileNotFoundException {
    // method implementation
}

7. Avoid Throwing Raw Exception Types

Avoid throwing raw exception types like RuntimeException, Exception, or Throwable. It’s always best to throw specific exception types, whether they're from the Java Standard Library or custom ones you've defined. 

Instead Of:
throw new RuntimeException("Database error");
Use or create a specific exception:
throw new DatabaseConnectionException("Unable to connect to database");

8. Use Checked Exceptions for Recoverable Errors

Checked exceptions (those that extend Exception but not RuntimeException) should be used for conditions from which the caller can reasonably be expected to recover. 
public void transferMoney(Account from, Account to, double amount) throws InsufficientFundsException {
    if(from.getBalance() < amount) {
        throw new InsufficientFundsException("Insufficient funds");
    }
    // Continue the transfer
}

9. Use Runtime Exceptions for Programming Errors

Unchecked exceptions (those that extend RuntimeException) should indicate programming errors, which typically cannot be handled at runtime. 
public void setName(String name) {
    if(name == null) {
        throw new IllegalArgumentException("Name cannot be null");
    }
    this.name = name;
}

10. Provide Context with Exceptions

Providing context with exceptions is crucial for debugging and understanding the reason behind an exception. It not only tells you that an error occurred but also provides an indication of why the error happened.

Without Context: Imagine a scenario where you're trying to connect to a database:
public void connectToDB(String url, String user, String pass) {
    if (url == null || user == null || pass == null) {
        throw new IllegalArgumentException();
    }
    // Logic to connect to the database
}
If the above method throws an IllegalArgumentException, you'll know something was null, but you won't know which parameter was null. 

With Context: 
public void connectToDB(String url, String user, String pass) {
    if (url == null) {
        throw new IllegalArgumentException("URL cannot be null");
    }
    if (user == null) {
        throw new IllegalArgumentException("User cannot be null");
    }
    if (pass == null) {
        throw new IllegalArgumentException("Password cannot be null");
    }
    // Logic to connect to the database
}
In the second example, if an IllegalArgumentException is thrown, you'll know exactly which parameter was null, which makes debugging and resolution significantly faster and more straightforward.

11. Chain Exceptions

If you catch an exception only to throw another, consider including the original exception as the cause to preserve the original stack trace. 

Example: Suppose you have a method that reads a configuration file. If the file is not found, it throws a FileNotFoundException. But in your application logic, you'd rather throw a custom ConfigurationLoadException to better represent the error at the application level. 

Without chaining:
public void loadConfig(String configPath) {
    try {
        // Logic to read a configuration file
    } catch (FileNotFoundException fileNotFoundException) {
throw new ConfigurationLoadException("Configuration file not found."); } }
In the above code, if ConfigurationLoadException is thrown, you will lose the context and stack trace of the original FileNotFoundException

With chaining:
public void loadConfig(String configPath) {
    try {
        // Logic to read a configuration file
    } catch (FileNotFoundException fileNotFoundException) {
        throw new ConfigurationLoadException("Configuration file not found.", fileNotFoundException);
} }
In the second example, the ConfigurationLoadException will contain the message "Configuration file not found." But it will also contain the original FileNotFoundException as its cause.

12. Avoid Unnecessary Use of Checked Exceptions

Checked exceptions add clutter to code as they have to be declared or caught. If an exception is used internally in a system and is not expected to be recovered from, consider making it unchecked. 
// Instead of:
void internalMethod() throws SomeCheckedException;

// Use:
void internalMethod() {
    // Let runtime exceptions propagate
}

13. Custom Exceptions Should Add Value

If creating a custom exception, ensure it provides additional benefits over existing exceptions, like conveying more specific error details or bundling multiple error data together.
public class UserNotFoundException extends RuntimeException {
    private int userId;
    
    public UserNotFoundException(int userId) {
        super("User with ID " + userId + " not found");
        this.userId = userId;
    }

    public int getUserId() {
        return userId;
    }
}
Remember, these are guidelines, and there might be scenarios where you need to make exceptions based on your specific application requirements. However, adhering to these best practices generally results in more maintainable and resilient code.

Comments