Structured Error Responses
A predictable error body is part of your API contract. Clients should be able to parse the same shape for every failure — a 404, a validation error, a 409 conflict — and branch on a stable code. This page designs a reusable error DTO carrying a timestamp, status, machine-readable code, message, request path, and per-field errors, then maps exceptions to it in a single global advice.
What a good error body contains
| Field | Type | Purpose |
|---|---|---|
timestamp | ISO-8601 instant | When the error occurred |
status | int | HTTP status code (mirrors the response line) |
code | string | Stable, machine-readable error code |
message | string | Human-readable summary (safe to display) |
path | string | Request URI that failed |
fieldErrors | array | Per-field validation failures (optional) |
Tip: Keep
codestable across versions and decoupled frommessage. Clients switch oncode; you stay free to rewordmessageor localize it.
The error DTO
A Java record is a clean fit, but validation errors need a mutable list, so a small class with a builder is often more practical. Here both pieces:
public record FieldValidationError(String field, Object rejectedValue, String message) {}
@Getter
@Builder
public class ApiError {
private final Instant timestamp;
private final int status;
private final String code;
private final String message;
private final String path;
private final List<FieldValidationError> fieldErrors;
public static ApiError of(HttpStatus status, String code, String message, String path) {
return ApiError.builder()
.timestamp(Instant.now())
.status(status.value())
.code(code)
.message(message)
.path(path)
.build();
}
}
Note: Annotate the DTO (or configure Jackson) so empty
fieldErrorsis omitted. With@JsonInclude(JsonInclude.Include.NON_EMPTY)the array disappears for non-validation errors, keeping bodies tidy.
Mapping exceptions in the advice
Each @ExceptionHandler produces an ApiError with the right status and code. Inject HttpServletRequest to fill path.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> notFound(ResourceNotFoundException ex,
HttpServletRequest req) {
var body = ApiError.of(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND",
ex.getMessage(), req.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ApiError> conflict(DuplicateResourceException ex,
HttpServletRequest req) {
var body = ApiError.of(HttpStatus.CONFLICT, "DUPLICATE_RESOURCE",
ex.getMessage(), req.getRequestURI());
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> unexpected(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception at {}", req.getRequestURI(), ex);
var body = ApiError.of(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR",
"An unexpected error occurred", req.getRequestURI());
return ResponseEntity.internalServerError().body(body);
}
}
Output (404):
{
"timestamp": "2026-06-13T10:15:42.123Z",
"status": 404,
"code": "RESOURCE_NOT_FOUND",
"message": "Product with id 999 was not found",
"path": "/api/products/999"
}
Warning: For the
Exceptioncatch-all, log the real exception but return a generic message. Never putex.getMessage()of an unknown500into the body — it can leak SQL, file paths, or class names.
Adding field-level validation errors
When @Valid fails, Spring throws MethodArgumentNotValidException, whose BindingResult lists every rejected field. Map those into fieldErrors for a rich 400.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> validation(MethodArgumentNotValidException ex,
HttpServletRequest req) {
List<FieldValidationError> fields = ex.getBindingResult().getFieldErrors().stream()
.map(f -> new FieldValidationError(
f.getField(), f.getRejectedValue(), f.getDefaultMessage()))
.toList();
var body = ApiError.builder()
.timestamp(Instant.now())
.status(400)
.code("VALIDATION_FAILED")
.message("Request validation failed")
.path(req.getRequestURI())
.fieldErrors(fields)
.build();
return ResponseEntity.badRequest().body(body);
}
Output (validation 400):
{
"timestamp": "2026-06-13T10:15:42.123Z",
"status": 400,
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"path": "/api/products",
"fieldErrors": [
{ "field": "name", "rejectedValue": "", "message": "must not be blank" },
{ "field": "price", "rejectedValue": -5, "message": "must be greater than 0" }
]
}
For the full validation story — @Valid, constraint annotations, and @ConstraintViolationException on path/query params — see Handling Validation Errors.
A consistent contract pays off
| Without a DTO | With ApiError |
|---|---|
| Each endpoint returns a different shape | One shape everywhere |
| Clients string-match messages | Clients switch on code |
No standard place for path/timestamp | Always present |
| Field errors formatted ad hoc | Uniform fieldErrors array |
Tip: If you want a standardized media type instead of a bespoke DTO, adopt the IETF format covered in ProblemDetail (RFC 7807) — Spring builds it in.
Pitfalls
- Returning the DTO without
@RestControllerAdvice(using plain@ControllerAdvice) treats it as a view name. Use@RestControllerAdvice. - Inconsistent timestamp formats — fix Jackson to ISO-8601 with
spring.jackson.serialization.write-dates-as-timestamps=false. - Leaking internals in
messagefor5xxerrors.