# 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](https://www.rfc-editor.org/rfc/rfc7807.html) 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](https://www.rfc-editor.org/rfc/rfc9457.html) 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](https://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:

```bash
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
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`:

```javascript
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:

```properties
spring.mvc.problemdetails.enabled=true
```

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

```java
@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:

```java
@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`](https://github.com/mnafshin/RFC_7807/blob/main/src/main/java/info/mnafshin/problem_detail_demo/web/GlobalExceptionHandler.java) and [`application.properties`](https://github.com/mnafshin/RFC_7807/blob/main/src/main/resources/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`:

```yaml
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:

```yaml
/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`](https://github.com/mnafshin/RFC_7807/blob/main/openapi/openapi.yaml).

### Generate server code from the spec

Use the [OpenAPI Generator](https://openapi-generator.tech/) Gradle plugin to produce a Spring controller interface and request/response models. The full [`build.gradle`](https://github.com/mnafshin/RFC_7807/blob/main/build.gradle) is in the repo; the essentials:

```gradle
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`](https://github.com/mnafshin/RFC_7807/blob/main/src/main/java/info/mnafshin/problem_detail_demo/account/AccountController.java) implements it:

```java
@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:

```gradle
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`:

```java
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

```bash
./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:

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

Open [http://localhost:8080/swagger-ui.html](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](https://github.com/mnafshin/RFC_7807) 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).

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

Trigger an insufficient-balance problem:

```bash
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:

```bash
./gradlew openApiGenerate
```

## Prove it with integration tests

Three layers to lock in CI:

| Test | What it guards |
|------|----------------|
| `openApiValidate` | The SSOT YAML ([`openapi/openapi.yaml`](https://github.com/mnafshin/RFC_7807/blob/main/openapi/openapi.yaml)) is well-formed |
| [`OpenApiIntegrationTest`](https://github.com/mnafshin/RFC_7807/blob/main/src/test/java/info/mnafshin/problem_detail_demo/openapi/OpenApiIntegrationTest.java) | The spec declares `application/problem+json` errors |
| [`AccountControllerIntegrationTest`](https://github.com/mnafshin/RFC_7807/blob/main/src/test/java/info/mnafshin/problem_detail_demo/account/AccountControllerIntegrationTest.java) | Runtime responses match those declared shapes |

`AccountControllerIntegrationTest` asserts the live error body:

```java
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.

```bash
./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](https://www.rfc-editor.org/rfc/rfc7807.html) — original specification
- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) — current standard
- [Spring Framework `ProblemDetail` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ProblemDetail.html)
- [OpenAPI Generator](https://openapi-generator.tech/) — generate server stubs and client SDKs from the spec
- [RFC_7807 on GitHub](https://github.com/mnafshin/RFC_7807) — companion code and [README](https://github.com/mnafshin/RFC_7807/blob/main/README.md)

---

*Full source: [github.com/mnafshin/RFC_7807](https://github.com/mnafshin/RFC_7807)*

