Skip to main content

Command Palette

Search for a command to run...

Stop Inventing Error JSON: A Practical Guide to RFC 7807 Problem Details

Updated
10 min readView as Markdown

Every HTTP API eventually returns an error. The hard part is not the status code — it is the body. One service returns { "error": "not found" }, another returns { "message": "User does not exist" }, and a third serves HTML from a reverse proxy. Clients end up with fragile if (body.error) logic that breaks the moment they call a second API.

RFC 7807 fixes that by defining a small, standard JSON object for error responses: Problem Details. The idea is simple: keep HTTP status codes as the primary signal, and put structured, machine-readable context in the body.

RFC 7807 was superseded by RFC 9457 in 2023. The field names and overall shape are the same. This post uses "RFC 7807" because that is still the name most teams search for.

Companion repository: github.com/mnafshin/RFC_7807 — a runnable Spring Boot demo with OpenAPI SSOT, Problem Detail error handling, and integration tests. Clone it and follow along:

git clone https://github.com/mnafshin/RFC_7807.git
cd RFC_7807
./gradlew test

What a Problem Detail looks like

A Problem Detail is a JSON object with a few well-known fields:

Field Meaning
type URI identifying the category of problem
title Short, stable summary of that category
status HTTP status code for this occurrence
detail Explanation specific to this request
instance URI identifying this specific error occurrence

The response uses the application/problem+json media type.

Here is a realistic example — a withdrawal rejected because the account does not have enough credits:

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

{
  "type": "https://problem-detail-demo.mnafshin.info/problems/insufficient-balance",
  "title": "Insufficient balance",
  "status": 403,
  "detail": "Account 123 has 30 credits but this operation costs 50",
  "instance": "/accounts/123/withdraw",
  "accountId": "123",
  "balance": 30,
  "required": 50
}

Notice the last three fields (accountId, balance, required). RFC 7807 allows extension members — extra properties that carry domain-specific data. Clients must ignore members they do not understand.

Why type matters more than title

The title is human-friendly text: "Insufficient balance". It is useful in logs and UIs, but it can be localized or reworded.

The type URI is the stable identifier. Client code should branch on type, not on string-matching title or detail:

if (problem.type.endsWith("/insufficient-balance")) {
  showBalanceWarning(problem.balance, problem.required);
}

Point type URIs at documentation you control, e.g. https://api.example.com/problems/insufficient-balance. Clients should not depend on dereferencing that URI at runtime.

Spring Boot makes this straightforward

Since Spring Framework 6, the org.springframework.http.ProblemDetail class maps directly to RFC 7807. Spring MVC serializes it as application/problem+json when returned from a controller or @RestControllerAdvice.

Enable automatic Problem Details for framework exceptions with one property:

spring.mvc.problemdetails.enabled=true

For Bean Validation failures, override handleMethodArgumentNotValid on ResponseEntityExceptionHandler so field errors appear as extension members:

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "One or more fields are invalid.");
        problem.setTitle("Validation failed");
        problem.setType(URI.create(ProblemTypes.VALIDATION_ERROR));
        problem.setProperty("errors", fieldErrors(ex));
        return handleExceptionInternal(ex, problem, headers, status, request);
    }
}

For business-rule failures, a dedicated @ExceptionHandler is enough:

@ExceptionHandler(InsufficientBalanceException.class)
ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex, HttpServletRequest request) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
    problem.setTitle("Insufficient balance");
    problem.setType(URI.create(ProblemTypes.INSUFFICIENT_BALANCE));
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("balance", ex.getBalance());
    problem.setProperty("required", ex.getRequired());
    return problem;
}

No custom DTO, no manual ObjectMapper wiring — Spring handles content negotiation and serialization. See GlobalExceptionHandler.java and application.properties in the repo.

OpenAPI as the single source of truth

In most companies, OpenAPI is not an afterthought — it is the contract. Product, backend, frontend, mobile, and partner teams all work from the same spec. Client SDKs, server stubs, and mock servers are generated from it. Problem Details must be modeled correctly in that spec, or every generated client will handle errors wrong.

This is a different mindset from "write code first, scan annotations later." The workflow looks like this:

openapi.yaml  →  generate server interface + DTOs + client SDKs
       ↓
implement controller + exception handler
       ↓
integration tests verify runtime matches the spec

The spec comes first. Code follows.

Model RFC 7807 problems in the spec

Define a reusable base schema for the standard RFC 7807 fields, then extend it per error type with allOf:

components:
  schemas:
    ProblemDetailBase:
      type: object
      required: [type, title, status]
      properties:
        type:     { type: string, format: uri }
        title:    { type: string }
        status:   { type: integer, format: int32 }
        detail:   { type: string }
        instance: { type: string, format: uri }

    AccountNotFoundProblem:
      allOf:
        - $ref: '#/components/schemas/ProblemDetailBase'
        - type: object
          properties:
            accountId: { type: string }

    InsufficientBalanceProblem:
      allOf:
        - $ref: '#/components/schemas/ProblemDetailBase'
        - type: object
          properties:
            accountId: { type: string }
            balance:   { type: integer }
            required:  { type: integer }

Then reference each problem schema on the endpoint with the correct media type:

/accounts/{id}:
  get:
    responses:
      '200':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Account'
      '404':
        description: Account not found
        content:
          application/problem+json:
            schema:
              $ref: '#/components/schemas/AccountNotFoundProblem'

Three rules matter for codegen:

  1. Use application/problem+json, not application/json, on every error response. Client generators bind responses by media type. If you omit it or default to application/json, generated clients will deserialize errors into the wrong type.

  2. One schema per problem type, not one generic "Error" object. A 404 account-not-found problem carries different extension fields than a 403 insufficient-balance problem. Generated clients need distinct types to branch on.

  3. Put extension fields at the top level in the schema (via allOf), not nested under a properties bag. That matches what RFC 7807 specifies and what Spring's ProblemDetail actually serializes.

This repo's SSOT lives at openapi/openapi.yaml.

Generate server code from the spec

Use the OpenAPI Generator Gradle plugin to produce a Spring controller interface and request/response models. The full build.gradle is in the repo; the essentials:

plugins {
    id 'org.openapi.generator' version '7.13.0'
}

openApiGenerate {
    generatorName = 'spring'
    inputSpec = "$rootDir/openapi/openapi.yaml"
    outputDir = "$buildDir/generated/openapi"
    apiPackage = 'info.mnafshin.problem_detail_demo.api'
    modelPackage = 'info.mnafshin.problem_detail_demo.api.model'
    configOptions = [
        interfaceOnly       : 'true',
        useSpringBoot3      : 'true',
        useBeanValidation   : 'true',
        skipDefaultInterface: 'true',
    ]
}

sourceSets.main.java.srcDirs += "$buildDir/generated/openapi/src/main/java"
compileJava.dependsOn tasks.openApiGenerate

This generates an AccountsApi interface with method signatures, paths, and validation annotations already wired. AccountController implements it:

@RestController
public class AccountController implements AccountsApi {

    @Override
    public ResponseEntity<Account> getAccount(String id) {
        return ResponseEntity.ok(accountService.findById(id));
    }
}

The generated models (Account, WithdrawRequest, AccountNotFoundProblem, …) are the DTOs your API exposes. You do not hand-write parallel Java classes — the spec is the only place those shapes are defined.

Run ./gradlew openApiGenerate after every spec change. Commit the YAML; let CI regenerate Java on each build.

Generate client SDKs from the same spec

The same openapi.yaml produces consumer code. Generate a Java client for a backend service, or TypeScript for a frontend:

tasks.register('openApiGenerateClient', org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
    generatorName = 'java'
    inputSpec = "$rootDir/openapi/openapi.yaml"
    outputDir = "$buildDir/generated/client"
    configOptions = [
        library: 'native',
        apiPackage: 'com.example.client.api',
        modelPackage: 'com.example.client.model',
    ]
}

The generated client knows that a 404 on GET /accounts/{id} deserializes to AccountNotFoundProblem, not a generic exception. It can read problem.getAccountId() because the spec defined that extension field.

That is the payoff of modeling Problem Details in OpenAPI: errors become typed, branchable objects in every language — not opaque JSON maps.

Wire runtime errors to match the spec

Code generation covers happy-path DTOs and the API surface. Errors still flow through your exception handler at runtime, returning Spring's ProblemDetail:

problem.setType(URI.create(ProblemTypes.INSUFFICIENT_BALANCE));
problem.setProperty("balance", ex.getBalance());
problem.setProperty("required", ex.getRequired());

The JSON Spring emits must match the schema in openapi.yaml. The generated InsufficientBalanceProblem model describes the same shape — use it in client code; use ProblemDetail on the server. Both come from one spec.

Keep them aligned with two test layers:

  • Runtime tests — assert the live response body and Content-Type: application/problem+json
  • Spec tests — assert openapi.yaml declares every error response; run openApiValidate in CI
./gradlew openApiValidate   # spec is well-formed
./gradlew test              # runtime matches declared errors

Browse the contract in Swagger UI

Swagger UI should render the SSOT file, not a separately maintained copy. Serve the same YAML and point springdoc at it:

springdoc.api-docs.enabled=false
springdoc.swagger-ui.url=/openapi/openapi.yaml

Open http://localhost:8080/swagger-ui.html after ./gradlew bootRun — what you see is exactly what clients and codegen tools consume.

Code-first vs contract-first

Approach How the spec is produced Problem with Problem Details
Code-first (springdoc scans annotations) Generated from @ApiResponse on controllers Easy to forget error responses; extension fields need duplicate schema classes
Contract-first (OpenAPI SSOT) Authored in YAML; code is generated Errors are first-class in the spec from day one; clients and server share one truth

If your organization already treats OpenAPI as SSOT — and most do at scale — model Problem Details in the YAML before writing Java. Do not bolt them on with annotations after the fact.

Try the demo

The RFC_7807 repository includes a small Spring Boot app — a credits account service with three error scenarios:

Scenario Status type suffix
Account not found 404 /account-not-found
Insufficient balance 403 /insufficient-balance
Invalid withdraw amount 400 /validation-error

Seeded accounts: 123 (Ada Lovelace, 30 credits) and 456 (Grace Hopper, 500 credits).

git clone https://github.com/mnafshin/RFC_7807.git
cd RFC_7807
./gradlew bootRun

Trigger an insufficient-balance problem:

curl -s -X POST http://localhost:8080/accounts/123/withdraw \
  -H 'Content-Type: application/json' \
  -d '{"amount": 50}' | jq

Then open Swagger UI and compare the live 403 response to the InsufficientBalanceProblem schema in the spec.

Regenerate server stubs after any spec change:

./gradlew openApiGenerate

Prove it with integration tests

Three layers to lock in CI:

Test What it guards
openApiValidate The SSOT YAML (openapi/openapi.yaml) is well-formed
OpenApiIntegrationTest The spec declares application/problem+json errors
AccountControllerIntegrationTest Runtime responses match those declared shapes

AccountControllerIntegrationTest asserts the live error body:

mockMvc.perform(get("/accounts/999"))
    .andExpect(status().isNotFound())
    .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
    .andExpect(jsonPath("$.type").value(
        "https://problem-detail-demo.mnafshin.info/problems/account-not-found"))
    .andExpect(jsonPath("$.accountId").value("999"));

OpenApiIntegrationTest asserts the committed spec — not a separately generated copy — declares those same errors.

./gradlew test          # runtime + spec contract
./gradlew openApiValidate   # YAML validity

Locking both contracts in CI catches breaking changes before any client does.

Design tips from the spec

Keep title stable, vary detail. "Insufficient balance" is the class; "Account 123 has 30 credits but this operation costs 50" is the instance.

Align body status with the HTTP status. They should always match. The body field is advisory for offline log analysis, not a second authority.

Use extensions for machine-readable extras. Put anything a client might act on — field names, retry-after hints, error codes — in extension properties rather than embedding them in free-text detail.

Do not replace HTTP semantics. A 404 is still a 404. Problem Details add context; they do not turn every failure into 200 OK with an error payload.

Document every error in the SSOT before implementing it. If it is not in openapi.yaml, do not generate client code for it — and do not expect consumers to handle it.

When Problem Details are worth it

Adopt them when:

  • Multiple clients or teams consume your API
  • You publish OpenAPI docs and generate clients from them
  • You operate several microservices and want a shared error contract

Skip them when you own the only client and will never expose the API externally — though even then, a consistent internal format pays off in debugging time.

Further reading


Full source: github.com/mnafshin/RFC_7807