Exception Handling with @ControllerAdvice & ProblemDetail¶
An API's error responses are part of its contract. Clients branch on them, dashboards count them, and humans read them at the worst moments. The goal of this lesson is one consistent error format, produced from one place, with no stack traces leaking out.
The standard format: problem details¶
RFC 9457 (which replaced RFC 7807) defines application/problem+json:
{
"type": "https://example.com/problems/task-not-found",
"title": "Task not found",
"status": 404,
"detail": "Task 99 does not exist",
"instance": "/api/tasks/99"
}
Spring has first-class support through the ProblemDetail class and the
ErrorResponse interface. Use it instead of inventing {"error": ..., "code": ...} — it
is a standard your clients may already parse, and you can add your own members
(errors, traceId) as extensions.
Domain exceptions¶
Services should throw exceptions that describe what went wrong in the domain, not HTTP:
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(long id) {
super("Task " + id + " does not exist");
}
}
public class TooManyOpenTasksException extends RuntimeException {
public TooManyOpenTasksException(int limit) {
super("Limit of " + limit + " open tasks reached");
}
}
The service does not know it is behind HTTP; the same service could be called from a message listener. Translating to status codes is the web layer's job.
One handler for the whole API¶
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
ProblemDetail notFound(TaskNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Task not found");
return pd;
}
@ExceptionHandler(TooManyOpenTasksException.class)
ProblemDetail tooMany(TooManyOpenTasksException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Open task limit reached");
return pd;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody();
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField,
fe -> String.valueOf(fe.getDefaultMessage()), (a, b) -> a));
pd.setProperty("errors", errors);
return handleExceptionInternal(ex, pd, headers, status, request);
}
}
@RestControllerAdviceapplies to every controller (scope it withbasePackages/assignableTypesif needed).- Extending
ResponseEntityExceptionHandlergives you problem-detail responses for all of Spring MVC's own exceptions — unsupported media type, missing parameter, unreadable body, method not allowed — and hooks likehandleMethodArgumentNotValidto customize them. - Returning a
ProblemDetailfrom an@ExceptionHandlersets the status from itsstatusfield and the content type toapplication/problem+json. Theinstanceis filled in from the request path.
Alternatively, set spring.mvc.problemdetails.enabled=true and Boot registers a
ResponseEntityExceptionHandler for you — useful when you have no custom handlers. Once
you write your own subclass (as above), Boot's backs off.
Worked example: the real responses¶
Against the Level 1 project, the handler above produced:
$ curl localhost:8080/api/tasks/99
{"detail":"Task 99 does not exist","instance":"/api/tasks/99","status":404,"title":"Task not found"}
$ curl -X POST localhost:8080/api/tasks -H 'Content-Type: application/json' -d '{"title":""}'
{"detail":"Invalid request content.","instance":"/api/tasks","status":400,"title":"Bad Request","errors":{"title":"must not be blank"}}
And a @WebMvcTest pins the behavior so it cannot regress (this test passes on Boot 4.1):
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired MockMvc mvc;
@MockitoBean TaskService service;
@Test
void missingTaskIsA404ProblemDetail() throws Exception {
given(service.get(42)).willThrow(new TaskNotFoundException(42));
mvc.perform(get("/api/tasks/42"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Task not found"))
.andExpect(jsonPath("$.detail").value("Task 42 does not exist"));
}
}
Unexpected exceptions¶
What about a NullPointerException from a bug? Do not return its message — it may contain
internal details. Add a catch-all that logs with full context and returns a generic body:
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(Exception.class)
ProblemDetail unexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled error on {} {}", request.getMethod(), request.getRequestURI(), ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred");
}
Boot's defaults already avoid leaking stack traces in the body (server.error.include-stacktrace
defaults to never); keep it that way in production.
Choosing status codes¶
| Situation | Status |
|---|---|
| Malformed syntax, failed validation | 400 |
| Not authenticated | 401 (Level 2) |
| Authenticated but not allowed | 403 |
| Resource does not exist | 404 |
| Conflicts with current state (duplicate, version mismatch, limit reached) | 409 |
| Well-formed but semantically invalid for business rules | 422 (some teams prefer 400 for all input errors — pick one and be consistent) |
| Bug or unexpected failure | 500 |
| Dependency down / overloaded | 503 |
How It Actually Works¶
When an exception escapes a handler method, the DispatcherServlet asks its
HandlerExceptionResolvers, in order:
ExceptionHandlerExceptionResolver— looks for an@ExceptionHandlermethod first in the controller class itself, then in@ControllerAdvicebeans (ordered by@Order). It picks the handler whose declared exception type is the closest match in the exception's class hierarchy, so a handler forTaskNotFoundExceptionbeats one forRuntimeException. The handler's return value then goes through the same return-value handling and message conversion as a normal controller method.ResponseStatusExceptionResolver— handles exceptions annotated with@ResponseStatusor of typeResponseStatusException.DefaultHandlerExceptionResolver— maps Spring's own MVC exceptions to status codes.
If none handles it, the exception propagates out of the servlet; the container forwards
to /error, where Boot's BasicErrorController renders its default error body. That
is the "Whitelabel" path you see when nothing else caught the error — including errors
thrown by filters, which run before the DispatcherServlet and therefore never reach
@ControllerAdvice (Spring Security errors are the common case, handled in Level 2).
Common mistakes¶
- try/catch in every controller method to build error responses. One advice class is easier to keep consistent.
- Throwing
ResponseStatusExceptionfrom services. It ties domain code to HTTP. Fine in controllers; avoid it deeper. - Leaking
ex.getMessage()from unexpected exceptions. SQL fragments, file paths, and class names end up in client responses. - Logging expected errors at ERROR. A 404 is not an incident. Log client errors at DEBUG/INFO, and alert only on 5xx.
- Overriding
handleMethodArgumentNotValidwith a different body shape than every other error. Keep the problem format and add fields as extensions.
Exercise¶
- Implement
ApiExceptionHandleras above, plus the catch-all handler. - Give each problem a stable
typeURI (for exampleURI.create("https://api.example.com/problems/task-not-found")), and document the types in a short README section. - Write
@WebMvcTesttests for: 404 on a missing task, 400 with anerrors.titlefield, 409 when the limit is reached, and 415 when postingtext/plain. - Throw an exception from a servlet
Filterand observe that your advice does not see it. Note which component produced the response instead.