Skip to content

ADR-004: GraphQL Mutation Error Handling

Status

Accepted

Context

Our GraphQL API is internal: all consumers are applications we control (web, native). The primary requirement from consuming teams is:

Know exactly which input errors a mutation can return, so they can map errors to their own form system and i18n messages.

Our domain layer already models errors with high specificity using typed const maps (e.g., StreetValidationError.TooLong, FirstNameValidationError.Immutable, EmailValidationError.Taken). The question is how to expose that specificity at the GraphQL schema boundary.

Decision

Use per-mutation error code enums. Each mutation defines its own error type and code enum that maps to the domain's validation error values.

Convention

Every mutation follows this structure:

graphql
type <Mutation>Payload {
    <result>: <ResultType>
    userErrors: [<Mutation>Error!]!
}

type <Mutation>Error {
    code: <Mutation>ErrorCode!
    field: [String!]!
    message: String
}

enum <Mutation>ErrorCode {
    <FIELD>_<REASON>
    ...
}

Enum values are named <FIELD>_<REASON> (e.g., STREET_TOO_LONG, EMAIL_TAKEN, FIRST_NAME_IMMUTABLE). Generic codes like INVALID that apply to many fields are not used.

The message field is a developer-facing hint. Clients key their i18n messages on code and are not expected to display message to end users.

Example

graphql
enum UserAddressUpdateErrorCode {
	STREET_INVALID
	STREET_TOO_LONG
	STREET_TOO_SHORT
	HOUSE_NUMBER_INVALID
	HOUSE_NUMBER_TOO_LONG
	HOUSE_NUMBER_SUFFIX_INVALID
	HOUSE_NUMBER_SUFFIX_TOO_LONG
	CITY_INVALID
	CITY_TOO_LONG
	CITY_TOO_SHORT
	COUNTRY_CODE_INVALID
	POSTAL_CODE_INVALID
	POSTAL_CODE_TOO_LONG
	POSTAL_CODE_TOO_SHORT
}

type UserAddressUpdateError {
	code: UserAddressUpdateErrorCode!
	field: [String!]!
	message: String
}

type UserAddressUpdatePayload {
	user: User
	userErrors: [UserAddressUpdateError!]!
}

Options Considered

Option A: Shared error type with generic codes (rejected)

A single UserErrorCode enum shared across all mutations with values like INVALID, TOO_LONG, TAKEN.

Rejected because:

  • Information loss: StreetValidationError.TooLong and StreetValidationError.Invalid both flatten to INVALID. Clients cannot distinguish them without parsing message.
  • No per-mutation contract: clients cannot know from the schema which codes a specific mutation can return.
  • No codegen exhaustiveness: TypeScript codegen produces a union of all possible codes for every mutation, not just the ones that apply.

Option B: Per-mutation error code enums (chosen)

Each mutation defines its own enum that mirrors the domain validation errors as much as possible.

Chosen because:

  • Schema is the contract: clients introspect the enum and see the exhaustive list of errors for that mutation.
  • Codegen exhaustiveness: a switch on code gives compile-time safety.
  • Direct form mapping: code identifies the error, field identifies the form field.
  • Low query verbosity: clients query userErrors { code field message } without inline fragments.

Option C: Union-based result types (not adopted as default)

Each mutation returns a union (MutationSuccess | MutationValidationErrors) instead of a payload with userErrors.

Not adopted as default because:

  • All our current errors are structurally identical ({code, field, message}). Unions add query verbosity (inline fragments) and schema complexity (3N types vs N enums) without adding type-safety value when every union member has the same shape.
  • Forced handling via __typename switching is the main benefit of unions. Per-mutation enums achieve exhaustiveness at the code level through codegen, which is sufficient for our use case.

Unions remain appropriate for individual mutations where error cases have genuinely different shapes (e.g., a RateLimitExceeded error that carries retryAfterSeconds). This is a per-mutation decision.

Consequences

Positive

  • Clients have a complete, machine-readable error contract per mutation directly from the schema.
  • TypeScript codegen produces exhaustive discriminated unions on code for each mutation's error type.
  • Domain error specificity is preserved end-to-end: Core.StreetValidationError.TooLongSTREET_TOO_LONG in the schema.
  • Clients own their own i18n messages keyed by code, independent of the message field.

Negative

  • Schema verbosity increases: each mutation requires its own enum and error type.
  • Adding a new validation rule requires adding an enum value to the schema and regenerating types before clients can handle it.

Risks

  • Enum proliferation: mitigated by the fact that domain validation errors are already enumerated in Core.*ValidationError const maps. The GraphQL enum is a projection of what already exists.
  • Union use creeping in inconsistently: mitigated by this ADR establishing per-mutation enums as the default, with unions as an explicit opt-in for structurally divergent cases.