Modeling Them Correctly in OpenAPI
Error modeling is the neglected part of API design. RFC 9457 Problem Details, domain-specific error codes and polymorphic error schemas make the difference between an API where integrators have to guess what an error means and an API where errors are self-explanatory and machine-processable.
Table of Contents
- 1. Why error modeling fails so often
- 2. RFC 9457 Problem Details: the standard for HTTP errors
- 3. Business errors: designing domain-specific error codes correctly
- 4. Validation errors: field information and multiple violations
- 5. Polymorphic error schemas with oneOf and discriminator
- 6. Implementation in Symfony: ExceptionListener and Normalizer
- 7. Fully documenting error schemas in OpenAPI
- 8. Comparing error model approaches
- 9. Summary
- 10. FAQ
1. Why error modeling fails so often
In most REST APIs, error modeling is treated as an afterthought. The happy-path responses are carefully modeled, documented in OpenAPI and equipped with examples. The error responses, on the other hand, are a mix of randomly chosen HTTP status codes, different payload structures depending on the endpoint, and error messages that were clearly written for developers on the internal team, not for external integrators. The result: integrators cannot implement error handling reliably because they do not know which error structure to expect, and whether the structure for a 400 at endpoint A is the same as at endpoint B.
The concrete problem: an API returns {"error": "invalid input"} for a validation error, {"message": "Insufficient stock", "code": 1042} for a business error, and nothing but HTTP 500 for a server error. A client that is supposed to react correctly to all three error types has to implement three different response parsers, and hopes there are no further formats. The solution is not a complicated schema system but a consistently applied standard: RFC 9457 Problem Details for HTTP APIs with sensible extensions for domain-specific error codes.
2. RFC 9457 Problem Details: the standard for HTTP errors
RFC 9457 (formerly RFC 7807) defines a standardized JSON response body for HTTP errors. The problem details object has five defined fields: type (URI identifying the error type), title (short, human-readable summary), status (HTTP status code), detail (detailed, human-readable description of the specific error), and instance (URI pointing to the specific problem instance, e.g. a support ticket ID). The Content-Type header for problem details responses is application/problem+json.
What makes RFC 9457 so practical: the object is extensible. You can add your own fields without violating the standard. Validation errors can add a violations array. Business errors can add an errorCode and a retryable flag. The type URI does not need to be reachable (it serves as a unique identifier), but it is good practice to link it to a documentation page that describes the error type in detail. That way a client developer who encounters an unknown error type can go straight to the documentation.
// RFC 9457 Problem Details, base structure
// Content-Type: application/problem+json
// Simple business error (HTTP 409 Conflict)
{
"type": "https://mironsoft.de/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Item PRD-042 only has 3 units left in stock, 10 were requested.",
"instance": "/api/v2/orders/ORD-2026-0042",
"errorCode": "INSUFFICIENT_STOCK",
"sku": "PRD-042",
"available": 3,
"requested": 10,
"retryable": false
}
// Validation error (HTTP 422 Unprocessable Entity)
{
"type": "https://mironsoft.de/errors/validation-failed",
"title": "Validation error",
"status": 422,
"detail": "The request contains 2 validation errors.",
"violations": [
{
"field": "customerEmail",
"message": "Must be a valid email address.",
"rejectedValue": "not-an-email"
},
{
"field": "items[0].quantity",
"message": "Must be greater than 0.",
"rejectedValue": 0
}
]
}
3. Business errors: designing domain-specific error codes correctly
Business errors are errors that do not describe technical problems but violations of domain rules. "Insufficient stock", "Account suspended", "Order already shipped" are business errors, they happen when a technically valid request fails against a business rule. These errors are fundamentally different from validation errors (incorrectly formatted input) and technical errors (database outage). They need their own HTTP status code (typically 409 Conflict or 422 Unprocessable Entity) and domain-specific information in the response body so the client can react sensibly.
The choice of error code naming matters for integrators. Codes in SCREAMING_SNAKE_CASE (INSUFFICIENT_STOCK, ACCOUNT_SUSPENDED) are readable and sortable. Numeric codes (1042) are shorter but not understandable without a lookup. URIs (https://mironsoft.de/errors/insufficient-stock) are self-documenting and can link to a documentation page. RFC 9457 recommends URIs for the type field, you can additionally add a human-readable code in a dedicated field. Integrators who match on an error code string have more robust code than those who match on HTTP status codes or error message strings, because status codes can be ambiguous and messages change with localization.
4. Validation errors: field information and multiple violations
Validation errors are the most common type of error in REST APIs. What integrators need is not "validation error" as an error message, but precise information about which field received which value with which problem. This is especially important for forms and bulk operations: the client needs to be able to tell the user or its own system which specific fields need to be corrected.
The recommended pattern: a violations array as an RFC 9457 extension that contains, per entry, field (JSONPath notation for nested fields), message (human-readable error description), code (machine-processable error code) and optionally rejectedValue. In Symfony you implement this with a dedicated ConstraintViolationNormalizer that converts Symfony validation errors into this format. Important: return all validation errors for a request at once, not just the first one. A client that has to correct its request three times because the API only ever reports one error at a time is inefficient and frustrating.
<?php
// Symfony ExceptionListener: convert business errors to RFC 9457 Problem Details
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Exception\BusinessException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Validator\ConstraintViolationListInterface;
/**
* Converts domain exceptions and validation errors to RFC 9457 Problem Details.
*/
final class ApiExceptionSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => ['onKernelException', 10]];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof BusinessException) {
$event->setResponse($this->buildBusinessErrorResponse($exception));
return;
}
}
private function buildBusinessErrorResponse(BusinessException $ex): JsonResponse
{
$payload = [
'type' => 'https://mironsoft.de/errors/' . $ex->getErrorSlug(),
'title' => $ex->getTitle(),
'status' => $ex->getStatusCode(),
'detail' => $ex->getMessage(),
'errorCode' => $ex->getErrorCode(),
'retryable' => $ex->isRetryable(),
];
// Merge domain-specific context fields (e.g. sku, available, requested)
$payload = array_merge($payload, $ex->getContext());
return new JsonResponse($payload, $ex->getStatusCode(), [
'Content-Type' => 'application/problem+json',
]);
}
/**
* Converts Symfony ConstraintViolationList to RFC 9457 + violations extension.
*/
public function buildValidationErrorResponse(
ConstraintViolationListInterface $violations,
): JsonResponse {
$violationList = [];
foreach ($violations as $violation) {
$violationList[] = [
'field' => $violation->getPropertyPath(),
'message' => $violation->getMessage(),
'code' => $violation->getCode(),
'rejectedValue' => $violation->getInvalidValue(),
];
}
return new JsonResponse([
'type' => 'https://mironsoft.de/errors/validation-failed',
'title' => 'Validation error',
'status' => 422,
'detail' => sprintf('The request contains %d validation errors.', count($violationList)),
'violations' => $violationList,
], 422, ['Content-Type' => 'application/problem+json']);
}
}
5. Polymorphic error schemas with oneOf and discriminator
A REST API typically has several error types with different structures: validation errors with a violations array, business errors with domain-specific fields, technical errors without extra fields. How do you model this in OpenAPI when different endpoints can return different error structures? The answer in OpenAPI 3.1: polymorphic schemas with oneOf and discriminator.
The base schema ProblemDetail contains the common RFC 9457 fields (type, title, status, detail). Schemas derived from it (ValidationError, BusinessError, InsufficientStockError) use allOf with the base schema and add their specific fields. In the endpoint response schema you reference all possible error types with oneOf and define a discriminator on the type field, so OpenAPI tools and code generators can select the correct implementation for each error type. This enables type-safe client generation from the OpenAPI schema.
# OpenAPI 3.1: polymorphic error schemas with oneOf and discriminator
components:
schemas:
ProblemDetail:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
example: "https://mironsoft.de/errors/insufficient-stock"
title:
type: string
example: "Insufficient stock"
status:
type: integer
example: 409
detail:
type: string
instance:
type: string
format: uri
ValidationError:
allOf:
- $ref: '#/components/schemas/ProblemDetail'
- type: object
properties:
violations:
type: array
items:
type: object
required: [field, message]
properties:
field: { type: string }
message: { type: string }
code: { type: string }
rejectedValue: {}
InsufficientStockError:
allOf:
- $ref: '#/components/schemas/ProblemDetail'
- type: object
properties:
errorCode: { type: string, example: INSUFFICIENT_STOCK }
sku: { type: string }
available: { type: integer }
requested: { type: integer }
retryable: { type: boolean, example: false }
responses:
OrderCreateErrors:
description: Errors when creating an order
content:
application/problem+json:
schema:
oneOf:
- $ref: '#/components/schemas/ValidationError'
- $ref: '#/components/schemas/InsufficientStockError'
discriminator:
propertyName: type
mapping:
"https://mironsoft.de/errors/validation-failed": '#/components/schemas/ValidationError'
"https://mironsoft.de/errors/insufficient-stock": '#/components/schemas/InsufficientStockError'
6. Implementation in Symfony: ExceptionListener and Normalizer
In Symfony, the best way to build RFC 9457 error conversion is as an event subscriber on the KernelEvents::EXCEPTION event. For each domain exception, you define a dedicated exception class that extends an abstract BusinessException and has its specific fields (e.g. sku, available, requested for InsufficientStockException) as constructor parameters. The subscriber converts the exception into a JsonResponse with the correct Content-Type: application/problem+json.
For Symfony validation errors (constraint violations from the Validator component) you implement a dedicated ConstraintViolationListNormalizer that produces the RFC 9457 violations format. This normalizer is invoked via the kernel.normalizer tag or directly in the controller. With the api_platform bundle much of this is already preconfigured, but adapting it to your own error format with custom business error types and the type URI scheme requires custom configuration that is not always obvious. The core rule: every domain exception needs a unique slug that feeds into the type URI and is referenced in the documentation.
7. Fully documenting error schemas in OpenAPI
Documenting error responses in OpenAPI means more than just listing the status code. For every error response you need to specify the schema, a concrete example and a description that explains when and why this error can occur. Integrators who only see the status code cannot implement robust error handling, they need to know which fields are present in the error body.
The following pattern is recommended in practice: define all reusable error schemas as components/schemas. Define reusable error responses as components/responses (e.g. UnauthorizedError, ValidationError). In each endpoint, reference the specific error responses with $ref, and only define endpoint-specific business errors (like InsufficientStockError) directly on the endpoint. This avoids duplicate code in the schema, keeps the documentation consistent, and lets code generators produce type-safe client classes for every error type.
8. Comparing error model approaches
Different error modeling approaches have different consequences for the usability of the API, the development speed of integrators and the maintainability of the schema.
| Approach | Example | Advantages | Disadvantages |
|---|---|---|---|
| No standard | {"error": "…"} |
Simple | Inconsistent, hard to parse |
| HTTP status only | 422 with no body | No schema needed | No error details for integrators |
| RFC 9457 baseline | type, title, status |
Standardized, tooling-compatible | Needs extensions for business errors |
| RFC 9457 + extensions | +violations, +errorCode |
Complete, type-safe, documentable | More implementation effort upfront |
The initial extra effort for RFC 9457 compliant error modeling pays off quickly: integrators need less support, error handling code is more robust and maintainable, OpenAPI tools can generate type-safe client libraries, and the error schema stays consistent across all endpoints. Teams that switch from an inconsistent error format to RFC 9457 regularly report that integration support requests about error handling drop significantly after the switch.
9. Summary
Error modeling in REST APIs is not an afterthought, it is a central quality characteristic that directly influences how robustly integrators can implement error handling. RFC 9457 Problem Details provides the foundation: type as a unique URI, title as a short summary, status as the HTTP status code, detail as concrete error context. Extensions for validation errors (violations array) and business errors (domain-specific fields like sku, available, retryable) make the model complete.
Polymorphic error schemas with oneOf and discriminator in OpenAPI enable type-safe client generation. Return all validation errors at once instead of iteratively. In Symfony, a KernelEvents::EXCEPTION subscriber converts domain exceptions into RFC 9457 compliant responses with the correct Content-Type: application/problem+json. Every business error type gets a unique slug that is referenced in the type URI and in the documentation, so integrators can handle error types stably and without string matching on error messages.
Error Payloads and Business Errors, the essentials at a glance
RFC 9457 Problem Details
type (URI), title, status, detail as the base. Content-Type: application/problem+json. Extensible for domain-specific fields. Standard for all HTTP errors.
Business errors
Dedicated exception classes with slug, errorCode, retryable flag and context fields. HTTP 409 for conflict, 422 for business validation. URI as the error identifier.
Validation errors
violations array with field (JSONPath), message, code, rejectedValue. Return all violations at once. Adapt Symfony ConstraintViolationListNormalizer.
OpenAPI schema
Polymorphic schemas with oneOf + discriminator. ProblemDetail as the base, specific error types with allOf. Document all error responses fully with examples.