What RFC 9457 Problem Details defines and why it replaced RFC 7807

RFC 9457 defines a standard JSON body for HTTP API errors, served with the media type application/problem+json. It specifies five members, all optional. type is a URI that identifies the kind of problem. title is a short human-readable summary of that type. status repeats the HTTP status code. detail explains this specific occurrence. instance is a URI that identifies this occurrence. If type is omitted, it defaults to about:blank, which means the problem has no meaning beyond the HTTP status itself. A typical body looks like {"type": "", "title": "Insufficient funds", "status": 422, "detail": "Balance is 30.00, transfer requires 50.00", "instance": "/transfers/8812", "balance": 30.00}. The final balance field is an extension member.

RFC 9457 replaced RFC 7807 without changing the core shape, so existing payloads remain valid. The update did three things:

  • It tightened guidance on extension members. Clients must ignore extensions they don't recognize, which lets producers add fields without breaking consumers.
  • It described how to represent multiple problems in a single response.
  • It created a registry of common problem types, so recurring cases can share a type URI instead of each API inventing its own.

The main benefit is consistency. When every service returns the same machine-readable error shape, client libraries, gateways and alerting can branch on type instead of parsing message strings. This pays off most for teams running public or multi-consumer REST APIs on Spring MVC.

This article treats Problem Details as an error contract you design. Your type URIs, extensions and wording become API surface that clients depend on, which is why enabling a single property is only the starting point.

import java.math.BigDecimal;
import java.net.URI;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// Domain exception carrying the data the error contract chooses to expose.
class InsufficientFundsException extends RuntimeException {
    final String transferId; final BigDecimal balance; final BigDecimal required;
    InsufficientFundsException(String transferId, BigDecimal balance, BigDecimal required) {
        super("insufficient funds");
        this.transferId = transferId; this.balance = balance; this.required = required;
    }
}

@RestControllerAdvice
class ProblemHandlers {
    // Stable, documented type URI: clients branch on this, never on detail text.
    private static final URI INSUFFICIENT_FUNDS =
        URI.create("");

    @ExceptionHandler(InsufficientFundsException.class)
    ProblemDetail insufficientFunds(InsufficientFundsException ex) {
        // status + detail: this specific occurrence, readable by humans.
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatusCode.valueOf(422),
            "Balance is " + ex.balance + ", transfer requires " + ex.required);
        pd.setType(INSUFFICIENT_FUNDS);       // without this, type is about:blank
        pd.setTitle("Insufficient funds");    // same for every occurrence of this type
        pd.setInstance(URI.create("/transfers/" + ex.transferId));
        // Extension member: serialized at the top level next to the standard members.
        // Clients that don't recognize it must ignore it.
        pd.setProperty("balance", ex.balance);
        return pd; // Spring MVC writes this as application/problem+json
    }
}

A Spring MVC exception handler that returns a ProblemDetail and produces the insufficient-funds problem+json body shown above. It sets an explicit type URI, a title, an instance and a balance extension member.

How Spring represents Problem Details: ProblemDetail, ErrorResponse and ErrorResponseException

ProblemDetail is the body model: a plain class with type, title, status, detail and instance, plus a properties map for extension members. You build one with ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Order already shipped"), then call setType(URI) and setProperty("orderId", id). Extension members are serialized at the top level of the JSON, not nested, which is what RFC 9457 requires.

ErrorResponse is the interface that ties a status, headers and a ProblemDetail body to an exception. Spring's own MVC exceptions implement it, so the framework already knows how to render them. ErrorResponseException is a concrete implementation you can extend. A subclass such as OrderConflictException carries its own status and body, so the handler doesn't need a lookup table.

A handler method can return ProblemDetail directly when the status in the body is enough. Return ResponseEntity<ProblemDetail> when you also need headers such as Retry-After. In both cases the message converter recognizes the type and writes application/problem+json. If you leave instance unset, Spring fills it with the request path.

 Servlet filters / Spring Security   <- 401/403 produced here never
 ─────────────────────────────────      reach the advice below
            │
       DispatcherServlet
            │
       Controller ──throws──▶ @RestControllerAdvice
                                  │ builds ProblemDetail
                                  ▼
                       HttpMessageConverter
                       writes application/problem+json

The diagram shows why the advice is not the whole story. Anything rejected before the DispatcherServlet runs bypasses it, and section 5 covers how to handle those errors.

// Custom exception: carries its own status and ProblemDetail body.
public class OrderConflictException extends ErrorResponseException {
    public OrderConflictException(String orderId) {
        super(HttpStatus.CONFLICT, problemFor(orderId), null);
    }

    private static ProblemDetail problemFor(String orderId) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT, "Order already shipped");
        pd.setType(URI.create("/problems/order-conflict")); // relative URI is allowed by RFC 9457
        pd.setTitle("Order conflict");
        pd.setProperty("orderId", orderId); // serialized as a top-level member
        return pd;
    }
}

// InventoryUnavailableException is a plain RuntimeException defined elsewhere.
@RestControllerAdvice
public class ApiExceptionHandler {

    // The status in the body is enough, so return ProblemDetail directly.
    @ExceptionHandler(OrderConflictException.class)
    public ProblemDetail handleConflict(OrderConflictException ex) {
        return ex.getBody(); // instance is filled with the request path if left unset
    }

    // A header is needed as well, so wrap the body in ResponseEntity.
    @ExceptionHandler(InventoryUnavailableException.class)
    public ResponseEntity<ProblemDetail> handleUnavailable(InventoryUnavailableException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.SERVICE_UNAVAILABLE, "Inventory service is unavailable");
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, "30")
                .body(pd); // written as application/problem+json by the message converter
    }
}

An ErrorResponseException subclass that carries its own ProblemDetail, plus a @RestControllerAdvice that returns ProblemDetail directly when the body is enough and ResponseEntity when it must also set a Retry-After header (imports omitted).

Enabling built-in support: the property versus extending ResponseEntityExceptionHandler

Spring Boot gives you two ways to produce Problem Details for the exceptions Spring MVC raises itself. Both are built on the same base class.

Setting spring.mvc.problemdetails.enabled=true makes Boot register its own @ControllerAdvice that extends ResponseEntityExceptionHandler. Writing your own @RestControllerAdvice that extends that class gives you the same coverage, plus override points such as handleMethodArgumentNotValid and createResponseEntity. Either way, the framework exceptions are translated to application/problem+json. These include MethodArgumentNotValidException, HttpMediaTypeNotSupportedException, MissingServletRequestParameterException and HttpRequestMethodNotSupportedException.

Property Custom subclass
Coverage Spring MVC's standard exceptions Same, plus your own @ExceptionHandler methods
Control Defaults only Full control of body, headers, status
Override points None beyond MessageSource codes Every protected handle* method
Conflict risk Low on its own Higher if the property or a second advice also handles framework exceptions

Boot's handler is meant to back off when it finds your own ResponseEntityExceptionHandler bean. It does not reconcile other advices, though. Suppose a separate advice declares @ExceptionHandler(MethodArgumentNotValidException.class). Which handler wins then depends on advice ordering, not on intent. Choose one ownership model per application: the property for defaults, or a single subclass for anything custom.

Switching it on rewrites error bodies for existing endpoints. Clients that read Boot's default timestamp, error and path fields will break. Treat the change as a versioned API change, and hold off until consumers are ready.

WebFlux has a parallel spring.webflux.problemdetails.enabled property and its own reactive ResponseEntityExceptionHandler. Verify these against the WebFlux setup rather than reusing this MVC configuration unchanged.

// Ownership model: one custom subclass owns Spring MVC's framework exceptions.
// Leave spring.mvc.problemdetails.enabled unset (or false) so Boot's
// default handler is not also in play, and do not declare
// @ExceptionHandler(MethodArgumentNotValidException.class) in any other advice.
package com.example.orders.web;

import java.util.Map;
import java.util.stream.Collectors;

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 {

    // Inherited handlers already return application/problem+json for
    // unsupported media types, missing parameters, wrong HTTP methods, etc.
    // Override only where the default body is not enough.
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        ProblemDetail body = ex.getBody(); // status, title, detail prefilled
        Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(fe -> fe.getField(),
                        fe -> String.valueOf(fe.getDefaultMessage()),
                        (first, second) -> first));
        body.setProperty("errors", fieldErrors); // extension member
        return handleExceptionInternal(ex, body, headers, status, request);
    }
}

A single ResponseEntityExceptionHandler subclass that owns framework exceptions and adds field errors to validation responses, used instead of the spring.mvc.problemdetails.enabled property.

Implementing the central translation layer: type URIs, extension members and messages

Put every mapping in one @RestControllerAdvice that extends ResponseEntityExceptionHandler. For each domain exception, build the body with ProblemDetail.forStatusAndDetail. Then call setType with a stable URI such as callsetTitlewith fixed wording, and callsetInstance` with a URI built from the request path.

Type URIs are public API

Clients branch on type, not on title or detail. Once a URI ships, renaming it breaks integrations, so version it like an endpoint. If the URIs resolve, you take on hosting and maintaining documentation pages. If they don't resolve, state that in your API docs so nobody treats a 404 as a bug.

Extension members

setProperty adds fields that Jackson serializes at the top level of the JSON, next to status and title, not inside a nested object. Common additions:

  • An errorCode for support teams.
  • A traceId taken from your tracing context.
  • An errors array built from MethodArgumentNotValidException.getBindingResult().getFieldErrors(), with one field/message pair per entry.

RFC 9457 has no first-class way to return multiple problems in one response. An array like errors works only if you document it as an agreed extension convention.

Wording through MessageSource

Resolve title and detail from MessageSource codes rather than hard-coded strings. This keeps wording consistent across handlers and makes it localizable through the request locale.

Never echo exception messages

Passing ex.getMessage() into detail can leak SQL fragments, class names, or another user's data. Return a generic, sanitized detail that includes the traceId. Log the full exception with the same ID so engineers can find the cause.

// Imports omitted. Assumes Spring Boot 3 / Spring Framework 6 and a tracer that puts traceId in the MDC.
@RestControllerAdvice
public class ProblemAdvice extends ResponseEntityExceptionHandler {
  private static final Logger log = LoggerFactory.getLogger(ProblemAdvice.class);
  private final MessageSource messages;
  public ProblemAdvice(MessageSource messages) { this.messages = messages; }

  @ExceptionHandler(OrderNotFoundException.class)
  ProblemDetail orderNotFound(OrderNotFoundException ex, HttpServletRequest req, Locale locale) {
    // Wording comes from messages*.properties, never from ex.getMessage()
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
        messages.getMessage("problem.order-not-found.detail", null, locale));
    pd.setType(URI.create("")); // public, versioned identifier
    pd.setTitle(messages.getMessage("problem.order-not-found.title", null, locale));
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setProperty("errorCode", "ORD-404");          // serialized at the top level
    pd.setProperty("traceId", MDC.get("traceId"));
    return pd;
  }

  @Override
  protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
      HttpHeaders headers, HttpStatusCode status, WebRequest request) {
    ProblemDetail pd = ex.getBody(); // Spring's default 400 body; we extend it
    // Documented extension convention: one field/message pair per violation
    List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.of("field", fe.getField(), "message", messages.getMessage(fe, request.getLocale())))
        .toList();
    pd.setProperty("errors", errors);
    pd.setProperty("traceId", MDC.get("traceId"));
    return handleExceptionInternal(ex, pd, headers, status, request);
  }

  @ExceptionHandler(Exception.class)
  ProblemDetail unexpected(Exception ex, Locale locale) {
    String traceId = MDC.get("traceId");
    log.error("Unhandled exception traceId={}", traceId, ex); // full cause stays in the logs
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
        messages.getMessage("problem.internal.detail", new Object[]{traceId}, locale)); // sanitized
    pd.setType(URI.create(""));
    pd.setProperty("traceId", traceId);
    return pd;
  }
}

A single @RestControllerAdvice that sets stable type URIs, resolves title and detail via MessageSource, adds errorCode, traceId and a field-level errors extension, and logs unexpected exceptions with the trace ID instead of returning their messages to the client.

Covering errors that never reach your controller advice

@RestControllerAdvice only sees exceptions thrown while DispatcherServlet is invoking a handler. Anything that fails earlier in the request pipeline never reaches it. The two common cases are custom servlet filters, such as tenant resolution, rate limiting, or request signing, and Spring Security's filter chain. When an unauthenticated request hits a protected endpoint, ExceptionTranslationFilter hands it to the AuthenticationEntryPoint. When an authenticated user lacks permission, it goes to the AccessDeniedHandler. By default these produce a bare status code or a WWW-Authenticate challenge, not the problem+json body your clients parse everywhere else.

To keep 401 and 403 responses consistent, implement both interfaces as beans that build a ProblemDetail and write it directly to the HttpServletResponse:

  • Set the status.
  • Set the content type to application/problem+json.
  • Serialize with the application's ObjectMapper, injected rather than created with new, so naming strategy and modules match.
  • Take the type URIs and the traceId extension from the same constants or factory your controller advice uses.
  • Keep any WWW-Authenticate header your authentication scheme requires.
  • Check response.isCommitted() before writing.

Filters you own should follow the same approach: catch the exception, then write the body through the shared factory. Do not rethrow and assume something downstream will format it.

Some failures are still out of reach. A proxy, gateway, or load balancer can return its own 502, 503, or 504. The servlet container can also reject a malformed request or oversized header before Spring runs, and these responses are often HTML or plain text. Clients must check Content-Type before parsing the body as a problem document, and fall back to the status code when it isn't one.

Testing the error contract and handling it on the client

Inside your application, by contrast, the format is under your control, so pin it down with tests. Write MockMvc tests that trigger each mapped exception and assert four things: the status code, a Content-Type of application/problem+json, the exact type URI, and the presence and values of extension members such as an error code or a list of field violations, using jsonPath expressions. Treat these assertions as a published schema. If someone renames an extension member, changes a type URI, or adds a handler that returns a plain map, the build fails before any consumer sees the change.

Add a second request per case that sends only Accept: application/json. Spring's content negotiation can then select application/json as the response media type instead of application/problem+json, but the body keeps the same members. Your test should confirm the structure survives, and clients should treat both media types as a problem document when the status indicates an error.

On the consuming side, branch on type and on structured members, never on detail. The detail text is written for humans. It changes when someone fixes a typo or localizes a message, and a regex over it fails silently. A type URI is stable by design, and an extension member like a field-error array can be parsed without guesswork.

This is where the engineering time is saved. Consumers write one error decoder instead of per-endpoint parsing. Debugging also changes: instead of first finding out which handler produced an inconsistent body, the problem is a documented type with known members, and a test protects it from regressions.

Alternatives, when to avoid it, and impact on cost and engineering time

Those savings only materialize once the format is adopted consistently, so it is worth comparing it with what teams already use.

Approach Standardization Client tooling Migration effort Flexibility
Problem Details (RFC 9457) Published standard with a registered media type Generic parsers and middleware can key on type and status Moderate: clients must switch to new fields Extension members allow domain data
Spring Boot default error JSON Framework convention only Spring-aware clients only None if already in use Low: shape is fixed unless you replace the attributes
Custom in-house envelope Internal only Shared client library you maintain None if stable High, though fields tend to drift without governance
Per-endpoint ad hoc bodies None Per-endpoint parsing None Unbounded, and unpredictable for clients

Avoid or defer the switch when an internal service has a single consumer that already handles a stable custom envelope. In that case, a migration adds coordination work and gives that consumer little in return. The same reasoning applies when external clients you cannot upgrade depend on the current shape. Running both formats during a transition doubles what you test and document.

The adoption cost is mostly design and coordination work:

  • Type URIs: someone has to choose them, decide whether they resolve, and keep their documentation current.
  • Extension schema: teams must agree on member names such as errors or traceId. In practice this means a shared library, because otherwise each service invents its own variant.
  • Client migration: consumers must be sequenced and their upgrades verified.

Most of this cost is paid once or changes slowly. The savings recur on every new endpoint and every client integration. Each new error case is a single mapping in the central translation layer, client code handles all errors in one place, and contract tests catch drift before it reaches consumers.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot problem details, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.