Standardizing GraphQL Error Codes: Structured Error Responses
AI generated
{ }
type
GraphQL · Error Handling · API Design · PHP
Standardizing GraphQL Error Codes
a catalog for structured, machine-readable error responses

A message text alone is not enough for a client to translate an error or drive retry logic. Anyone standardizing GraphQL error codes needs a central catalog of consistent extensions.code values that maps domain exceptions unambiguously onto stable, documented error codes.

17 min read extensions.code · Error Catalog · Exception Mapping GraphQL 16 · PHP 8.4 · API Design

1. Why a message text alone is not enough

In many GraphQL APIs, an error response looks like free text: "message": "Product not found", with no further structure. For a human testing the API during development, that is sufficient, for a client in production it is unusable. Anyone standardizing GraphQL error codes quickly realizes that clients must never branch on text content, neither for translation nor for control flow, because wording can change without the underlying cause changing.

The GraphQL spec provides exactly the extensions field on every error object for this purpose, a freely definable area where structured additional information can be attached. In practice, extensions.code has become the de facto standard, a stable, machine-readable string that stays independent of the human-readable message. Without this structured approach, all error handling in the client remains fragile and tied to whatever text the backend currently uses.

2. extensions.code: the machine-readable core of every GraphQL error

A GraphQL error object consists of message, locations, path and optionally extensions. While the first three fields are populated by the GraphQL engine itself, extensions is entirely the application's responsibility. Anyone consistently standardizing GraphQL error codes defines exactly one code for every business and technical error case, such as PRODUCT_NOT_FOUND or INSUFFICIENT_STOCK, in a constant SCREAMING_SNAKE_CASE style, so codes remain unambiguously recognizable across the whole team.

What matters is separating technical standard codes such as UNAUTHENTICATED or BAD_USER_INPUT, which many GraphQL server libraries already predefine, from business codes coming from your own domain. Both categories belong in the same catalog, but with a clear namespace prefix, such as ORDER_ for order errors or PAYMENT_ for payment errors, so name collisions don't occur even as the schema grows.


# GraphQL error response with standardized extensions.code
{
  "errors": [
    {
      "message": "The requested product could not be found.",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["product"],
      "extensions": {
        "code": "PRODUCT_NOT_FOUND",
        "category": "business",
        "productId": "SKU-4821",
        "timestamp": "2026-08-06T10:15:00Z"
      }
    }
  ],
  "data": { "product": null }
}

3. Building a central error code catalog

Without a central registration point, different team members independently invent codes for the same situation, OUT_OF_STOCK in one resolver, INSUFFICIENT_STOCK in another. Anyone wanting to run standardizing GraphQL error codes sustainably maintains a single enum or a single constants class from which all resolvers and services draw their codes. New codes are added to this central file via pull request, subject to review like any other API contract change.

This catalog should additionally document which HTTP status analogue corresponds to each code, whether the error is retryable, and which additional extensions fields it typically carries. This metadata turns the catalog into the single source of truth both for backend developers throwing new errors and for frontend developers who need to handle error cases in the UI.


<?php

declare(strict_types=1);

namespace App\GraphQL\Error;

/**
 * Central registry of all GraphQL error codes used across the schema.
 * Every code must be documented here before it is thrown anywhere.
 */
enum ErrorCode: string
{
    case ProductNotFound = 'PRODUCT_NOT_FOUND';
    case InsufficientStock = 'INSUFFICIENT_STOCK';
    case OrderAlreadyShipped = 'ORDER_ALREADY_SHIPPED';
    case PaymentDeclined = 'PAYMENT_DECLINED';
    case Unauthenticated = 'UNAUTHENTICATED';
    case Forbidden = 'FORBIDDEN';
    case BadUserInput = 'BAD_USER_INPUT';
    case InternalError = 'INTERNAL_ERROR';

    /**
     * Returns whether a client may safely retry the operation
     * that produced this error code.
     *
     * @return bool
     */
    public function isRetryable(): bool
    {
        return match ($this) {
            self::InternalError => true,
            default => false,
        };
    }
}

4. Mapping domain exceptions to error codes

Resolvers should never construct GraphQL error objects directly. Instead, the domain layer throws its own, business-named exceptions, ProductNotFoundException, InsufficientStockException, independent of GraphQL. A central error mapping layer then translates these domain exceptions into the matching extensions.code values. This separation allows the same domain logic to later be exposed over REST or a CLI too, without maintaining GraphQL specific code twice.

Anyone consistently standardizing GraphQL error codes implements this translation as a standalone formatter that runs once per request and maps every thrown exception to an error code based on its type. Unknown, unmapped exceptions never leak their internal message to the client, they are generically masked as INTERNAL_ERROR, while the original message is only logged server-side.


<?php

declare(strict_types=1);

namespace App\GraphQL\Error;

use App\Domain\Exception\InsufficientStockException;
use App\Domain\Exception\ProductNotFoundException;
use GraphQL\Error\ClientAware;
use GraphQL\Error\Error;
use Psr\Log\LoggerInterface;
use Throwable;

/**
 * Translates domain exceptions into GraphQL errors carrying a
 * standardized extensions.code, so resolvers never build error
 * objects themselves.
 */
final class DomainErrorFormatter
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Maps a caught throwable to a GraphQL error with a stable code.
     *
     * @param Throwable $exception The exception thrown by domain code
     * @return Error GraphQL error carrying extensions.code
     */
    public function format(Throwable $exception): Error
    {
        $code = match (true) {
            $exception instanceof ProductNotFoundException => ErrorCode::ProductNotFound,
            $exception instanceof InsufficientStockException => ErrorCode::InsufficientStock,
            default => null,
        };

        if ($code === null) {
            // Unknown exceptions never leak their internal message
            $this->logger->error('Unmapped exception', ['exception' => $exception]);

            return new Error('Internal server error.', null, null, [], null, null, [
                'code' => ErrorCode::InternalError->value,
            ]);
        }

        return new Error($exception->getMessage(), null, null, [], null, $exception, [
            'code' => $code->value,
            'category' => 'business',
        ]);
    }
}

5. Error categories: validation, auth, business, system

A flat catalog of fifty individual codes becomes unmanageable once a client has to decide how to generally react to an unknown code. That is why extensions.code is complemented by extensions.category with a fixed set of values: validation for malformed input, authentication and authorization for access issues, business for domain rule violations such as insufficient stock, and system for unexpected technical errors.

This categorization lets clients react sensibly even to new codes they do not yet know: an error in the validation category can be shown generically on a form field, an error in the system category triggers a blanket retry or an error page, regardless of the specific code. Anyone building standardizing GraphQL error codes this way makes the error system robust against future extensions, without clients needing an update for every new code.

6. Error formatting: from exception to GraphQL response

Most GraphQL server implementations offer a central error formatting hook through which every error passes before being delivered to the client, regardless of which resolver produced it. That, and not scattered across individual resolvers, is exactly where the mapping logic shown in section 4 belongs. This centralization ensures no resolver accidentally leaks a raw error with stack trace information to the client.

An additional aspect of formatting is environment dependence: in development, it makes sense to attach extra debug information such as stack traces in extensions.debugMessage, in production these fields must be removed entirely. Anyone running standardizing GraphQL error codes should control this switch through a single configuration flag, never through conditional code scattered across multiple formatters.

7. Internationalization: stable codes, translated messages

A common mistake is displaying the GraphQL error's message directly in the UI. That works as long as the API only serves one language, but breaks with any internationalization effort. The correct separation: extensions.code stays language-neutral and stable, translating the displayed text happens entirely in the client based on the code, usually through a simple lookup table per supported language.

This separation has an additional benefit for standardizing GraphQL error codes: the backend team can adjust wording anytime without forcing client releases, as long as the code stays stable. Conversely, the frontend team can write its own, more user-friendly text, tailored to the specific UI situation, better than any generic backend message ever could be.

8. Client-side error handling driven by error codes

On the client side, a central error handler pays off, one that scans every GraphQL response's errors array and branches into the right handling based on extensions.code, instead of every individual component implementing its own error logic. Apollo Client and urql offer error link and exchange mechanisms respectively for exactly this, running centrally before the actual component.

A robust client distinguishes at least three reactions: automatic retry for retryable codes such as transient system errors, redirect to login on UNAUTHENTICATED, and inline display of the translated error message for all remaining codes. Anyone consistently applying standardizing GraphQL error codes on the client side too drastically reduces the amount of special-case handling scattered across individual UI components.


// Central GraphQL error link: routes errors by extensions.code
// instead of letting every component handle errors individually
import { onError } from "@apollo/client/link/error";

const errorLink = onError(({ graphQLErrors }) => {
  graphQLErrors?.forEach((error) => {
    const code = error.extensions?.code as string | undefined;

    switch (code) {
      case "UNAUTHENTICATED":
        redirectToLogin();
        break;
      case "INTERNAL_ERROR":
        scheduleRetry(error);
        break;
      default:
        // Unknown or business codes fall back to inline display,
        // the message shown is looked up by code, not error.message
        showTranslatedError(code ?? "UNKNOWN_ERROR");
    }
  });
});

9. Error approaches head to head

Different maturity levels of error handling lead to very different client robustness. The table below compares common approaches.

Approach Client evaluation i18n-capable Maintainability
Message text only Fragile, text dependent No Low
Scattered ad hoc codes Possible but inconsistent Partial Medium
Central catalog + mapping Reliable Yes High
Catalog + categories Robust against new codes Yes Very high

Investing in a central catalog with categories pays off at the latest once multiple frontend teams work against the same schema and every team makes its own assumptions about error formats. A documented, versioned catalog prevents these assumptions from drifting apart.

Mironsoft

GraphQL API design, error handling and Magento integration

A consistent error format for your GraphQL schema?

We build a central error code catalog with you, a clean domain exception mapping, and client-side error handling that stays predictable even as your schema grows.

Error catalog

Central registration of all extensions.code values with categories and retry metadata

Exception mapping

Cleanly mapping domain exceptions to stable GraphQL error codes

Client integration

Central error links and translated error display based on error codes

10. Summary

Anyone standardizing GraphQL error codes consistently separates the human-readable message text from a stable, machine-readable extensions.code. A central catalog, maintained as an enum or a constants class, prevents different team members from independently inventing codes for the same situation. Domain exceptions are mapped to these codes through a central formatting layer, never directly in the resolver.

Categories such as validation, business and system make the error system robust against future extensions, because clients can react generically even to codes they do not yet know. Internationalization only works if codes stay stable and messages are translated in the client, never the other way around. This structure drastically reduces special-case handling in the UI and makes error behavior predictable across the entire system.

Standardizing GraphQL Error Codes — The Essentials at a Glance

extensions.code

A stable, language-neutral code per error case, independent of the human-readable message.

Central catalog

An enum or a constants class as the single source of truth for all codes across the team.

Exception mapping

A central formatting layer translates domain exceptions into error codes, never the resolver itself.

Categories

validation, authentication, business, system allow generic handling even of unknown codes.

11. FAQ: Standardizing GraphQL Error Codes

1Why isn't message alone enough?
Wording can change without the cause changing. A stable code is the only reliable basis.
2What belongs in extensions.code?
A stable, language-neutral string in SCREAMING_SNAKE_CASE that uniquely identifies the error case.
3Avoiding duplicate codes?
A central catalog as an enum or constants class serves as the single source of truth, new codes via pull request.
4Do resolvers build error objects?
No, resolvers throw domain exceptions, a central formatting layer translates them into GraphQL errors.
5Handling unknown exceptions?
Generically masked as INTERNAL_ERROR, internal message only logged server-side, never to the client.
6What is extensions.category for?
Allows generic handling even of unknown, future codes based on their category.
7Internationalizing error messages?
Code stays stable, translation happens entirely in the client based on the code.
8Debug info like stack traces?
Only in development behind a central flag, remove entirely in production.
9Client reaction to error codes?
Central error handler branching by code: retry, redirect, or translated inline display.
10When does the catalog pay off?
At the latest once multiple frontend teams work against the same schema and would make diverging assumptions.