Spring Boot Global Exception Handling Tutorial

Watch on YouTube

Spring Boot Global Exception Handling

In a real REST API, failures are normal: a product may not exist, a request may contain invalid data, or a database may be temporarily unavailable. Global exception handling converts these failures into clear, consistent JSON responses for the frontend or API client.

Request and exception flow
ClientREST request
ControllerCalls service
ServiceThrows meaningful exception
AdviceReturns safe error JSON
Spring Boot exception handling flow from REST client through controller, service, exception, RestControllerAdvice, and consistent JSON error response
How Spring Boot converts exceptions into safe, consistent API responses

Choose the correct HTTP status

HTTP status code guide for Spring Boot APIs covering 400, 401, 403, 404, 409, and 500 errors
Choose the HTTP status that matches the failure
Situation Status Meaning
Invalid JSON or validation failure 400 Bad Request Client must correct the request
Missing or invalid login token 401 Unauthorized Authentication is required
Authenticated user lacks permission 403 Forbidden Access is not allowed
Product or employee does not exist 404 Not Found Requested resource is missing
Duplicate email or business conflict 409 Conflict Request conflicts with current data
Unexpected server or database failure 500 Internal Server Error Server failed safely

What is a global exception handler?

A global handler catches exceptions after they leave a controller and converts them into one predictable response format. Controllers stay focused on successful requests instead of repeating try/catch blocks.

@RestControllerAdvice

@RestControllerAdvice applies to all REST controllers in the application. It combines controller advice with response-body behavior, so handler methods automatically return JSON.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ApiError> productNotFound(
            ProductNotFoundException ex,
            HttpServletRequest request) {

        ApiError error = new ApiError(
                Instant.now(),
                404,
                "Product Not Found",
                ex.getMessage(),
                request.getRequestURI());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

Create one consistent error response

Consistent Spring Boot API error response containing timestamp, status, error, message, and path fields
A predictable error response is easier for clients to consume and test
public record ApiError(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path) {
}

The client always receives the same shape, for example:

{
  "timestamp": "2026-07-24T10:15:30Z",
  "status": 404,
  "error": "Product Not Found",
  "message": "Product 42 was not found",
  "path": "/api/products/42"
}

@ExceptionHandler

@ExceptionHandler maps one or more exception types to a handler method. Handle specific, expected exceptions first, then use a final generic handler for unexpected failures.

@ExceptionHandler({
        ProductNotFoundException.class,
        EmployeeNotFoundException.class
})
public ResponseEntity<ApiError> resourceNotFound(
        RuntimeException ex,
        HttpServletRequest request) {
    return error(HttpStatus.NOT_FOUND, "Resource Not Found",
            ex.getMessage(), request.getRequestURI());
}

Create business exceptions

A custom exception gives a business failure a clear name. It is better than throwing a generic RuntimeException because the global handler can map it to the correct status code.

public class ProductNotFoundException
        extends RuntimeException {

    public ProductNotFoundException(Long id) {
        super("Product " + id + " was not found");
    }
}

public class DuplicateEmailException
        extends RuntimeException {

    public DuplicateEmailException(String email) {
        super("Email is already registered: " + email);
    }
}

End-to-end product API example

This flow loads a product from a repository. If the product is missing, the service throws ProductNotFoundException; the controller does not need to catch it because the advice handles it globally.

1. Repository

public interface ProductRepository
        extends JpaRepository<Product, Long> {
}

2. Service

@Service
public class ProductService {
    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public Product findById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

3. REST controller

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public Product findById(@PathVariable Long id) {
        return service.findById(id);
    }
}

4. Complete global handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ApiError> notFound(
            ProductNotFoundException ex,
            HttpServletRequest request) {
        return error(HttpStatus.NOT_FOUND, "Product Not Found",
                ex.getMessage(), request.getRequestURI());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationError> validation(
            MethodArgumentNotValidException ex) {
        Map<String, String> fields = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(field ->
                fields.put(field.getField(), field.getDefaultMessage()));
        return ResponseEntity.badRequest().body(
                new ValidationError(400, "Validation Failed", fields));
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ApiError> conflict(
            DataIntegrityViolationException ex,
            HttpServletRequest request) {
        return error(HttpStatus.CONFLICT, "Data Conflict",
                "The request conflicts with existing data",
                request.getRequestURI());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> unexpected(
            Exception ex, HttpServletRequest request) {
        log.error("Unexpected error for {}", request.getRequestURI(), ex);
        return error(HttpStatus.INTERNAL_SERVER_ERROR,
                "Internal Server Error",
                "Something went wrong. Try again later.",
                request.getRequestURI());
    }

    private ResponseEntity<ApiError> error(
            HttpStatus status, String title, String message, String path) {
        return ResponseEntity.status(status).body(new ApiError(
                Instant.now(), status.value(), title, message, path));
    }
}

Handle validation errors

Add validation annotations to the request DTO and use @Valid in the controller. The handler returns field-level messages that a frontend can display beside each input.

public record CreateProductRequest(
        @NotBlank(message = "Name is required") String name,
        @Positive(message = "Price must be greater than zero")
        BigDecimal price) {
}

@PostMapping
public Product create(
        @Valid @RequestBody CreateProductRequest request) {
    return service.create(request);
}

public record ValidationError(
        int status,
        String error,
        Map<String, String> fields) {
}

Handle framework and security errors

Some errors happen before a controller is called. For example, Spring Security uses an AuthenticationEntryPoint for 401 responses and an AccessDeniedHandler for 403 responses. Configure both to return the same JSON style as the rest of the API.

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http)
        throws Exception {
    http.exceptionHandling(exceptions -> exceptions
        .authenticationEntryPoint((request, response, ex) ->
            response.sendError(401, "Authentication required"))
        .accessDeniedHandler((request, response, ex) ->
            response.sendError(403, "Access denied")));
    return http.build();
}

Log errors safely

Log technical details on the server with a request or correlation ID. Return a safe message to the client. Never expose stack traces, SQL, passwords, JWTs, or internal file paths in a production response.

private static final Logger log =
        LoggerFactory.getLogger(GlobalExceptionHandler.class);

log.error("Unexpected error for requestId={}", requestId, ex);

Best practices

  • Use one consistent error response shape across endpoints.
  • Map expected business exceptions to intentional HTTP status codes.
  • Keep public messages safe and put technical details in server logs.
  • Do not catch Exception everywhere; handle failures where you can add useful context.
  • Do not return 200 OK for an error.
  • Test successful responses and error responses separately.
  • Use a correlation ID to trace one request across services.
Simple rule: The controller handles success, the service explains the business failure, and the global handler decides how that failure is represented to the client.
Next: Spring Data JPA

Continue learning