Designing Consistent Error Responses
Every API that can fail — and all APIs can fail — needs a consistent error format. When a validation constraint is violated, when a resource is not found, or when an unhandled exception slips through, clients should receive a predictable, machine-readable structure rather than a raw stack trace or an arbitrary JSON shape invented on the spot. This lesson covers the two main approaches: a hand-rolled shared Error DTO and the RFC 9457 Problem Details standard, both with Spring Boot 3.
Why Consistency Matters
Inconsistent error responses create real pain. A mobile client that handles {"error": "not found"} in one place and {"message": "Entity not found", "code": 404} in another needs bespoke parsing logic everywhere. A consumer writing integration tests must accommodate multiple shapes. Logging and tracing systems that try to correlate errors across services give up when the field names change per endpoint.
Deciding on a single error structure — and enforcing it across every controller, every @ExceptionHandler, and every filter — is an architectural decision that pays for itself quickly.
Approach 1: A Shared Error DTO
The simplest solution is a plain Java record (or class) that every handler maps its exceptions into. Define it once and use it everywhere:
package com.example.api.error;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiError(
int status,
String error,
String message,
String path,
Instant timestamp,
List<FieldViolation> violations
) {
public record FieldViolation(String field, String message) {}
/** Convenience factory for single-message errors */
public static ApiError of(int status, String error, String message, String path) {
return new ApiError(status, error, message, path, Instant.now(), null);
}
/** Factory for validation errors that carry field-level detail */
public static ApiError validation(String path, List<FieldViolation> violations) {
return new ApiError(422, "Unprocessable Entity",
"Validation failed", path, Instant.now(), violations);
}
}
Key design choices here: @JsonInclude(NON_NULL) omits the violations field when it is null, keeping non-validation errors compact. Using a Java record makes the DTO immutable and removes all boilerplate. The timestamp field as Instant serialises to ISO-8601 by default — no ambiguous epoch numbers.
Wire this into a @RestControllerAdvice:
package com.example.api.error;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ApiError handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest req) {
List<ApiError.FieldViolation> violations = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> new ApiError.FieldViolation(fe.getField(), fe.getDefaultMessage()))
.toList();
return ApiError.validation(req.getRequestURI(), violations);
}
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError handleNotFound(ResourceNotFoundException ex,
HttpServletRequest req) {
return ApiError.of(404, "Not Found", ex.getMessage(), req.getRequestURI());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiError handleGeneric(Exception ex, HttpServletRequest req) {
return ApiError.of(500, "Internal Server Error",
"An unexpected error occurred", req.getRequestURI());
}
}
Never expose internal exception messages to clients in production. The handleGeneric method deliberately returns a generic message. Log the real exception server-side (log.error("Unhandled", ex)) but keep the client response vague to avoid leaking implementation details or stack traces.
With this setup every error — whether a 404, a 422 validation failure, or a 500 — arrives at the client in the same shape:
// 422 from a failed @Valid check
{
"status": 422,
"error": "Unprocessable Entity",
"message": "Validation failed",
"path": "/api/users",
"timestamp": "2024-11-15T10:23:45.123Z",
"violations": [
{ "field": "email", "message": "must be a well-formed email address" },
{ "field": "firstName", "message": "must not be blank" }
]
}
// 404 from ResourceNotFoundException
{
"status": 404,
"error": "Not Found",
"message": "User with id 42 not found",
"path": "/api/users/42",
"timestamp": "2024-11-15T10:23:46.001Z"
}
Approach 2: RFC 9457 Problem Details
The Problem Details specification (RFC 9457, which superseded RFC 7807) defines a standard JSON structure for HTTP error responses. Spring Framework 6 ships first-class support for it via ProblemDetail. Using Problem Details means your API speaks a language that off-the-shelf HTTP clients, API gateways, and documentation tools already understand.
The canonical shape looks like this:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Validation failed for 2 field(s).",
"instance": "/api/users",
"violations": [
{ "field": "email", "message": "must be a well-formed email address" }
]
}
The required fields are type (a URI identifying the problem class), title (a short human-readable summary), and status. detail and instance are optional but strongly recommended. Any additional properties are allowed as extensions.
Spring Boot 3 can opt into Problem Details automatically for its built-in exceptions:
# application.properties
spring.mvc.problemdetails.enabled=true
For your own exceptions, build a ProblemDetail object directly:
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.Map;
@RestControllerAdvice
public class ProblemDetailsHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest req) {
ProblemDetail pd = ProblemDetail.forStatus(422);
pd.setType(URI.create("https://api.example.com/errors/validation-failed"));
pd.setTitle("Unprocessable Entity");
pd.setDetail("Validation failed for "
+ ex.getBindingResult().getErrorCount() + " field(s).");
pd.setInstance(URI.create(req.getRequestURI()));
List<Map<String, String>> violations = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of("field", fe.getField(),
"message", fe.getDefaultMessage()))
.toList();
pd.setProperty("violations", violations);
return pd;
}
}
Prefer Problem Details for new public APIs. Clients that parse RFC 9457 can handle errors from any compliant API without bespoke mapping. If you are building an internal API consumed only by your own front-end, the custom DTO approach is equally valid and simpler to extend.
Choosing Between the Two
- Custom Error DTO: Full control over the shape, easy to add fields, familiar to most Spring developers. The right choice for internal or tightly-coupled APIs.
- RFC 9457 Problem Details: Interoperable, tooling-friendly, self-describing via the
type URI. The right choice for public APIs, API-first designs, or when consumers are not under your control.
- You can also combine both: implement Problem Details as the outer envelope and put your field violations in a custom extension property — which is exactly what the second example above does.
Setting the Content-Type Header
For Problem Details responses the MIME type is application/problem+json rather than application/json. Spring's ProblemDetail plumbing sets this automatically when returned from a handler. If you use a custom DTO you may want to set it explicitly to signal the intent:
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex,
HttpServletRequest req) {
ApiError body = ApiError.of(404, "Not Found", ex.getMessage(), req.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(body);
}
Summary
A consistent error response structure is not a cosmetic detail — it is a contract between your API and its consumers. A shared Error DTO gives you full control and fits naturally into any Spring Boot project. The RFC 9457 Problem Details standard provides interoperability and tooling support out of the box with Spring 6. Both approaches centre on the same idea: every failure path must produce the same predictable envelope, populated by a central @RestControllerAdvice rather than scattered ad-hoc logic across your controllers.