Software Engineer's Blog

How Real-World Java Developers Handle Exceptions: Rethinking Checked vs. Unchecked

How Real-World Java Developers Handle Exceptions:  Rethinking Checked vs. Unchecked

🤔 “According to theory, shouldn’t we use Checked Exceptions?”

If an error occurs in a recoverable situation—meaning the program can continue to run normally without stopping—We are taught that you should use a Checked Exception and handle it explicitly.

To use a Checked Exception is to design the API in a way that forces explicit error handling—either by declaring the exception with throws, catching it with try-catch, or delegating the responsibility to the caller.

However, if you look at actual production code—especially in modern web framework environments like Spring Boot—the reality is quite different.

In most real-world projects, Unchecked Exceptions (RuntimeExceptions) are dominant. Are developers simply ignoring the rules because they don’t know them?

No. There are practical reasons hiding behind this choice, specifically for “Maintainability” and “Clean Code.” Today, we will dig deep into the exception handling patterns actually used by modern Java developers. (If the textbook distinction itself is fuzzy — what actually makes an exception checked vs unchecked — start with the fundamentals.)

1. The Modern Trend: The Rise of Unchecked Exceptions

In Spring Boot environments, senior developers strongly prefer using Unchecked Exceptions in business logic.

“Using Unchecked Exceptions keeps service method signatures clean and makes global exception handling much more elegant.”

The specific reasons why this approach is preferred are:

  • Prevention of unnecessary throws propagation: It stops meaningless throws Exception declarations from cluttering the code as you move up the call stack.
  • Freedom of movement between layers: Exceptions occurring in the Service layer can propagate to the Controller or Filter without obstruction.
  • Perfect synergy with Spring: @ControllerAdvice automatically catches Unchecked Exceptions and converts them into clean API responses.
  • Readability: It prevents core business logic from being buried under try-catch blocks.

So, how exactly do we distinguish between these two types of exceptions in practice?

2. Practical Guide: Exception Strategy

Case 1) Checked Exception

External I/O Operations (File/DB/Network) → Wrap and Throw

File I/O or database access are classic scenarios where Checked Exceptions occur. The Java compiler forces you to handle these. However, if you expose these exceptions in your service method signatures, every component calling that service is forced to handle the exception, leading to tight coupling and boilerplate code.

The technique used here is “Wrapping in an Unchecked Exception and Throwing.”

❌ Before: Using Raw Checked Exceptions

public class FileReaderService {
    // Passes IOException up to the caller (Enforced handling)
    public String readFile(String path) throws IOException {
        return Files.readString(Path.of(path));
    }
}

✅ After: Wrapping with RuntimeException

@Service
@RequiredArgsConstructor
public class DocumentService {

    private final FileReaderService fileReaderService;

    public String loadDocument(String path) {
        try {
            return fileReaderService.readFile(path);
        } catch (IOException e) {
            // Catch the Checked Exception and convert it to a custom Unchecked Exception
            // Key Point: Pass the original exception (e) as the 'cause' to preserve debugging info!
            throw new FileStorageException("Failed to read file: " + path, e);
        }
    }
}

Point: Failures in external systems are often impossible for developers to recover from at the code level. Therefore, rather than cluttering the code with Checked Exceptions, it is more efficient to wrap them in a runtime exception and send them to a global handler.

Case 2) Unchecked Exception

Business Logic Validation → Use Unchecked Exceptions

When business rules are violated—such as insufficient funds or duplicate sign-ups—it is best to create a custom exception that extends RuntimeException from the start.

Defining the Custom Exception

public class InsufficientBalanceException extends RuntimeException {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

Service Layer

@Service
public class PaymentService {

    public void processPayment(int balance, int amount) {
        if (balance < amount) {
            // Thrown immediately without a 'throws' declaration
            throw new InsufficientBalanceException(
                "Insufficient balance. Current: " + balance + ", Request: " + amount
            );
        }
        // Proceed with business logic...
    }
}

Controller Layer

@RestController
@RequiredArgsConstructor
public class PaymentController {

    private final PaymentService paymentService;

    @PostMapping("/pay")
    public String pay(@RequestParam int balance, @RequestParam int amount) {
        // Clean call without try-catch blocks
        paymentService.processPayment(balance, amount);
        return "Payment Successful!";
    }
}

Point: The Controller is liberated from exception handling logic. This allows the code to focus purely on the “Success Flow.”

3. What Makes This Possible: @ControllerAdvice

For the Unchecked Exception strategy to shine, someone must be responsible for catching and handling these exceptions at the end. In Spring Boot, @RestControllerAdvice performs this role perfectly.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle Business Logic Exceptions (400 Bad Request)
    @ExceptionHandler(InsufficientBalanceException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleInsufficientBalance(InsufficientBalanceException e) {
        return new ErrorResponse("PAYMENT_ERROR", e.getMessage());
    }

    // Handle System/IO Exceptions (500 Internal Server Error)
    @ExceptionHandler(FileStorageException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleFileErrors(FileStorageException e) {
        // For security, log the detailed path but send a generic message to the client
        return new ErrorResponse("FILE_ERROR", "An error occurred during file processing.");
    }
}

Advantages of this structure:

  1. When an exception occurs, it propagates directly past the controller to the global handler.
  2. You can manage the application’s entire error response standard in a single class.
  3. Separation of Concerns is achieved: Service code focuses on business logic, and exception handling focuses solely on error management.

Conclusion

The textbook approach to Checked Exceptions isn’t “wrong.” However, utilizing the powerful features provided by the framework to make code more concise and safe—that is the essence of “Modern Java Development.”

The custom exceptions in the examples above (FileStorageException, InsufficientBalanceException) deserve deliberate design, not ad-hoc naming — for how to structure them, separate business from system errors, and map them to HTTP status codes, see designing effective custom exceptions.