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:
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
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.TooLongandStreetValidationError.Invalidboth flatten toINVALID. Clients cannot distinguish them without parsingmessage. - 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
switchoncodegives compile-time safety. - Direct form mapping:
codeidentifies the error,fieldidentifies 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
__typenameswitching is the main benefit of unions. Per-mutation enums achieve exhaustiveness at thecodelevel 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
codefor each mutation's error type. - Domain error specificity is preserved end-to-end:
Core.StreetValidationError.TooLong→STREET_TOO_LONGin the schema. - Clients own their own i18n messages keyed by
code, independent of themessagefield.
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.*ValidationErrorconst 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.