[Draft] Best practices for handling exceptions in Java - vinhtbkit/bkit-kb GitHub Wiki

Choosing between checked exceptions and unchecked exceptions

Choosing between checked and unchecked exceptions depends on the nature of the exception and how you expect the calling code to handle it.

Checked Exceptions:

  • Expected and Recoverable Situations: when the exceptional situation is expected and recoverable.

    • E.g: if your method performs file I/O, it might throw a checked IOException. The calling code can catch this exception and take appropriate action, such as displaying an error message or asking the user to provide a valid file name.
  • Forcing Handling: when you want to force the calling code to handle the exception explicitly.

    • This can make the code more robust by ensuring that the calling code acknowledges and deals with potential issues.
  • Compile-Time Checking: Checked exceptions provide compile-time checking

    • Compiler will enforce handling or declaration of these exceptions.
    • Helps catch potential issues early in the development process.

Example Use-cases:

  1. File I/O Operations:
    Operations involving file I/O, such as reading from or writing to files, often throw checked exceptions like IOException. These exceptions indicate potential issues with file access, and developers are expected to handle or propagate them.
public void readFile(String filePath) throws IOException {
    // Code that reads from the file
}
  1. Network Operations:
    Operations involving network communication, like opening a socket or making an HTTP request, may throw checked exceptions like IOException. Handling these exceptions allows the application to respond appropriately to network-related issues.
public void openSocket(String host, int port) throws IOException {
    // Code that opens a socket
}
  1. Database Operations:
    Database interactions often involve potential issues such as connection failures or SQL errors. Methods that perform database operations may declare checked exceptions, such as SQLException, to indicate these possibilities.
public void executeQuery(String sql) throws SQLException {
    // Code that executes a database query
}
  1. External Services:
    When interacting with external services or APIs, checked exceptions can be used to signal potential problems, such as invalid responses or connection issues.
public void callExternalService() throws ServiceException {
    // Code that interacts with an external service
}
  1. Parsing and Conversion:
    Methods that involve parsing or converting data, such as parsing strings to numbers or dates, may throw checked exceptions like ParseException or NumberFormatException. These are often checked exceptions since they indicate a problem that the application should handle.
public Date parseDate(String dateString) throws ParseException {
    // Code that parses a date from a string
}
  1. Custom Business Logic Exceptions:
    In addition to standard Java exceptions, you may create your own custom-checked exceptions to represent specific business logic errors. This allows you to provide more meaningful information about exceptional situations in your application.
public class CustomValidationException extends Exception {
    public CustomValidationException(String message) {
        super(message);
    }
}

Unchecked Exceptions:

  • Runtime Errors: for errors that are typically not recoverable at runtime, such as NullPointerException, ArrayIndexOutOfBoundsException, etc.
    • Unchecked exceptions are often used for programming errors or situations that should NOT occur during normal execution.
  • Convenience: can be more convenient in situations where it might be cumbersome to declare or catch checked exceptions at every level of the call stack.

Examples:

  1. Programming Errors:
    Unchecked exceptions are often used to signal programming errors that should be fixed during development, such as null pointer dereferences (NullPointerException), array index out of bounds (ArrayIndexOutOfBoundsException), or using an incorrect method argument (IllegalArgumentException).
    These exceptions indicate bugs in the code that should be addressed during development.
    For example, a NullPointerException might be thrown if an attempt is made to access an object that is null, indicating a bug in the code.
public class Example {
    public void performOperation(String value) {
        if (value == null) {
            throw new NullPointerException("Value cannot be null.");
        }
        // rest of the code
    }
}

If a method receives an illegal or invalid argument value, you might choose to throw an unchecked exception like IllegalArgumentException to indicate that the provided argument is not acceptable.

public class Calculator {
    public int divide(int dividend, int divisor) {
        if (divisor == 0) {
            throw new IllegalArgumentException("Divisor cannot be zero.");
        }
        return dividend / divisor;
    }
}
  1. Unexpected Conditions:
    Use unchecked exceptions when encountering unexpected and unrecoverable conditions during runtime. For instance, an ArithmeticException may be thrown when dividing by zero, signaling a situation that the developer might not anticipate or handle.
public class Calculator {
    public int divide(int dividend, int divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("Divisor cannot be zero.");
        }
        return dividend / divisor;
    }
}
  1. Convenience in Error Handling:
    Unchecked exceptions can be more convenient when the calling code is unlikely to recover from the error and explicit handling is not necessary at every level of the call stack.
  2. Simplifying Code:
    Unchecked exceptions can simplify code by avoiding the need to declare or catch exceptions at every level of the call hierarchy for situations that are unlikely to occur. This can make the code more readable and less cluttered with exception handling code.

When do re-throw exceptions? When do handling exceptions?

The decision to re-throw or handle an exception in Java depends on the specific context and your application's error-handling strategy. Here are some guidelines to help you decide whether to re-throw or handle an exception:

Re-Throwing Exceptions:
Re-throwing an exception is suitable when you want to propagate information or centralize handling at higher levels.

  1. Propagation of Information:
    Re-throw an exception when you want to propagate information about the exception to higher levels of the call stack.
    This allows the upper layers of the code to have more context about the nature of the problem.
public void processFile(String fileName) throws IOException {
    try {
        // code that may throw IOException
    } catch (IOException e) {
        // log the exception or perform some specific actions
        // re-throw the exception to propagate it to the caller
        throw e;
    }
}
  1. Enhancing Information:
  • Re-throw an exception if you want to enhance or provide additional information about the exception before propagating it.
  • This can be useful for adding more context or details to the exception message.
  • Another use-case is to convert an exception into a new exception
  • Re-throwing an exception SHOULD include the original exception, so it can be retrieved with getCause()
public void performOperation() throws CustomException {
    try {
        // code that may throw CustomException
    } catch (CustomException e) {
        // log the exception or perform some specific actions
        // enhance the exception message before re-throwing
        throw new CustomException("Error during operation: " + e.getMessage(), e);
    }
}
  1. Centralized Handling:
    Re-throw an exception if you want to centralize exception handling at a higher level in your application.
    This allows you to have a centralized point for logging, auditing, or taking specific actions in response to exceptions.

Handling Exceptions:
Handling an exception is appropriate when you can recover, provide a user-friendly response, or perform cleanup actions at the current level.

  1. Recovery and Graceful Degradation:
    Handle an exception when you can recover from an exceptional situation or when you want to implement graceful degradation.
    In some cases, you might take alternative actions or provide default values to keep the application running.
public void performOperation() {
    try {
        // code that may throw CustomException
    } catch (CustomException e) {
        // log the exception or perform recovery actions
        // continue with a fallback operation or provide a default result
        System.out.println("Fallback operation performed.");
    }
}
  1. User-Friendly Error Messages:
    Handle an exception when you want to present a user-friendly error message rather than exposing the raw exception details.
    This is especially important in user interfaces where clear and understandable error messages enhance the user experience.

  2. Partial Recovery or Cleanup:
    Handle an exception when you can partially recover from the exceptional situation or when you need to perform cleanup actions.
    For example, closing resources, releasing locks, or logging information before letting the application proceed.

How to log an exception properly?

Logging exceptions properly is crucial for effective troubleshooting and debugging. Logging helps you understand what went wrong in your application, provides context about the error, and assists in identifying the root cause.

  1. Use a Logging Framework:
  • Choose a logging framework like SLF4J or Log4j.
  • These frameworks provide a consistent and flexible way to handle logging in your application.
  • SLF4J, in particular, is an abstraction over various logging frameworks and is commonly used in Java applications.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Example {
    private static final Logger logger = LoggerFactory.getLogger(Example.class);

    public void performOperation() {
        try {
            // code that may throw an exception
        } catch (Exception e) {
            // Log the exception with appropriate logging level
            logger.error("An error occurred during the operation.", e);
        }
    }
}
  1. Log Contextual Information:
  • Include contextual information that helps in understanding the state of the application when the exception occurred.
  • This might include information about the current user, session, or any other relevant context.
public class Example {
    private static final Logger logger = LoggerFactory.getLogger(Example.class);

    public void processRequest(String username, String requestData) {
        try {
            // code that may throw an exception
        } catch (Exception e) {
            // Log the exception with additional contextual information
            logger.error("Error processing request for user '{}'. Request data: '{}'", username, requestData, e);
        }
    }
}
  1. Log Business-Specific Information:
  • Include information related to the business logic or domain of your application.
  • Avoid Logging Sensitive Information:
    • Never log sensitive information such as passwords, credit card numbers, or personally identifiable information...
    • Ensure that your log statements do not include sensitive data that could be misused if logs are accessed by unauthorized individuals.
    • If you need to log information that is sensitive but necessary for debugging, consider using masking or redaction to hide part of the information. This ensures that sensitive data is not exposed in clear text in the logs.
public class PaymentProcessor {
    private static final Logger logger = LoggerFactory.getLogger(PaymentProcessor.class);

    public void processPayment(double amount, String cardNumber) {
        try {
            // code that may throw an exception
        } catch (Exception e) {
            // Log the exception with business-specific information
            logger.error("Error processing payment for amount '{}' with card ending in '{}'", amount, getLastFourDigits(cardNumber), e);
        }
    }

    private String getLastFourDigits(String cardNumber) {
        // Extract the last four digits of the card number
        return cardNumber.substring(cardNumber.length() - 4);
    }
}
  1. Use Log Levels Appropriately:
  • Choose the appropriate log level based on the severity of the exception.
  • Common logging levels include error, warn, info, debug, and trace.
  • Notice: the exceptions are NOT necessarily logged with error or warn level. These two levels often indicate that there are some issues with your system, and you absolutely should not log with error or warn when you dealing with errors from user inputs, or some external third parties not working.
  1. Include Exception Stack Trace:
  • Include the full stack trace of the exception in the log.
  • This provides detailed information about the exception, including the line numbers in your code where the exception occurred.
public class Example {
    private static final Logger logger = LoggerFactory.getLogger(Example.class);

    public void performOperation() {
        try {
            // code that may throw an exception
        } catch (Exception e) {
            // Log the exception with its stack trace
            logger.error("An error occurred during the operation.", e);
        }
    }
}
  1. Centralized Logging Configuration:
  • Ensure that your logging framework is configured appropriately.
  • This may involve specifying log levels, log file locations, and other settings.
  • Centralized configuration makes it easier to manage logging consistently across your application.

References: