skip to content

Why can a GraphQL mutation document leave a write half-applied?

level: juniorimportance: must knowfreq 61%

answer

  1. Ask what the transaction boundary is
  2. A document is not a unit of work
  3. Root fields commit one by one
  4. The first write is already durable
  5. One action per mutation field

basics

~20 s

A document can carry several root mutation fields. They run one after another, and nothing wraps the group in a shared transaction, so if the second one fails the first one's write has already committed and stays committed.

solid answer

~50 s

GraphQL gives a document no transactional scope. A client may put several fields under `mutation`, the server runs them one at a time in the order written, and each resolver commits whatever it commits. If the second field's resolver throws — say an authorization check that only runs once the row has been loaded — the first field's write is already durable and nothing undoes it. The response then carries a `data` map holding the payloads of the fields that worked, a `null` where the failing field sat, and an entry in the `errors` list. By GraphQL's own rules that is a perfectly ordinary response; the write is simply half-applied. Atomicity is available only *inside* one root field, where a single resolver can own a single transaction. Hence the working rule: one action per mutation field, one mutation field per request.

code

graphql · 5 lines
graphql
mutation CloseOutTable {
  applyDiscount(orderId: "ord_48213", code: "STAFF15") { totalCents }
  voidLine(orderLineId: "line_9127")                   { orderId }
  capturePayment(orderId: "ord_48213", amountCents: 4183) { receiptId }
}

go deeper

for a junior

Recall the shape of the answer: several root mutation fields run one after another, no transaction covers the group, and a later failure leaves earlier writes committed. Be able to point at the null field sitting beside filled payloads in data.

for a middle

Explain why no generic fix exists — different resolvers may write to different stores, and the executor has no inverse for a commit. Then give the remedy: collapse the atomic action into one root field so one resolver owns one transaction.

for a senior

Show you have operated this. Talk about what the client does with a half-applied response, why compensation is not rollback, and how you keep multi-write documents out of production traffic rather than merely discouraging them in review.

for a principal

Own the boundary decision. Argue where the atomic unit belongs, what consistency contract you publish to client teams you do not ship, and what you are prepared to reconcile after the fact when an action genuinely cannot be made atomic.

## What "atomic" would have to mean here Atomicity means a group of writes either all take effect or none do. In a relational store that unit is a transaction; the boundary is explicit and the store enforces it. The question this leaf answers is which GraphQL construct, if any, carries that boundary. The answer is: **the root field, and only if its resolver opens one.** No larger unit exists. Not the document, not the operation, not the HTTP request. ## The unit of execution is the field A `mutation` operation's selection set is a list of **root fields** — fields declared on the mutation root type. The executor runs them one at a time, in the order the client wrote them, finishing each before starting the next. Each root field is served by an ordinary resolver function. Whatever transaction that function opens belongs to that function. Nothing outside opens one around the group, and nothing outside knows how to close one. Take a restaurant ordering graph and a document that closes out a table: ```graphql mutation CloseOutTable { applyDiscount(orderId: "ord_48213", code: "STAFF15") { totalCents } voidLine(orderLineId: "line_9127") { orderId } capturePayment(orderId: "ord_48213", amountCents: 4183) { receiptId } } ``` Three root fields, three resolvers, three separate transactions at best — and three separate commits. ## The failure `voidLine` loads the order line and only then checks whether this caller is allowed to void a line the kitchen has already fired: a permission check that ran too late, inside the resolver, after the discount write had committed. It raises. The executor records the error, produces `null` for that field, and carries on with the next root field. The response comes back looking like this: ```json { "data": { "applyDiscount": { "totalCents": 4183 }, "voidLine": null, "capturePayment": { "receiptId": "rcp_20714" } }, "errors": [ { "message": "Not permitted to void a fired line" } ] } ``` The discount applied. The void did not. The payment went through anyway. The guest is charged for a line that should have been removed, and the response that says so is a normal, successful GraphQL response. ## Why no server can fix this generically Three reasons, and they compound: * **The resolvers may not share a store.** One may write to a relational database, one may call a payments provider over the network, one may publish to an event backbone. There is no transaction that spans those. * **The executor does not know what an undo is.** It sees functions returning values. It has no inverse for "money captured". * **An "abort the rest" hook would not help.** By the time the second field fails, the first has already committed. Stopping the third does not restore the first. Some server implementations do offer a request-scoped transaction handed to every resolver through the execution context. That only works when every write in the document goes to the same store, and it converts the whole document into one long-running transaction, which is its own operational problem. It is a server implementation choice, not something the specification provides. ## Specified versus conventional Worth separating carefully, because interviewers probe it. **Specified:** the root fields of a mutation execute serially, in document order. **Not specified, and not mentioned anywhere:** transactions, rollback, compensation, or any notion of a document succeeding or failing as a whole. That is not an oversight to be patched. GraphQL describes how a request is resolved into a response; durability is the resolver's business, exactly as it is behind any other API style. ## The working rule Because the field is the only atomic unit on offer, put the atomic action inside one field. Name the field for the business action rather than for its steps: ```graphql type Mutation { closeOutTable(input: CloseOutTableInput!): CloseOutTablePayload! } ``` One field, one resolver, one transaction, one outcome. The client can no longer half-apply it by accident, because it can no longer split it. ## What a client must do in the meantime * Never read a successful HTTP exchange as proof that every write applied — half-applied documents come back looking healthy. * On a mutation, treat any entry in `errors` as "some part of this may not have happened", and inspect `data` to see which parts returned payloads. * Repair forward, not backward. There is no rollback to ask for; the only recovery is a compensating write — refund the capture, re-add the voided line — and compensating writes can themselves fail. * Prefer sending one root mutation field per request, so the unit the client retries is the same as the unit the server made atomic.

  • The response came back over a successful HTTP exchange with a filled `data` map. Does that prove every mutation in the document applied?
    No. A half-applied document produces exactly that shape: payloads for the root fields that worked, `null` for the one that failed, and an entry in `errors`. The transport succeeded because the request was parsed, validated and executed — which says nothing about whether each resolver's write committed. The client has to read the `errors` list before trusting the write.
  • If two writes must succeed or fail together, how do you express that in the schema?
    Put them behind a single root mutation field named for the business action, and let that one resolver own one transaction. Clients then cannot split the action apart, because there is nothing to split. Exposing the two steps as two fields and telling client teams to always send both is advice, not a guarantee — the first client that sends only one breaks the invariant.
  • Can the client undo the part that succeeded?
    Only by compensating: issuing a further write that reverses the effect — a refund, a re-add, a status flip. That is not rollback. The compensating write can fail too, it is visible to anyone watching the data in between, and some effects have no inverse at all, such as an email already sent or a kitchen ticket already printed.
  • Does the same exposure exist for a `query` operation?
    Not in the same way. Query root fields are permitted to execute in parallel and are expected to be side-effect free, so a failure leaves a partial *read*, not a partial *write*. The stakes differ: a partial read is a rendering problem the client can retry, while a partial write leaves durable state that no retry cleans up.

It is a shopping list handed to one clerk, not a bank transfer. The clerk works down the list, and if item three is refused, items one and two are already bagged and paid for.

saying these in an interview costs you the question

  • Claims the whole document runs in one transaction
  • Says a failing field rolls back earlier mutations
  • Treats a successful HTTP exchange as proof every write applied
  • Thinks the executor aborts the document at the first error
  • Sends five root mutation fields expecting all-or-nothing
  • Confuses compensating writes with real rollback

context