The Input / Payload Pattern
The mutations in the last lesson took loose arguments and returned a bare object. That is fine for two fields. But real mutations grow: you add an optional field, then another, then a validation rule that can fail for three different reasons. Two conventions keep that growth under control — a single input argument going in, and a structured payload type coming out. Together they are the most widely adopted shape in production GraphQL.
The single input argument
Section titled “The single input argument”Instead of spreading every field across the argument list, define one input object type and accept it as a single argument named input:
input CreateReviewInput { trackId: ID! rating: Int! body: String}
type Mutation { createReview(input: CreateReviewInput!): CreateReviewPayload!}An input type looks like an object type but uses the input keyword, and its fields may only be scalars, enums, or other input types — never object types. The payoff is evolution: adding an optional field to CreateReviewInput does not change the mutation’s signature, so existing clients keep working untouched. It also gives the whole set of inputs a name you can reference, document, and reuse.
The payload return type
Section titled “The payload return type”A bare Review! return type has no room for anything except the review. But mutations need to report two things: the entity that was created or changed, and whether anything went wrong in a way the client can act on. The payload type carries both:
type UserError { message: String! field: [String!]}
type CreateReviewPayload { review: Review userErrors: [UserError!]!}Two design choices matter here. review is nullable — on failure there is nothing to return, so it comes back null. And userErrors is a non-null list of non-null errors — always present, just empty ([]) when everything succeeded. The client’s logic becomes uniform: check userErrors first; if it is empty, trust review.
flowchart LR
In["CreateReviewInput!"] --> Mut["createReview"]
Mut --> Valid{"valid?"}
Valid -->|"yes"| OK["payload: review set, userErrors []"]
Valid -->|"no"| Err["payload: review null, userErrors [...]"] Why this scales
Section titled “Why this scales”These userErrors are expected, recoverable problems — a rating out of range, a missing title — the kind a user can fix and retry. They live in the payload, as data, so the client can render them next to the offending form field. That is different from protocol-level errors (a malformed query, an auth failure), which belong in the top-level errors array. The next lesson on best practices draws that line in detail; for now the rule is: business validation goes in the payload.
The pattern, run for real
Section titled “The pattern, run for real”The example implements createReview(input: CreateReviewInput!): CreateReviewPayload! with real validation. Press Run: the first call sends an invalid rating and gets back a populated userErrors with a null review; the second sends valid data and gets the review with an empty userErrors.
Read both results side by side. The invalid call returns review: null and a userErrors entry pointing at the rating field — and crucially, the top-level errors array is absent, because nothing went wrong with the request; the user simply sent bad data. The valid call returns the review and an empty userErrors: []. A client can branch on a single rule — “is userErrors empty?” — regardless of which mutation it called.