Skip to content
Spring Boot sb exceptions 3 min read

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

FieldTypePurpose
timestampISO-8601 instantWhen the error occurred
statusintHTTP status code (mirrors the response line)
codestringStable, machine-readable error code
messagestringHuman-readable summary (safe to display)
pathstringRequest URI that failed
fieldErrorsarrayPer-field validation failures (optional)

Tip: Keep code stable across versions and decoupled from message. Clients switch on code; you stay free to reword message or 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 fieldErrors is 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 Exception catch-all, log the real exception but return a generic message. Never put ex.getMessage() of an unknown 500 into 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 DTOWith ApiError
Each endpoint returns a different shapeOne shape everywhere
Clients string-match messagesClients switch on code
No standard place for path/timestampAlways present
Field errors formatted ad hocUniform 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 message for 5xx errors.
Last updated June 13, 2026
Was this helpful?