Skip to main content

Command Palette

Search for a command to run...

How I stopped throwing generic exceptions in Spring Boot

Updated
4 min read
How I stopped throwing generic exceptions in Spring Boot
M
Systems Engineering Student | Focused on Backend Development

The Context

Relying on standard try-catch blocks across the service layer inevitably leads to code duplication and leaks HTTP/web layer concerns into the business logic. I recently decided to audit how I was handling exceptions in my APIs. Without a centralized strategy, the API blindly throws a 500 Internal Server Error or the dreaded Spring Boot "Whitelabel Error Page" for predictable business rule violations, such as attempting to fetch a user that does not exist in the MySQL database. I needed a centralized way to translate business rule failures into standard HTTP responses with consistent JSON error payloads.

The Core Concept

The solution relies on the Global Exception Handler pattern using Spring's @ControllerAdvice. Instead of catching errors locally, the service layer throws a Custom Exception (a class extending RuntimeException). The @ControllerAdvice component acts as a global interceptor: it listens for these specific exceptions across all controllers, catches them, and formats a standardized HTTP response (e.g., 404 Not Found). This keeps the business logic strictly focused on business rules, entirely decoupled from REST API semantics.

The Implementation

1. The Custom Exception

package com.example.demo.exception;

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User with ID " + id + " was not found in the database.");
    }
}

2. The Standardized Error Payload

package com.example.demo.dto;

import java.time.LocalDateTime;

public record ErrorResponse(
    int statusCode,
    String message,
    LocalDateTime timestamp
) {}

3. The Global Exception Handler

package com.example.demo.exception;

import com.example.demo.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFoundException(UserNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }
}

4. The Service Layer

package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.exception.UserNotFoundException;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
    }
}

5. The Controller Layer

package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }
}

Trade-offs & Gotchas

Moving from theory to practice brought up some strict Java and Spring Boot architectural rules that I had to troubleshoot.

First, I encountered the error Driver class com.mysql.cj.jdbc.Driver not found. Even though I declared the MySQL connection in my application.properties, Maven was missing the actual driver dependency. I had to explicitly inject the mysql-connector-j dependency into my pom.xml to allow the application to communicate with the database.

Secondly, I faced a severe package structure issue: Package name does not correspond to the file path. Initially, I created my controller, service, and repository folders directly under the java directory. Java is incredibly strict about file paths matching the declared package exactly. Furthermore, Spring Boot's @SpringBootApplication only scans for components (like @RestController or @Service) inside its own base package or sub-packages. By not placing my folders inside com.example.demo, Spring Boot simply ignored my code, resulting in a fallback Whitelabel Error Page instead of my custom JSON. Refactoring the folder structure fixed the issue instantly.

Real-World Use Case

In a FinTech payment gateway, custom exceptions are critical for transaction states. If a user attempts a transfer, the service might evaluate multiple rules: sufficient balance, account locking status, and daily limits. By throwing specific unchecked exceptions (InsufficientFundsException, AccountLockedException), the global handler intercepts them and maps them to precise 422 Unprocessable Entity or 403 Forbidden responses, each carrying a unique errorCode in the JSON body that the mobile frontend uses to display specific UI flows.