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.
Name the verb, not the table
Section titled “Name the verb, not the table”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 intent — publishReview — restores that meaning, so the server can run exactly the logic that action requires.
One mutation per logical change
Section titled “One mutation per logical change”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"]
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.
Always return the affected data
Section titled “Always return the affected data”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.
Designing a mutation, run for real
Section titled “Designing a mutation, run for real”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.
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.