Reads Have a Contract; Writes Need a Separate One
Reads Have a Contract; Writes Need a Separate One
A REST API grows one endpoint per verb per resource: POST /articles, PATCH /articles/:id, DELETE /articles/:id. GraphQL collapses transport to a single endpoint, but collapsing transport does not collapse semantics — a read and a write are still different operations, and the schema has to say which is which.
The Query type carries that contract. Fields on Query are expected to be side-effect free: running the same query twice returns the same data and changes nothing on the server. That property is what lets a server cache query responses, and what lets a client retry a query without checking first whether it already took effect.
Writes need the opposite declaration. The Mutation root type marks fields as operations allowed to change server state:
type Query {
post(id: ID!): Post
posts(first: Int = 20, after: String): [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post!
archivePost(id: ID!): Post!
}
Three consequences follow from placing a field on Mutation instead of Query:
Query field |
Mutation field |
|
|---|---|---|
| Side effects | none, by contract | expected |
| Root field execution | may interleave | runs in document order |
| Response caching | allowed | never |
The fourth consequence surprises people most: a schema with no Mutation type is read-only, and the server enforces it. Send mutation { publishPost(id: "p1") { id } } to a schema that only ever declared Query, and validation fails before a single resolver runs. GraphQL does not invent write operations from the shape of the request.
Arguments sit on mutation fields exactly as they sit on query fields — field arguments covers naming, nullability, and defaults — but the intent differs: arguments on a mutation describe the change to make, not a filter over what to read.
Anatomy of a Mutation the Client Can Actually Use
Anatomy of a Mutation the Client Can Actually Use
Here is a complete write path: schema, resolver, and the document the client sends.
Schema
type Post {
id: ID!
title: String!
status: PostStatus!
publishedAt: DateTime
}
enum PostStatus { DRAFT PUBLISHED ARCHIVED }
type Mutation {
publishPost(id: ID!): Post!
}
Resolver (Node.js)
const resolvers = {
Mutation: {
publishPost: async (_parent, args, ctx) => {
const post = await ctx.dataSources.posts.publish(args.id);
return post; // the executor places this under data.publishPost
},
},
};
Client document
mutation Publish($id: ID!) {
publishPost(id: $id) {
id
title
status
publishedAt
}
}
With the variable {"id": "p-42"} the response mirrors the selection set:
{
"data": {
"publishPost": {
"id": "p-42",
"title": "Shipping the indexer",
"status": "PUBLISHED",
"publishedAt": "2026-01-12T09:14:00Z"
}
}
}
Notice what the client did not have to do: send a follow-up post(id: $id) query to see the result. The mutation returned the changed entity with exactly the fields the UI renders, so a normalized client cache can patch its Post:p-42 record in place. A mutation returning only Boolean! or a bare id forces that second round trip — the design tradeoff is mutation return payloads.
Three conventions worth adopting from the first commit:
- Verb-first names:
createPost,publishPost,archivePost. The name tells the caller this field is a state transition, not a lookup. - Required ids are non-null:
id: ID!means a missing id becomes a validation error instead of anullthat reaches your resolver and fails halfway through the write. - Return the entity non-null:
Post!makes "succeeded but returned nothing" an internal bug the server reports, rather than a silentnullthe client quietly caches.
Where Write Operations Break in Production
Where Write Operations Break in Production
A mutation surface that works perfectly in GraphiQL still fails real users. Three failure modes show up repeatedly.
Retries create duplicate writes. Mobile clients, API gateways, and HTTP proxies retry requests they believe may have failed. A createOrder mutation that runs twice creates two orders and one very unhappy customer. The schema cannot fix this; the resolver can. Accept a client-generated idempotency key, store it with a unique constraint, and return the stored response on a repeat. Serial execution explains what ordering you do and do not get while those retries land.
Authorization runs too late. Guard the mutation, not only the fields it returns. A deleteUser(id: ID!) mutation that deletes the row and then fails on an unreadable field has already done the damage. Resolve the acting user before touching data — resolver context covers where that authenticated user comes from.
All-or-nothing responses hide partial work. If a mutation writes three tables and the third write fails, what does the client see? Two options, both defensible:
- Wrap every write in one database transaction, throw on failure, and let the executor return
data: nullfor that branch alongside an entry inerrors— reality and response stay consistent. - Commit what you can and return a payload carrying both the saved entity and a list of field-level errors — the client repairs the rejected fields instead of redoing everything.
What is not defensible is not deciding: two writes committed plus a generic error leaves the client unable to tell whether to retry.
Deletion is the argument you cannot take back. Prefer an archiveX mutation or a soft-delete flag when clients can mis-fire; a non-null ID! argument on deleteX leaves no room to ask "which record was that?" after the fact.
Interview angle: when an interviewer asks "what happens if a mutation fails halfway?", answer in three moves — where the transaction boundary sits, what the response body contains, and how the client decides whether to retry. That structure signals you have shipped writes before.
Practice Problems
Extend a task-tracking schema with three mutations: create a task, change its status, and delete a task. Required data must be rejected by validation rather than by resolver code, status changes must only accept legal state transitions, and every mutation must return enough for a task list UI to update without refetching.
Example:
Input: mutation { createTask(input: { title: "Write migration" }) { id title status dueDate } }
Output: { "data": { "createTask": { "id": "t-9", "title": "Write migration", "status": "TODO", "dueDate": null } } }
title is required inside the input, so omitting it fails validation. status and dueDate come back because the list UI renders them immediately.
Optimal Solution — O(1) time, O(1) space
Declare an input type for the create payload, an enum for status, and one mutation field per state transition returning the changed entity.
enum TaskStatus { TODO IN_PROGRESS DONE ARCHIVED }
input CreateTaskInput {
title: String!
description: String
dueDate: DateTime
}
type Task {
id: ID!
title: String!
status: TaskStatus!
dueDate: DateTime
completedAt: DateTime
}
type DeleteTaskPayload {
deletedId: ID!
success: Boolean!
}
type Mutation {
createTask(input: CreateTaskInput!): Task!
updateTaskStatus(id: ID!, status: TaskStatus!): Task!
deleteTask(id: ID!): DeleteTaskPayload!
} Edge Cases:
- Empty title string ("") passes String! — if whitespace-only titles are invalid, that check belongs in the resolver
- Deleting an unknown id should surface as a resolver error, not a null Task
- Setting status to its current value is a legal no-op, not an error
Quiz
1. A client sends a mutation operation to a server whose schema declares only a Query type. What happens?
2. Why does a mutation usually return the changed entity instead of Boolean!?
3. Which SDL signature declares a required id argument and guarantees a Post back?
4. Where does argument type and nullability checking happen?
Flashcards
Question
What the Mutation root type declares
Click to reveal answer
Answer
The schema field group holding write operations. Its fields may have side effects and their root fields execute in document order. Without it, mutation operations fail validation.
Question
Why Query fields must be side-effect free
Click to reveal answer
Answer
The no-side-effects contract makes query responses cacheable and retries safe. Writes get their own root so the two contracts never blur.
Question
First check when a mutation operation is rejected
Click to reveal answer
Answer
Whether the schema declares a Mutation type and the operation selects fields from it. This fails at validation, before resolvers run.
Question
Naming convention for mutation fields
Click to reveal answer
Answer
Verb-first camelCase: createOrder, publishPost, archiveOrder. Signals a state transition and keeps generated SDKs predictable.
Question
Why mutations return entities, not booleans
Click to reveal answer
Answer
The client updates its normalized cache from the response instead of issuing a follow-up query — one round trip instead of two.
Revision Notes
Key Takeaways
- 1. Query fields promise no side effects; the Mutation root exists specifically to declare fields that break that promise
- 2. A schema with no Mutation type rejects mutation operations during validation, before any resolver runs
- 3. Return the mutated entity so the client can patch its cache without refetching
- 4. Idempotency keys and transaction boundaries live in the resolver layer; SDL only describes shape
Interview Tips
- • Be ready to separate three ideas interviewers conflate: operation-type validation, root-field execution order, and field-level authorization
- • For a flaky write endpoint, mention idempotency keys, transaction boundaries, and exactly what the error body contains when a write fails halfway
Cheat Sheet
Mutation Operations Cheat Sheet
type Mutation {
createPost(input: CreatePostInput!): Post!
publishPost(id: ID!): Post!
archivePost(id: ID!): Post!
}
- No
Mutationtype in the schema → mutation operations fail validation - Query roots may interleave; mutation root fields run in document order
- Arguments use the full type system:
ID!rejects missing values before resolvers run - Return the mutated entity, not
Boolean!, so clients skip the refetch - Verb-first names (
createX,archiveX) mark state transitions - Idempotency keys, transactions, and auth checks belong in the resolver, not the SDL