What RFC 9457 problem details are and why APIs use them

An RFC 9457 problem details document is a JSON object served with the media type application/problem+json. It gives every error a common structure, so clients read failures the same way regardless of which endpoint or service produced them. RFC 9457 obsoletes RFC 7807 but keeps the same media type and member names. A response built to 7807 is still valid under 9457, and existing clients need no changes. The newer RFC mainly clarifies the original guidance and adds a registry for common problem types.

The five standard members, with values from a sample response for a failed payment:

Member Meaning Sample value
type URI identifying the problem category; defaults to about:blank `
title Short summary that stays the same for every occurrence of that type Insufficient funds
status HTTP status code, repeated in the body for convenience 403
detail Explanation specific to this occurrence Balance is 30.00, charge is 50.00
instance URI identifying this specific occurrence /accounts/12345/charges/abc

Extension members are any additional top-level fields, such as balance or traceId. Clients must ignore extensions they don't recognize, so you can add new ones without breaking existing consumers.

The main reason to adopt the format is that clients can branch on type instead of parsing ad hoc JSON or matching message strings. Without a shared format, each service tends to invent its own error shape, and every client ends up carrying parsing code for each one. Teams running several services, gateways that aggregate errors, and public APIs with many independent consumers gain the most from a single shared contract.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
Content-Language: en

{
  "type": "",
  "title": "Insufficient funds",
  "status": 403,
  "detail": "Balance is 30.00, charge is 50.00",
  "instance": "/accounts/12345/charges/abc",
  "balance": 30.00,
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

A complete problem+json response for the failed payment from the table: the five standard members plus two extension members, balance and traceId, which clients that don't recognize them must ignore.

How Spring represents problem details: ProblemDetail, ErrorResponse, and ErrorResponseException

Spring models a problem response with three types, each covering a different part of the job.

ProblemDetail is the response body. It holds the five standard members (type, title, status, detail, instance) and a properties map for extension members. When Jackson serializes it, those extension members are written at the top level of the JSON object rather than nested, which is what RFC 9457 expects.

ErrorResponse is an interface for anything that can describe a complete HTTP error: a status code, headers, and a ProblemDetail body. Spring MVC's own exceptions implement it. This lets the framework render them without knowing their concrete types.

ErrorResponseException is a runtime exception that implements ErrorResponse. It is the natural base class for domain exceptions. A subclass such as OrderNotFoundException can build its own ProblemDetail in the constructor, so the code that throws it decides what the client sees. Advice classes no longer need a growing list of instanceof checks.

The flow

  1. A controller throws.
  2. DispatcherServlet passes the exception to its HandlerExceptionResolver chain.
  3. ExceptionHandlerExceptionResolver looks for a matching @ExceptionHandler, first on the controller and then in any @ControllerAdvice. A handler can return a ProblemDetail or an ErrorResponse.
  4. If no handler matches but the exception is an ErrorResponse, a later resolver can still render it.
  5. The message converter writes the body as application/problem+json.

Errors outside MVC dispatch

All of this only works inside DispatcherServlet. Exceptions raised in servlet filters never reach @ControllerAdvice. They go to the container and are forwarded to Boot's /error endpoint. The Spring Security filter chain sends authentication failures to an AuthenticationEntryPoint and authorization failures to an AccessDeniedHandler. To keep every response in the same format, those components need to write problem details too.

WebFlux has its own parallel types, including a reactive ResponseEntityExceptionHandler. Errors raised in a WebFilter are handled by a WebExceptionHandler, not by controller advice.

  HTTP request
       v
+-----------------------------+   error in filter   +----------------------+
| Servlet filters             |-------------------->| Container -> /error  |
| Spring Security filter chain|                     | (Boot error handling)|
|                             |---+  auth failure   +----------------------+
+-----------------------------+   |                 +----------------------+
       |                          +---------------->| AuthenticationEntry- |
       v                                            | Point / AccessDenied-|
+-----------------------------+                     | Handler              |
| DispatcherServlet           |                     +----------------------+
|   controller method throws  |
+-----------------------------+
       v
+--------------------------------------------+
| HandlerExceptionResolver chain             |
|  1. ExceptionHandlerExceptionResolver:     |
|     @ExceptionHandler on controller, then  |
|     @ControllerAdvice -> ProblemDetail or  |
|     ErrorResponse                          |
|  2. no match, but exception is an          |
|     ErrorResponse (e.g. subclass of        |
|     ErrorResponseException) -> rendered    |
+--------------------------------------------+
       v
+--------------------------------------------+
| message converter: application/problem+json|
+--------------------------------------------+

Spring MVC exception flow: controller exceptions pass through the HandlerExceptionResolver chain to a problem+json body, while filter and Spring Security errors bypass @ControllerAdvice (WebFlux uses separate handler classes).

Enabling built-in handling and building a shared @RestControllerAdvice

Set spring.mvc.problemdetails.enabled=true for servlet applications, or spring.webflux.problemdetails.enabled=true for reactive ones. Spring Boot then registers a ResponseEntityExceptionHandler that renders framework exceptions as problem details. Clients see two changes. Error responses now carry Content-Type: application/problem+json instead of application/json. The body also changes from Boot's default error map, with timestamp, error, and path, to the RFC fields type, title, status, detail, and instance. A failed @Valid request body returns a 400 in this shape. An unsupported HTTP method returns a 405 that names the rejected method in detail. If any client parses the old error map, treat this switch as a breaking change.

The property covers only framework exceptions. To give domain exceptions the same format, write one @RestControllerAdvice that extends ResponseEntityExceptionHandler. Boot's auto-configured handler steps aside when you declare your own subclass, so you keep a single source of truth instead of two competing handlers.

Worked example: mapping a domain exception

Add an @ExceptionHandler(OrderNotFoundException.class) method that calls ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ...). Set a stable type URI and a short title such as "Order not found", then return the result. Put the order ID in detail or in an extension property, not in title. The title should stay the same for every occurrence of this problem type.

The catch-all

Add an @ExceptionHandler(Exception.class) method. More specific handlers still take precedence, so this one runs only for exceptions nothing else matched. Log the full exception with its stack trace on the server side, along with a correlation ID. Return a 500 with a generic detail such as "An unexpected error occurred", and include the same correlation ID as an extension member. Never put ex.getMessage() in the response. Exception messages often contain SQL fragments, class names, or file paths, and those details give attackers a map of your system.

import java.net.URI;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// Subclassing ResponseEntityExceptionHandler keeps framework errors (400, 405, ...) in the same format.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

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

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
                "No order exists with ID " + ex.getOrderId());
        pd.setType(URI.create("")); // stable per problem type
        pd.setTitle("Order not found");               // same for every occurrence
        pd.setProperty("orderId", ex.getOrderId());   // variable data goes in extensions
        return pd;
    }

    // Runs only when no more specific handler matched.
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        String correlationId = UUID.randomUUID().toString();
        // Full cause and stack trace stay in server logs only.
        log.error("Unhandled exception, correlationId={}", correlationId, ex);
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "An unexpected error occurred."); // never ex.getMessage(): may hold SQL or class names
        pd.setTitle("Internal server error");
        pd.setProperty("correlationId", correlationId); // lets support find the log entry
        return pd;
    }
}

A shared @RestControllerAdvice that extends ResponseEntityExceptionHandler. It maps OrderNotFoundException to a 404 ProblemDetail and adds a catch-all that logs the full cause server-side but returns only a generic 500 with a correlation ID.

Designing the contract: type URIs, extension members, and localization

Type URIs

Treat type as the primary identifier of the error contract. When it is omitted, it defaults to about:blank, which means the problem has no semantics beyond its HTTP status code. Give each error category its own stable type, such as insufficient funds or duplicate order, and document it. Never reuse one type for different problem semantics. Clients branch on type, so overloading it breaks their logic without any visible error.

Form Example Dereferenceable Trade-off
HTTPS URL ` Yes Can link to documentation, but the URL must remain resolvable
URN urn:example:problem:insufficient-funds No Has no hosting commitment, so documentation must be found elsewhere

Extension members

Use ProblemDetail.setProperty for machine-readable data such as a stable errorCode, a traceId, or per-field validation errors. You can collect the field errors from the BindingResult of a MethodArgumentNotValidException. RFC 9457 does not standardize a structure for multiple errors. Pick one shape, for example an errors array of {field, code, message}, and use it in every service. Apply one naming convention, such as camelCase, everywhere. A client that receives trace_id from one service and traceId from another needs special cases for each.

Clients must never parse detail. It is human-readable text, and it can change through rewording or translation. Put anything a client acts on in type or in an extension member.

Localization

Resolve title and detail from a MessageSource using message codes. ErrorResponse exposes title and detail message codes that the base exception handler looks up for the request locale. The handler code stays the same for every language, and translators edit property files instead of Java branches.

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    // One stable, documented type per error category. Never reuse it for other semantics.
    private static final URI VALIDATION_FAILED =
            URI.create("");

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        // super resolves title/detail through MessageSource for the request locale, e.g. in messages_de.properties:
        // problemDetail.title.org.springframework.web.bind.MethodArgumentNotValidException=Ungültige Anfrage
        // The handler itself has no locale branching.
        ResponseEntity<Object> response =
                super.handleMethodArgumentNotValid(ex, headers, status, request);

        if (response != null && response.getBody() instanceof ProblemDetail problem) {
            problem.setType(VALIDATION_FAILED);
            // Clients branch on type/errorCode, never on the human-readable detail text.
            problem.setProperty("errorCode", "VALIDATION_FAILED");

            // RFC 9457 defines no multi-error shape: use this {field, code, message} array in every service.
            List<Map<String, Object>> errors = ex.getBindingResult().getFieldErrors().stream()
                    .map(fe -> Map.<String, Object>of(
                            "field", fe.getField(),
                            "code", String.valueOf(fe.getCode()),
                            "message", String.valueOf(fe.getDefaultMessage())))
                    .toList();
            problem.setProperty("errors", errors);
        }
        return response;
    }
}

A ResponseEntityExceptionHandler override keeps the localized title and detail that Spring resolves from MessageSource, then adds a stable type URI, a camelCase errorCode, and a consistent errors array built from the BindingResult of MethodArgumentNotValidException (imports omitted).

Testing error responses with MockMvc and WebTestClient

Localization adds one more moving part that tests should cover, and the whole error contract deserves the same regression coverage as success responses. Clients branch on error fields, so a silent change breaks them as surely as a renamed property in a 200 response.

What to assert

For each error path, assert four things:

  • Status code. Check that it matches the status member in the body.
  • Content type. It must be application/problem+json, not plain application/json. Clients and gateways may use the media type to choose a parser.
  • Type URI. Compare the exact string. It is the stable identifier clients match on.
  • Extension members. Check presence and value, for example an error code or a list of invalid fields.

With MockMvc, use status(), content().contentType(...) and jsonPath(...) matchers against the response. With WebTestClient, use expectStatus(), expectHeader().contentType(...) and expectBody().jsonPath(...). Both work against a sliced web context, so these tests stay fast enough to run on every build.

Also cover the cases you did not write handlers for: a malformed JSON body, a missing required parameter, an unsupported method. These go through the base handler.

Why this matters during upgrades

ResponseEntityExceptionHandler is a framework class. Its protected methods, and the set of exceptions it handles, can change between releases. Suppose a method you override is renamed or gets a new signature. Your override can then stop being called without any compile error, unless you marked it with @Override. A newly handled exception can also start producing a default body that skips your extension members. Tests that pin the exact response shape catch both problems before clients do.

When to adopt it, when to avoid it, and the alternatives

Problem details pay off most where no error contract exists yet. On a new API you avoid a migration entirely. On a multi-service platform, one shape means gateways, client SDKs, and dashboards share a single parser instead of keeping one per team. On a public contract, pointing integrators to a published RFC is easier than documenting a format you invented and then maintaining that documentation.

Defer it when existing clients depend on the current error body. A client that reads a message field, or checks that the Content-Type is exactly application/json, will break once responses switch to application/problem+json with different member names. Treat the switch as a breaking API change. Ship it behind a new API version, or negotiate it through the Accept header, and coordinate with consumers before you retire the old shape. Turning on the built-in handling across an existing service can change many responses at once without anyone noticing.

Approach Consistency Standardized Main drawback
RFC 9457 problem details High, through one shared advice Yes Migration cost for existing clients
Custom error envelope High if enforced No You own the spec, the docs, and the client parsers
Boot default error attributes Moderate No Shape is set by the framework, and it is easy to leak internals
Per-controller handling Low No Drifts as teams add their own handlers

A custom envelope is a reasonable choice when you already have one that works. Rewriting it to match the RFC rarely justifies breaking the clients that already parse it.

Impact on cost and engineering time across services

The spending is front-loaded. Before any service benefits, someone has to choose the type URI scheme, name and define extension members such as a correlation ID or machine-readable error code, and publish a schema that clients can rely on. This design work is cheap only if it happens once. The ongoing cost is the shared library: base exception types plus the advice class, in two variants, because MVC and WebFlux use different request and handler types. Every framework upgrade means re-testing both.

The savings land in every consuming service and client. A service team throws a library exception instead of writing its own handler, so per-service error code shrinks to exception subclasses and messages. Clients write one error parser that branches on type instead of one parser per API. On-call engineers search logs by the correlation ID in a response instead of reconstructing the request. Contract drift, such as one service emitting errorCode and another code, becomes a review comment instead of a client bug found in production.

To get these savings, keep the library small and treat extension member names as public API. Route new names through a short review by the people who own the schema, so no service invents its own conventions.

Illustrative estimate (assumed numbers, not a benchmark): break-even services = (design days + yearly maintenance days) ÷ days saved per service per year.

With 10 design days, 8 maintenance days, and 2 days saved per service: (10 + 8) ÷ 2 = 9 services in year one. Below that number, a written convention may be enough without a library.

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.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.