“Handling exceptions in your API is like a good bouncer at a club—politely but firmly showing troublemakers the door, and making sure everyone else has a great time!” 🕺🚫

You’ve built awesome Spring Boot REST APIs, but what happens when things go wrong? A database error, invalid user input, or a resource not found? Without proper handling, your users might see cryptic stack traces or generic 500 errors. Let’s learn to catch those exceptions gracefully, returning clean, helpful JSON error messages, and keeping your API user-friendly.


🚩 Prerequisites

  1. Java Development Kit (JDK) 17+
  2. Maven or Gradle
  3. A basic Spring Boot REST project with some endpoints.

1️⃣ The Problem: Default Error Handling

By default, Spring Boot handles exceptions by returning a simple JSON error object (or a Whitelabel Error Page for browsers). While functional, it might not always provide the precise status codes or custom messages your API clients need.

{
  "timestamp": "2023-10-27T10:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "No value present",
  "path": "/api/users/99"
}

We want more control! Let's get to it.


2️⃣ Centralizing with @ControllerAdvice and @ExceptionHandler

The core of graceful exception handling in Spring is the @ControllerAdvice annotation, often combined with @ExceptionHandler. This allows you to define global error handlers for your entire application.

Create a Global Exception Handler

Let's create a simple ErrorResponse DTO for consistent error messages:

// src/main/java/com/example/demo/dto/ErrorResponse.java
package com.example.demo.dto;

import java.time.LocalDateTime;

public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;

    public ErrorResponse(int status, String error, String message, String path) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    // Getters
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
}

Now, our main handler class:

// src/main/java/com/example/demo/exception/GlobalExceptionHandler.java
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.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

import java.util.NoSuchElementException;

@ControllerAdvice // Apply to all @Controller classes
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<ErrorResponse> handleNoSuchElementException(
            NoSuchElementException ex, WebRequest request) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                HttpStatus.NOT_FOUND.getReasonPhrase(),
                ex.getMessage(),
                request.getDescription(false).replace("uri=", "")
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // You can add more @ExceptionHandler methods for other exception types
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException ex, WebRequest request) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                ex.getMessage(),
                request.getDescription(false).replace("uri=", "")
        );
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Generic exception handler for anything else unhandled
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGlobalException(
            Exception ex, WebRequest request) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
                "An unexpected error occurred: " + ex.getMessage(),
                request.getDescription(false).replace("uri=", "")
        );
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
  • @ControllerAdvice: This annotation marks a class as a global exception handler.
  • @ExceptionHandler(ExceptionType.class): Each method annotated with this will handle a specific exception type across all controllers.
  • ResponseEntity<ErrorResponse>: We return a ResponseEntity with our custom ErrorResponse DTO and the appropriate HTTP status.

3️⃣ Custom Exceptions for Specific Scenarios

While handling generic exceptions is good, defining your own custom exceptions makes your code cleaner and more expressive.

// src/main/java/com/example/demo/exception/ResourceNotFoundException.java
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND) // Returns 404 Not Found
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

// src/main/java/com/example/demo/exception/InvalidInputException.java
package com.example.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.BAD_REQUEST) // Returns 400 Bad Request
public class InvalidInputException extends RuntimeException {
    public InvalidInputException(String message) {
        super(message);
    }
}

Then, you can either handle them in GlobalExceptionHandler or let Spring handle them automatically with @ResponseStatus:

// src/main/java/com/example/demo/service/UserService.java (example service)
package com.example.demo.service;

import com.example.demo.exception.ResourceNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class UserService {
    // Dummy user data
    private Optional<String> getUserById(Long id) {
        if (id == 1L) return Optional.of("Alice");
        return Optional.empty();
    }

    public String findUser(Long id) {
        return getUserById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User not found with ID: " + id));
    }
}

4️⃣ Handling Validation Errors: MethodArgumentNotValidException

When you use @Valid or @Validated on request bodies/parameters, Spring automatically throws MethodArgumentNotValidException if validation fails. Let's customize its response.

Add to GlobalExceptionHandler

// src/main/java/com/example/demo/exception/GlobalExceptionHandler.java (add this method)
// ... existing imports ...
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.HashMap;
import java.util.Map;

// ...
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleValidationExceptions(
        MethodArgumentNotValidException ex, WebRequest request) {

    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage()));

    ErrorResponse errorResponse = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation Error",
            "Input validation failed.",
            request.getDescription(false).replace("uri=", "")
    );

    // Optionally, embed the field errors into the ErrorResponse or return them directly
    // For simplicity, we'll just return the map of errors for this example
    return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}

Example Controller with Validation

// src/main/java/com/example/demo/dto/CreateUserRequest.java
package com.example.demo.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class CreateUserRequest {
    @NotBlank(message = "Name cannot be empty")
    private String name;

    @NotBlank(message = "Email cannot be empty")
    @Email(message = "Email should be valid")
    private String email;

    @Size(min = 6, message = "Password must be at least 6 characters long")
    private String password;

    // Getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
}

// src/main/java/com/example/demo/controller/UserController.java
package com.example.demo.controller;

import com.example.demo.dto.CreateUserRequest;
import com.example.demo.service.UserService;
import jakarta.validation.Valid;
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<String> getUser(@PathVariable Long id) {
        // This will throw ResourceNotFoundException if user doesn't exist
        return ResponseEntity.ok(userService.findUser(id));
    }

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody CreateUserRequest request) {
        // Logic to create user
        return ResponseEntity.ok("User created: " + request.getName());
    }
}

5️⃣ Testing Your Exception Handlers

Start your Spring Boot application and use cURL or Postman to test:

1. Resource Not Found

curl http://localhost:8080/api/users/99
# Expected: 404 Not Found with custom ErrorResponse
# {
#   "timestamp": "...",
#   "status": 404,
#   "error": "Not Found",
#   "message": "User not found with ID: 99",
#   "path": "/api/users/99"
# }

2. Validation Error

curl -X POST -H "Content-Type: application/json" \
  -d '{ "name": "", "email": "invalid-email", "password": "123" }' \
  http://localhost:8080/api/users
# Expected: 400 Bad Request with field errors map
# {
#   "name": "Name cannot be empty",
#   "email": "Email should be valid",
#   "password": "Password must be at least 6 characters long"
# }

💡 Monkey-Proof Tips

  • Specific Before Generic: Always handle more specific exceptions before generic ones in your @ControllerAdvice (e.g., handle NoSuchElementException before RuntimeException).
  • Don't Leak Information: Avoid returning internal stack traces or sensitive data in production error responses.
  • Consistent Error Format: Stick to a single, consistent JSON structure for all your error responses across the API.
  • Use Problem Details (RFC 7807): For truly robust APIs, consider implementing RFC 7807 "Problem Details for HTTP APIs," which Spring Boot 3.0 has good support for.
  • Log Errors: Even if you handle an error gracefully for the client, always log the full exception details on the server side for debugging and monitoring.

👏 You've now equipped your Spring Boot REST APIs with powerful, centralized exception handling! Your API clients will thank you for clear, consistent error messages, and your backend will be more robust. Keep building, and keep those errors in check!

By admin

Leave a Reply

Your email address will not be published. Required fields are marked *