Skip to content

Designing Mutations

A query field answers “what is the value of this?” A mutation field answers a very different question: “what should the server do?” That difference should be visible in the name. The most common mistake in mutation design is treating writes like database rows — a single updateUser that can change anything — instead of like actions a user actually takes. This lesson is about designing mutations around intent.

A good mutation name reads like a command. It starts with a verb and names the thing it acts on:

type Mutation {
createReview(trackId: ID!, rating: Int!, body: String): Review!
publishReview(reviewId: ID!): Review!
deleteReview(reviewId: ID!): ID!
}

Compare that with a CRUD-shaped design that leans on one giant updater:

type Mutation {
# Anti-pattern: one mutation that can change anything, meaning nothing.
updateReview(reviewId: ID!, rating: Int, body: String, published: Boolean): Review!
}

The CRUD version works, but it loses information. When a client calls updateReview with published: true, the server cannot tell “the author edited the text” apart from “the author hit Publish.” Those are different events with different rules, side effects, and audit meaning. Naming by intentpublishReview — restores that meaning, so the server can run exactly the logic that action requires.

A single mutation should represent one indivisible thing the user did. If the client has to call three mutations in a row to complete one action, the API is leaking workflow onto the client — and any failure in the middle leaves the data half-changed.

flowchart TD
  U1["User posts a review"] --> M1["createReview"]
  U2["User hits Publish"] --> M2["publishReview"]
  U3["User removes a review"] --> M3["deleteReview"]
  M1 --> C1["one write: insert review"]
  M2 --> C2["one write: set published"]
  M3 --> C3["one write: delete review"]
Each user intent maps to exactly one mutation, which performs one logical change.

The flip side is just as important: do not bundle unrelated changes into one mega-mutation just to save a round trip. “One logical change” is the unit. Posting a review and following the artist are two intents, so they are two mutations — even if your UI happens to fire them together.

A write that returns nothing forces the client to immediately re-query to find out what happened. Instead, return the object the mutation affected, so the client can update its cache directly:

type Mutation {
createReview(trackId: ID!, rating: Int!, body: String): Review!
}

Returning Review! lets the caller select the new id, any server-defaulted fields, and even traverse into linked types — all in the same round trip that performed the write.

The example defines three intent-named mutations over one mutable store. Press Run: we create a review, then publish it. Each mutation returns the affected Review, and the names make the two distinct intents unmistakable.

JavaScript

The output tells the story: createReview returns a brand-new review with published: false, and the separate publishReview flips that one field. Two intents, two mutations, each returning the affected object. A reader scanning the schema can guess exactly what each does — which is the whole point of naming by intent.

Why is publishReview preferable to a generic updateReview(published: true)?
What does "one mutation per logical change" guard against?
Why should a mutation return the object it affected?