GraphQL Mutation Input and Response Design

Well-designed mutation inputs and responses make an API easy to use and robust to change. Poorly designed ones force clients to make guesses about what went wrong or what changed. This topic covers the patterns that production GraphQL APIs use.

Always Use an Input Type for Mutations

Passing many individual arguments directly to a mutation works, but it becomes hard to manage when the number of fields grows. Using an input type groups arguments cleanly and makes the schema easier to read and extend.

  ✗ Loose arguments — hard to extend later:
  ──────────────────────────────────────────
  type Mutation {
    registerUser(
      name:     String!
      email:    String!
      password: String!
      phone:    String
      country:  String!
    ): User!
  }

  ✓ Input type — clean and extensible:
  ─────────────────────────────────────
  input RegisterUserInput {
    name:     String!
    email:    String!
    password: String!
    phone:    String
    country:  String!
  }

  type Mutation {
    registerUser(input: RegisterUserInput!): User!
  }

The Payload Pattern for Responses

Instead of returning the raw object, return a payload wrapper type. The payload carries the result, any errors, and extra metadata. This pattern is used by GitHub's GraphQL API and many large APIs.

  Without payload:              With payload:
  ────────────────              ─────────────
  registerUser: User!           registerUser: RegisterUserPayload!

                                type RegisterUserPayload {
                                  user:    User
                                  errors:  [UserError!]!
                                  success: Boolean!
                                }

UserError Type for Business Logic Errors

There are two kinds of errors in GraphQL. System errors (server crashed, network failed) go in the top-level errors array. Business logic errors (email already taken, password too short) should travel inside the payload as structured data, not as system errors.

  type UserError {
    field:   String    ← Which field caused the problem
    message: String!   ← Human-readable explanation
  }

  type RegisterUserPayload {
    user:   User
    errors: [UserError!]!
  }

  type Mutation {
    registerUser(input: RegisterUserInput!): RegisterUserPayload!
  }

  Successful mutation response:
  ──────────────────────────────
  {
    "data": {
      "registerUser": {
        "user":   { "id": "u9", "name": "Leela" },
        "errors": []
      }
    }
  }

  Failed mutation (email taken):
  ───────────────────────────────
  {
    "data": {
      "registerUser": {
        "user":  null,
        "errors": [
          {
            "field":   "email",
            "message": "This email address is already registered"
          }
        ]
      }
    }
  }

Why Business Errors Belong in the Payload

System errors                    Business errors
─────────────                    ───────────────
Server crash                     Email already taken
Database unavailable             Password too short
Network timeout                  Coupon code expired
Unhandled exception              Insufficient fundsGo in:  top-level "errors"       Go in:  payload.errors
Status: HTTP 500 / GraphQL err   Status: HTTP 200, data returns
Client: catch/error handler      Client: reads payload.errors

Leave a Comment