Stop Inventing Error JSON: A Practical Guide to RFC 7807 Problem Details
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:
Use
application/problem+json, notapplication/json, on every error response. Client generators bind responses by media type. If you omit it or default toapplication/json, generated clients will deserialize errors into the wrong type.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.
Put extension fields at the top level in the schema (via
allOf), not nested under apropertiesbag. That matches what RFC 7807 specifies and what Spring'sProblemDetailactually 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.yamldeclares every error response; runopenApiValidatein 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
- RFC 7807 — original specification
- RFC 9457 — current standard
- Spring Framework
ProblemDetailJavadoc - OpenAPI Generator — generate server stubs and client SDKs from the spec
- RFC_7807 on GitHub — companion code and README
Full source: github.com/mnafshin/RFC_7807