Skip to main content

Spring REST Exception Handling

When building REST APIs with Spring, proper exception handling is crucial for creating robust and user-friendly applications. Well-designed error responses help API consumers understand what went wrong and how to fix it, while also keeping your code clean and maintainable.

Why Exception Handling Matters

Without proper exception handling:

  • Users receive confusing, technical error messages
  • Security vulnerabilities might be exposed through stack traces
  • Different parts of your API may return inconsistent error formats
  • Debugging becomes difficult

In this tutorial, you'll learn how to implement effective exception handling in Spring REST applications.

Basic Spring Exception Handling

Default Exception Handling

Spring Boot provides some basic exception handling out of the box. For example, if a request is made to a non-existent endpoint, Spring will automatically return a 404 error.

However, for most applications, you'll want more control over how exceptions are handled and presented to users.

Using @ExceptionHandler

The simplest way to handle exceptions in a Spring REST controller is using the @ExceptionHandler annotation.

java
@RestController
@RequestMapping("/api/books")
public class BookController {

@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
Book book = bookService.findById(id);
if (book == null) {
throw new BookNotFoundException(id);
}
return book;
}

@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleBookNotFoundException(BookNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}

Here's our custom exception class:

java
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
}

And a simple error response class:

java
public class ErrorResponse {
private int status;
private String message;
private long timestamp;

// Constructor, getters and setters
public ErrorResponse(int status, String message, long timestamp) {
this.status = status;
this.message = message;
this.timestamp = timestamp;
}

// Getters and setters omitted for brevity
}

With this setup, when a BookNotFoundException is thrown, the user will receive a structured JSON response like:

json
{
"status": 404,
"message": "Book not found with id: 123",
"timestamp": 1629380425853
}

Global Exception Handling with @ControllerAdvice

While @ExceptionHandler works well for controller-specific exceptions, you often want consistent error handling across your entire application. This is where @ControllerAdvice comes in.

java
@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleBookNotFoundException(BookNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidationException(ValidationException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}

// Catch-all for unexpected exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

With this global exception handler, any BookNotFoundException or ValidationException thrown anywhere in your application will be handled consistently.

Handling Validation Errors

Spring Boot's validation framework is commonly used to validate request payloads. Here's how to handle validation errors gracefully:

java
@RestController
@RequestMapping("/api/books")
public class BookController {

@PostMapping
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
Book savedBook = bookService.save(book);
return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
}
}

In our global exception handler, we can add:

java
@ControllerAdvice
public class GlobalExceptionHandler {

// ... other handlers

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
ValidationErrorResponse response = new ValidationErrorResponse();
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setMessage("Validation error");
response.setTimestamp(System.currentTimeMillis());

ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
response.addError(fieldName, errorMessage);
});

return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}

With a special response class for validation errors:

java
public class ValidationErrorResponse extends ErrorResponse {
private Map<String, String> errors = new HashMap<>();

// Constructor, getters and setters
public ValidationErrorResponse() {
super(400, "Validation error", System.currentTimeMillis());
}

public void addError(String field, String message) {
errors.put(field, message);
}

public Map<String, String> getErrors() {
return errors;
}
}

Now, if a book is submitted with invalid fields, the response will look like:

json
{
"status": 400,
"message": "Validation error",
"timestamp": 1629380425853,
"errors": {
"title": "Title cannot be empty",
"author": "Author must be at least 2 characters"
}
}

Handling Request/Response Exceptions

Spring provides specific exception types for common HTTP problems:

java
@ControllerAdvice
public class GlobalExceptionHandler {

// ... other handlers

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMessageNotReadableException(HttpMessageNotReadableException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Malformed JSON request",
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.METHOD_NOT_ALLOWED.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.METHOD_NOT_ALLOWED);
}
}

Using ResponseStatusException (Spring 5+)

Starting with Spring 5, you can use ResponseStatusException for simpler exception handling without creating custom exception classes:

java
@RestController
@RequestMapping("/api/books")
public class BookController {

@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
Book book = bookService.findById(id);
if (book == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Book not found with id: " + id
);
}
return book;
}
}

This approach is convenient for simple use cases but doesn't provide the customization of the previous approaches.

Real-World Example: Complete API

Let's put everything together into a complete example of a book API with comprehensive exception handling:

java
// Custom exceptions
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
}

public class BookAlreadyExistsException extends RuntimeException {
public BookAlreadyExistsException(String isbn) {
super("Book with ISBN " + isbn + " already exists");
}
}

// Model
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotBlank(message = "Title cannot be blank")
private String title;

@NotBlank(message = "Author cannot be blank")
private String author;

@NotBlank(message = "ISBN cannot be blank")
@Pattern(regexp = "^\\d{13}$", message = "ISBN must be a 13-digit number")
private String isbn;

// Getters, setters, constructors
}

// Controller
@RestController
@RequestMapping("/api/books")
public class BookController {
@Autowired
private BookService bookService;

@GetMapping
public List<Book> getAllBooks() {
return bookService.findAll();
}

@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return bookService.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}

@PostMapping
public ResponseEntity<Book> createBook(@Valid @RequestBody Book book) {
Book savedBook = bookService.save(book);
return ResponseEntity.status(HttpStatus.CREATED).body(savedBook);
}

@PutMapping("/{id}")
public Book updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
if (!bookService.existsById(id)) {
throw new BookNotFoundException(id);
}
book.setId(id);
return bookService.save(book);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
if (!bookService.existsById(id)) {
throw new BookNotFoundException(id);
}
bookService.deleteById(id);
return ResponseEntity.noContent().build();
}
}

// Global Exception Handler
@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorResponse> handleBookNotFoundException(BookNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}

@ExceptionHandler(BookAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleBookAlreadyExistsException(BookAlreadyExistsException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
ex.getMessage(),
System.currentTimeMillis()
);
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
ValidationErrorResponse response = new ValidationErrorResponse();

ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
response.addError(fieldName, errorMessage);
});

return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
System.currentTimeMillis()
);
// Log the exception
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

Best Practices for Exception Handling

  1. Be consistent: Use the same error response format across your entire API
  2. Be specific: Provide meaningful error messages that help users understand what went wrong
  3. Don't leak sensitive information: Never expose stack traces or sensitive details in error responses
  4. Use appropriate HTTP status codes:
    • 400 Bad Request for client errors
    • 401 Unauthorized for authentication failures
    • 403 Forbidden for authorization failures
    • 404 Not Found for missing resources
    • 409 Conflict for resource conflicts
    • 500 Internal Server Error for server errors
  5. Log detailed information: While the user gets simplified messages, ensure your logs have detailed information for debugging
  6. Include unique error identifiers: For complex systems, include a unique ID in the response that matches detailed logs

Summary

In this tutorial, you've learned:

  • The importance of proper exception handling in REST APIs
  • How to use @ExceptionHandler for controller-specific exception handling
  • How to implement global exception handling with @ControllerAdvice
  • How to handle validation errors from @Valid annotations
  • How to handle common HTTP request/response exceptions
  • How to use ResponseStatusException for simpler cases
  • A complete real-world example of a REST API with comprehensive exception handling

By implementing these patterns, you'll create APIs that are more robust, user-friendly, and easier to maintain.

Additional Resources

Exercises

  1. Implement a REST API for managing a simple entity (e.g., products, users) with CRUD operations and proper exception handling
  2. Enhance the error responses to follow the RFC 7807 Problem Details standard
  3. Add logging to the global exception handler to record detailed error information
  4. Create custom exception types for specific business rules in your application
  5. Implement a frontend that consumes your API and displays friendly error messages based on the structured error responses


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)