How to build consistent, standards-compliant error responses in Spring Boot 3 using @ControllerAdvice and the RFC 7807 ProblemDetail class.
In a large programme — say, twenty or thirty microservices built by different teams — one of the first things that starts to fall apart is error responses. Each service invents its own error shape. One wraps everything in a { "error": "..." } envelope. Another returns { "message": "...", "code": 404 }. A third returns a raw Spring default with a timestamp, path, and a stack trace in production because nobody turned off the defaults. The consuming clients end up with error-parsing logic that forks on which service they’re talking to. This is a solvable problem, and RFC 7807 with Spring Boot 3 solves it cleanly.
The immediate consequence of inconsistent error shapes is that client code can’t reliably parse failures. If you’re calling five downstream services and any of them can fail, you need five different error-handling branches — each guessing at which field contains the human-readable message, whether the HTTP status code is in the body or only in the header, and whether field-level validation errors are flattened or nested.
This compounds when you add observability. If every service emits a different error structure, your logging and alerting pipelines have to normalise them. An API gateway trying to return a clean error to an upstream consumer has to understand each downstream format individually.
The other failure mode is the Spring Boot default. Without any configuration, a validation failure on a Spring MVC controller returns a DefaultHandlerExceptionResolver response that includes a trace field in non-production profiles. Services reach production with this enabled because nobody wrote a handler, and suddenly your internal exception messages are part of your API contract.
A shared error contract — one that all services follow — eliminates all of this. RFC 7807 is that contract.
RFC 7807 (now superseded by RFC 9457, but the structure is the same) defines a standard JSON format for HTTP API error responses:
{
"type": "https://api.example.com/errors/claim-not-found",
"title": "Claim Not Found",
"status": 404,
"detail": "No claim exists with ID claim-8821",
"instance": "/api/v1/claims/claim-8821"
}
type — a URI identifying the error category. Can be a real documentation URL or a stable identifier URI.title — short, human-readable summary. Should be stable for a given type.status — the HTTP status code, mirrored in the body for clients that parse the body without access to the HTTP layer.detail — specific description of this occurrence.instance — URI identifying this specific occurrence of the problem, typically the request URI.Spring Boot 3 ships a ProblemDetail class in spring-web that models this structure directly. You can construct one without any third-party library:
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "No claim exists with ID claim-8821");
problem.setType(URI.create("https://api.trinitylogic.co.uk/errors/claim-not-found"));
problem.setTitle("Claim Not Found");
problem.setInstance(URI.create("/api/v1/claims/claim-8821"));
Spring MVC will serialise this directly if returned from a controller or exception handler. The Content-Type is application/problem+json, which RFC 7807 specifies and which clients can test for explicitly.
The cleanest way to centralise error handling is a single @ControllerAdvice class. Every exception type you want to handle cleanly gets its own @ExceptionHandler method. Everything else falls to a catch-all.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ClaimNotFoundException.class)
public ProblemDetail handleClaimNotFound(ClaimNotFoundException ex, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create("https://api.example.com/errors/claim-not-found"));
problem.setTitle("Claim Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationFailure(MethodArgumentNotValidException ex, HttpServletRequest request) {
List<Map<String, String>> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(), "message", defaultString(fe.getDefaultMessage())))
.toList();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
problem.setType(URI.create("https://api.example.com/errors/validation-failure"));
problem.setTitle("Validation Failure");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("fieldErrors", fieldErrors);
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception on {}", request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setType(URI.create("https://api.example.com/errors/internal-error"));
problem.setTitle("Internal Server Error");
problem.setInstance(URI.create(request.getRequestURI()));
return problem;
}
}
A few things to note. @RestControllerAdvice is equivalent to @ControllerAdvice + @ResponseBody — it means the return value is written directly to the response body, not resolved as a view name. The MethodArgumentNotValidException handler covers Bean Validation failures on @RequestBody objects annotated with @Valid. The fallback Exception.class handler logs the full stack trace server-side but returns nothing sensitive to the caller — no message, no trace.
The domain exception (ClaimNotFoundException) is a checked or unchecked exception you define in your domain layer. Keeping the mapping in the advice class rather than annotating the exception itself with @ResponseStatus keeps your domain model free of HTTP concerns.
RFC 7807 explicitly allows additional fields beyond the standard five. Spring Boot’s ProblemDetail.setProperty() method adds them to the JSON output. Two extensions that earn their place in production:
Field errors for validation responses:
The fieldErrors property shown above gives API consumers a structured list of which fields failed and why, without needing to parse the detail string:
{
"type": "https://api.example.com/errors/validation-failure",
"title": "Validation Failure",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/claims",
"fieldErrors": [
{ "field": "claimantNino", "message": "must not be blank" },
{ "field": "claimDate", "message": "must be a past date" }
]
}
Correlation ID for distributed tracing:
In a microservices programme running on something like AWS X-Ray or OpenTelemetry, every request carries a trace ID. Including it in the error response means a support engineer can go directly from a reported error to the trace in your observability tool:
@ExceptionHandler(ClaimNotFoundException.class)
public ProblemDetail handleClaimNotFound(ClaimNotFoundException ex, HttpServletRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setType(URI.create("https://api.example.com/errors/claim-not-found"));
problem.setTitle("Claim Not Found");
problem.setInstance(URI.create(request.getRequestURI()));
problem.setProperty("correlationId", MDC.get("traceId")); // populated by your tracing filter
return problem;
}
At DWP Digital, having a correlationId in error responses was the difference between a support ticket taking ten minutes to resolve and taking two hours. When the correlation ID matches the trace in Grafana and the log line in Kibana, the investigation path is obvious.
The instance field is often the most underused part of a problem detail response. It exists precisely to make a specific occurrence of an error findable — and the request URI is the right value for it in the vast majority of cases.
Inject HttpServletRequest into every @ExceptionHandler method and always set it:
problem.setInstance(URI.create(request.getRequestURI()));
For query-heavy endpoints where the query string is part of what caused the failure, use getRequestURI() + "?" + request.getQueryString() instead — but be cautious about including sensitive parameters. For POST endpoints, the path alone is usually sufficient.
The benefit is operational: when a client reports an error, the instance URI in the response body tells you exactly which endpoint and resource was being accessed, without relying on the client to describe it accurately. Combined with a correlationId, a problem detail response becomes a self-contained incident report.
If you’re building REST APIs that need to be consumed reliably across teams, get in touch.