Spring Boot Intermediate: Layers, Data and Testing
Error Handling and Exceptions
In this lesson you will
- Create a custom exception for a missing resource
- Handle exceptions globally with @RestControllerAdvice
- Return structured validation errors and safe messages
Introduction
Things go wrong: a record is missing, input is invalid, a database is down. A good API answers with the right status code and a clear, consistent body, and never reveals stack traces or internals. This lesson is written for Spring Boot 4.1.2 and was not compiled or run here.
A custom exception
Java
package com.example.demo.book;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(long id) {
super("No book with id " + id);
}
}Use unchecked exceptions (RuntimeException) for conditions the caller cannot sensibly recover from locally. Throwing it from the service keeps controllers clean:
Java
public Book get(long id) {
return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
}A global handler
@RestControllerAdvice lets one class handle exceptions from all controllers.
Java
package com.example.demo.web;
import com.example.demo.book.BookNotFoundException;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
e -> e.getField(),
e -> String.valueOf(e.getDefaultMessage()),
(first, second) -> first));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", errors);
return problem;
}
}ProblemDetailis Spring's class for the Problem Details format (RFC 9457), a standard JSON shape for errors with fields such asstatus,titleanddetail.- A not-found response should look like this JSON (shape shown, not run):
JSON
{"type":"about:blank","title":"Not Found","status":404,"detail":"No book with id 99"}- A validation error adds an
errorsobject that maps each invalid field to a message, so a client can show the right message next to the right input.
Spring Boot can also produce Problem Details for built-in framework exceptions when you set spring.mvc.problemdetails.enabled=true (the property exists in Boot 4.1.2, and its default is false). Using your own handler gives you full control.
Quick quiz
What does @RestControllerAdvice do?
Catching unexpected errors
Add a final handler for anything you did not expect, and make it safe:
Java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unexpected error", ex); // full details stay in the server log
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
}Guidelines:
- Log the exception on the server with its stack trace.
- Return a generic message to the client. Never include stack traces, SQL, file paths or secrets.
- Handlers more specific than
Exceptionwin, but a catch-all can hide framework exceptions such as 404 for unknown routes or 405 for wrong methods. Test your handler so it does not turn those into 500s.
Choose the right status
| Situation | Status |
|---|---|
| Invalid input | 400 |
| Not logged in | 401 |
| Logged in but not allowed | 403 |
| Resource missing | 404 |
| Conflict, such as a duplicate | 409 |
| Unexpected server fault | 500 |
Simple alternative
For a quick one-off you can annotate an exception class with @ResponseStatus(HttpStatus.NOT_FOUND). It is simple, but the global handler gives a consistent body across the API.
Try it yourself
Add a DuplicateBookException (409) thrown when a book with the same title and year already exists. Handle it in the advice class and check that a validation error and a not-found error have the same overall shape.
Summary
- Throw custom unchecked exceptions in services, and translate them in one @RestControllerAdvice class.
- Return ProblemDetail responses with the correct status, and structured field errors for validation failures.
- Log details on the server, but send only safe, generic messages to clients.
Lesson complete
+20 XP
Nice work. Keep going while it is fresh.
Sources and further reading