Modeling Error Payloads and Business Errors in OpenAPI Correctly
AI generated
{ }
GET
REST API · OpenAPI · RFC 9457 · Business Errors · Error Modeling
Error Payloads and Business Errors
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.

20 min read RFC 9457 · Problem Details · Business Errors · Validation Errors · OpenAPI REST · Symfony · PHP 8.4

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.

10. FAQ: Error Payloads and Business Errors in OpenAPI

1What is RFC 9457 Problem Details?
Standardized JSON response body for HTTP errors with type (URI), title, status, detail, instance. Content-Type: application/problem+json. Extensible for domain-specific fields.
2Business error vs. validation error?
Validation error: input formatted incorrectly. Business error: input correct, fails against a domain rule (Insufficient Stock, Account Suspended). Both need their own error structures.
3Which HTTP status code for business errors?
409 Conflict for state conflicts. 422 Unprocessable Entity for business validation. 402 Payment Required for payment errors. Status code must reflect the business situation.
4What is oneOf with discriminator?
oneOf allows several possible schema variants. discriminator specifies which field (e.g. type) is used to distinguish them. Enables type-safe client generation.
5Why is application/problem+json important?
Signals an RFC 9457 error document. Clients can recognize this Content-Type and activate their problem detail parser instead of processing normal JSON.
6How many validation errors to return at once?
Always all of them at once. Iterative error reporting is inefficient and frustrating. The violations array in the RFC 9457 format is designed for exactly this.
7How do you implement RFC 9457 in Symfony?
EventSubscriber on KernelEvents::EXCEPTION. Domain exceptions become a JsonResponse with Content-Type application/problem+json. ConstraintViolationList via a dedicated normalizer into the violations format.
8Does the type URI need to be reachable?
No, it serves as a unique identifier. Recommended: link it to a documentation page describing the error type, its causes and recommended responses.
9What does retryable mean in the error payload?
Boolean flag: retryable: true for temporary errors (rate limit). retryable: false for permanent errors (Insufficient Stock, Invalid Input). Controls automatic retry behavior in the client.
10How do you fully document error schemas in OpenAPI?
ProblemDetail as the base in components/schemas. Specific types with allOf. Reusable responses in components/responses. Concrete examples. Reference all error responses per endpoint.