The orders API already rejects a blank customerId with 400 Bad Request. @Valid is doing its job. Open the body anyway. You get a timestamp, a status, and a path — and nothing a client can use to fix the request.

the error body is part of the API contract. Status codes tell the client it failed. RFC 7807 ProblemDetail tells the client what failed, without leaking stack traces or validator internals.

This post continues that lab. The controller, records, and @Valid constraints stay as they are. We will replace the default 400 blob with a ProblemDetail the client can parse, then map a domain OrderNotFoundException to 404 the same way.

The 400 you already have

Keep the previous app running and send the same invalid POST the records post used. @Valid still fails on a blank customerId and a non-positive quantity.

curl -i -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"","items":[{"sku":"book","quantity":0}]}'

On a stock Spring Boot 3 app the status is correct and the body is not:

HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-09-06T00:00:00.000+00:00","status":400,"error":"Bad Request","path":"/api/orders"}

customerId and items[0].quantity never appear. Boot omits binding errors and exception messages by default (server.error.include-binding-errors=never, server.error.include-message=never) so a careless /error page does not dump validator output. That is a good security default. It is not an API contract.

Turning those flags to always is the wrong fix. The payload then includes codes, arguments, and often rejectedValue — internals a browser or mobile client should not have to parse, and values you may not want on the wire.

RFC 7807 is a document, not a stack dump

RFC 7807 (now maintained as RFC 9457) defines a small JSON object for HTTP APIs. Spring Boot 3 exposes it as org.springframework.http.ProblemDetail.

The members clients should learn:

MemberRole
typeURI that identifies this class of problem. Stable key for client switch logic.
titleShort summary for humans. Do not parse this in code.
statusHTTP status, repeated in the body so the payload is self-contained.
detailThis occurrence, in one sentence.
instanceURI of this request, usually the path.

Anything else is an extension. We will add errors for field violations. The media type is application/problem+json, not application/json.

A useful validation body looks like this — same 400, with fields a client can walk:

{
  "type": "https://api.geekmonks.com/problems/validation",
  "title": "Invalid request",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "instance": "/api/orders",
  "errors": [
    { "field": "customerId", "message": "must not be blank" },
    { "field": "items[0].quantity", "message": "must be greater than 0" }
  ]
}

Clients branch on type and errors[].field, not on English title strings. Serve HTML at those type URIs later if you want; the URI is already a contract even while it 404s as a web page.

Boot’s one-flag Problem Detail

Spring Framework 6 models this as ErrorResponse: status, headers, and a ProblemDetail body. MVC exceptions such as MethodArgumentNotValidException already implement it. Boot can render that shape without your own advice.

spring:
  mvc:
    problemdetails:
      enabled: true

Restart and replay the invalid POST. The body is now RFC-shaped and still not helpful:

HTTP/1.1 400
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/api/orders"
}

about:blank means “no more specific type.” There is still no field list. The flag is a useful experiment and a fine default for exceptions you have not customized. It does not finish the validation contract.

Note: Boot registers that renderer only when no ResponseEntityExceptionHandler bean exists. Once you add the advice below, your class takes over and the property becomes redundant.

@ControllerAdvice owns the mapping

A @RestControllerAdvice is a @ControllerAdvice that writes a body. Extend ResponseEntityExceptionHandler so you keep Spring’s mappings for malformed JSON, missing parameters, and type mismatches, then override the one exception @Valid actually throws for a @RequestBody: MethodArgumentNotValidException.

Put this next to OrderController in the same package.

package com.geekmonks.orders;

import java.net.URI;
import java.util.List;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    private static final URI VALIDATION_TYPE =
            URI.create("https://api.geekmonks.com/problems/validation");

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                status, "One or more fields failed validation.");
        problem.setTitle("Invalid request");
        problem.setType(VALIDATION_TYPE);
        problem.setProperty("errors", fieldViolations(ex));

        return handleExceptionInternal(ex, problem, headers, status, request);
    }

    private static List<FieldViolation> fieldViolations(
            MethodArgumentNotValidException ex) {

        return ex.getBindingResult().getFieldErrors().stream()
                .map(error -> new FieldViolation(
                        error.getField(),
                        error.getDefaultMessage() != null
                                ? error.getDefaultMessage()
                                : "invalid"))
                .toList();
    }

    public record FieldViolation(String field, String message) {}
}

Three choices in that class are the whole design:

  • handleExceptionInternal fills instance from the request and keeps headers consistent with other MVC errors.
  • setProperty("errors", …) is the RFC extension. Jackson serializes the FieldViolation record as objects with field and message.
  • No rejectedValue. The client already sent the value. Echoing it back can leak passwords, tokens, or PII into logs and screenshots.

Leave CreateOrderRequest and the @PostMapping method alone. The records post already put @Valid on the body; this class only changes what happens after the constraint fails.

Note: Do not copy ex.getMessage() into detail. For MethodArgumentNotValidException that string is a BindingResult dump (Validation failed for argument [0]…), not a sentence a UI can show.

Replay the POST, read the fields

Restart so the advice is in the context, then send the same payload again.

curl -i -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"","items":[{"sku":"book","quantity":0}]}'

The status is still 400. The body now names both failures. Nested paths use the same items[0].quantity form Bean Validation already uses.

HTTP/1.1 400
Content-Type: application/problem+json

{
  "type": "https://api.geekmonks.com/problems/validation",
  "title": "Invalid request",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "instance": "/api/orders",
  "errors": [
    {"field":"customerId","message":"must not be blank"},
    {"field":"items[0].quantity","message":"must be greater than 0"}
  ]
}

Treat errors as a set. Hibernate Validator does not promise array order, and a client that only reads errors[0] will drop the second violation.

A UI can now highlight customerId and the first line’s quantity. That is the difference between “Boot returned 400” and “the API explained the 400.”

OrderNotFound is a domain 404

The records lab’s OrderService.findById throws ResponseStatusException. That works, and it couples the service to Spring Web. The production note in that post was to throw a domain exception and translate it in advice. Do that next.

package com.geekmonks.orders;

import java.util.UUID;

public class OrderNotFoundException extends RuntimeException {

    private final UUID orderId;

    public OrderNotFoundException(UUID orderId) {
        super("Order %s was not found".formatted(orderId));
        this.orderId = orderId;
    }

    public UUID orderId() {
        return orderId;
    }
}

Keep the exception free of HttpStatus and ProblemDetail. The service states a fact; HTTP mapping stays in the web layer.

Replace the ResponseStatusException in findById:

public Order findById(UUID id) {
    var order = orders.get(id);
    if (order == null) {
        throw new OrderNotFoundException(id);
    }
    return order;
}

The controller’s GET /api/orders/{id} does not change. It still calls orders.findById(id) and maps the result to OrderResponse. Without advice, an uncaught OrderNotFoundException would become a 500. Add a specific @ExceptionHandler on the same advice class.

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;

private static final URI ORDER_NOT_FOUND_TYPE =
        URI.create("https://api.geekmonks.com/problems/order-not-found");

@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<Object> handleOrderNotFound(
        OrderNotFoundException ex, WebRequest request) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setTitle("Order not found");
    problem.setType(ORDER_NOT_FOUND_TYPE);

    return handleExceptionInternal(
            ex, problem, HttpHeaders.EMPTY, HttpStatus.NOT_FOUND, request);
}

Spring Framework’s ErrorResponse / ErrorResponseException can carry a ProblemDetail on the exception itself. That is convenient inside the web layer. Prefer the domain type plus @ExceptionHandler when the throw site is OrderService, so the use case does not import HTTP types.

Fetch an id the in-memory map never saw:

curl -i http://localhost:8080/api/orders/11111111-1111-1111-1111-111111111111

The response is 404 with the same document shape as the 400, minus errors:

HTTP/1.1 404
Content-Type: application/problem+json

{
  "type": "https://api.geekmonks.com/problems/order-not-found",
  "title": "Order not found",
  "status": 404,
  "detail": "Order 11111111-1111-1111-1111-111111111111 was not found",
  "instance": "/api/orders/11111111-1111-1111-1111-111111111111"
}

Including the id in detail is reasonable here: the client sent it. Do not reuse that habit for unexpected failures. A 500 detail that repeats a JDBC message or a filesystem path is an information leak.

Keep stack traces off the wire

Boot’s default is already conservative. Do not undo it in application.yml while you debug on a laptop and then forget to revert.

server:
  error:
    include-stacktrace: never
    include-message: never
    include-binding-errors: never

Those flags apply to the fallback /error page. Your @ExceptionHandler methods are a second path. They must be just as strict: log the exception, send a generic body.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;

private static final Logger log =
        LoggerFactory.getLogger(ApiExceptionHandler.class);

private static final URI INTERNAL_TYPE =
        URI.create("https://api.geekmonks.com/problems/internal");

@ExceptionHandler(Exception.class)
ResponseEntity<Object> handleUnknown(Exception ex, WebRequest request) {

    log.error("Unhandled exception", ex);

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred.");
    problem.setTitle("Internal error");
    problem.setType(INTERNAL_TYPE);

    return handleExceptionInternal(
            ex,
            problem,
            HttpHeaders.EMPTY,
            HttpStatus.INTERNAL_SERVER_ERROR,
            request);
}

Never put ex.getMessage() or a stack trace in a 500 body. The logger keeps the cause for operators. The client gets type, title, status, and a sentence that does not change with the bug.

Spring still picks the most specific handler. MethodArgumentNotValidException and OrderNotFoundException do not fall through to Exception. Extending ResponseEntityExceptionHandler keeps that true for the MVC exceptions the parent already lists.

One more leak to avoid on the 400 path: do not add rejectedValue to FieldViolation “for convenience.” A password reset, token, or card field will show up in the next HAR file you attach to a ticket.

Error-body checklist

400  @Valid / @RequestBody
     MethodArgumentNotValidException
     type = .../problems/validation
     errors[] = { field, message }     -- no rejectedValue, no codes

404  missing order
     OrderNotFoundException in the service
     @ExceptionHandler in advice
     type = .../problems/order-not-found

500  anything else
     log the exception
     generic detail, no getMessage()

Always
     ProblemDetail: type, title, status, detail, instance
     Content-Type: application/problem+json
     extend ResponseEntityExceptionHandler
     server.error.include-stacktrace: never

Do not
     throw ResponseStatusException from the service
     enable include-binding-errors=always as the public contract
     mix /error JSON and ProblemDetail on the same API

Wrap-up

@Valid already decided the request was wrong. The missing piece was a body the client could parse. RFC 7807 ProblemDetail is that body: type, title, status, detail, plus an errors extension for field names. Spring Boot 3’s ErrorResponse support and a small @RestControllerAdvice that extends ResponseEntityExceptionHandler are how you own it.

Keep HTTP mapping in advice. Let OrderService throw OrderNotFoundException, not ResponseStatusException. Log unexpected failures and return a boring 500. The status code stays honest; the body becomes part of the same contract as CreateOrderRequest.

Next optional step Keep production config and secrets out of the jar. Profiles and Secrets