Skip to content
academia.sh

Lesson 27 / 34

Query, Mutation, and Subscription

The three operation types' execution rules, the measured result of running mutation fields sequentially on a single-copy book, and a subscription applying the same selection set more than once over time.

Contents

The schema says what can be asked, but not where the response will come from. This lesson writes the engine and shows what separates GraphQL’s three operation types.

The difference is not in syntax; all three use the same selection set language. The difference is in the execution rules. Query fields are assumed independent of each other and can run together. Mutation fields can affect each other and must run in order. Subscription produces not one response but more than one over time.

The Engine

The engine walks the selection set, calls a resolver for each field, and completes the returned value according to the field’s type. A leaf type’s value returns as is; a composite type’s subselection runs through the same procedure.

// executor.mjs — runs a validated document
// Query fields run together, mutation fields run in order.
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

const LEAF = new Set(["scalar", "enum"]);

// Merges variable declarations with the values supplied.
function resolveVariables(op, given) {
  const d = {};
  for (const b of op.variables) {
    const v = given[b.name] ?? b.defaultValue;
    if (v === undefined && b.type.endsWith("!")) throw new Error(`variable $${b.name} is required`);
    d[b.name] = v;
  }
  return d;
}

const resolveArgs = (args, vars) =>
  Object.fromEntries(Object.entries(args).map(([a, d]) => [a, d.kind === "variable" ? vars[d.name] : d.value]));

// Opens fragments and inline fragments; drops branches that do not match the concrete type.
function flatten(schema, document, typeName, selection, acc = []) {
  for (const s of selection) {
    if (s.kind === "field") { acc.push(s); continue; }
    const fragment = s.kind === "spread" ? document.fragments[s.name] : s;
    const condition = s.kind === "spread" ? fragment.type : s.type;
    if (condition === typeName || possibleTypes(schema, condition).includes(typeName)) flatten(schema, document, typeName, fragment.selection, acc);
  }
  return acc;
}

async function executeSelection(o, typeName, selection, source, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection);
  const result = {};
  for (const f of fields) result[f.alias] = null;      // key order comes from the query
  const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source); };
  if (sequential) for (const f of fields) await run(f);
  else await Promise.all(fields.map(run));
  return result;
}

async function executeField(o, typeName, field, source) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const resolver = o.resolvers[typeName]?.[field.name] ?? ((k) => k?.[field.name]);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context);
  return complete(o, def.type, value, field.selection);
}

async function complete(o, typeText, value, selection) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) return Promise.all(value.map((d) => complete(o, t.name, d, selection)));
  const type = o.schema.types[t.name];
  if (LEAF.has(type.kind)) return value;
  // Interface and union types ask the concrete-type resolvers.
  const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value);
  return executeSelection(o, concrete, selection, value);
}

export async function execute(schema, resolvers, document, { operationName, variables = {}, context = {} } = {}) {
  const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context };
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  return { data: await executeSelection(o, root, op.selection, null, op.type === "mutation") };
}

// Subscription: the resolver returns an event stream, each event formatted with the same selection set.
export async function* subscribe(schema, resolvers, document, { variables = {}, context = {} } = {}) {
  const op = document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context };
  const field = flatten(schema, document, schema.subscription, op.selection)[0];
  const stream = resolvers[schema.subscription][field.name](null, resolveArgs(field.args, o.vars), context);
  const def = fieldDef(schema.types[schema.subscription], field.name);
  for await (const event of stream) yield { data: { [field.alias]: await complete(o, def.type, event, field.selection) } };
}

The only branch in the execute function is whether the operation type is a mutation, and that branch passes through to the executeSelection call as the sequential flag. That is the rule: the entire difference is the choice between a single for loop and Promise.all.

The engine needs resolvers to reach the data. The data source below is in-memory, and the loan-issuing operation is deliberately written to be realistic: the remaining copy count is read, there is a wait as long as a database round trip, then the record is written.

// data.mjs — in-memory loan data and resolvers
export const BOOKS = new Map([
  ["978-0262033848", { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", copies: 1 }],
  ["978-0201896831", { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", copies: 2 }],
]);
export const MEMBERS = new Map([
  ["U-1001", { id: "U-1001", createdAt: "2024-02-11", name: "Alice Carter" }],
  ["U-1002", { id: "U-1002", createdAt: "2025-06-03", name: "Dana Reyes" }],
]);
export const LOANS = new Map();
let sequence = 0;

export const wait = (ms) => new Promise((c) => setTimeout(c, ms));
export const events = [];   // to see the execution order
export const reset = () => { LOANS.clear(); sequence = 0; events.length = 0; };

const onLoanCount = (isbn) => [...LOANS.values()].filter((o) => o.status === "OPEN" && o.items.some((k) => k.isbn === isbn)).length;

export function issueLoan(member, isbn, delay = 30) {
  return (async () => {
    events.push(`issueLoan(${isbn.slice(-4)}) started`);
    const remaining = BOOKS.get(isbn).copies - onLoanCount(isbn);   // READ
    await wait(delay);                                             // database round trip
    if (remaining <= 0) { events.push(`issueLoan(${isbn.slice(-4)}) finished: no copy available`); return null; }
    const record = { id: `O-${++sequence}`, createdAt: "2026-03-01", memberId: member,   // WRITE
                    items: [{ isbn, branch: "central" }], status: "OPEN", returnDate: null };
    LOANS.set(record.id, record);
    events.push(`issueLoan(${isbn.slice(-4)}) finished: ${record.id}`);
    return record;
  })();
}

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member"), SearchResult: (d) => (d.isbn ? "Book" : "Member") },
  Query: {
    loan: (_, a) => LOANS.get(a.id) ?? null,
    member: (_, a) => MEMBERS.get(a.code) ?? null,
    record: (_, a) => LOANS.get(a.id) ?? MEMBERS.get(a.id) ?? null,
    search: (_, a) => [...BOOKS.values()].filter((k) => k.title.toLowerCase().includes(a.term.toLowerCase())),
  },
  Mutation: {
    issueLoan: (_, a) => issueLoan(a.member, a.isbn),
    returnLoan: (_, a) => { const o = LOANS.get(a.id); if (o) o.status = "CLOSED"; return o ?? null; },
  },
  Loan: {
    member: (o) => MEMBERS.get(o.memberId),
    items: (o) => o.items,
  },
  Item: { book: (k) => BOOKS.get(k.isbn) },
  Member: { loans: (u, a) => [...LOANS.values()].filter((o) => o.memberId === u.id && (!a.status || o.status === a.status)) },
};

The Three Operation Types Side by Side

The schema built in the Schema and Type System lesson had two roots: query and mutation. Subscription is the third root, and it is added to the schema as a type; its fields name the events the client can watch.

// schema-subscription.mjs — the schema from lesson 01 with a subscription root added
import { SCHEMA } from "./schema.mjs";

export const SCHEMA_S = {
  ...SCHEMA,
  subscription: "Subscription",
  types: {
    ...SCHEMA.types,
    Subscription: {
      kind: "object",
      fields: { loanStatus: { type: "Loan!", args: { id: "ID!" } } },
    },
  },
};
// run.mjs — runs all three operation types; compares query and mutation execution order
import { parse } from "./parser.mjs";
import { execute, subscribe } from "./executor.mjs";
import { SCHEMA_S as SCHEMA } from "./schema-subscription.mjs";
import { RESOLVERS, LOANS, events, wait, reset } from "./data.mjs";

const log = (b, d) => console.log(b, JSON.stringify(d));

// 1) QUERY — fields run together
LOANS.set("O-9", { id: "O-9", createdAt: "2026-02-01", memberId: "U-1001",
  items: [{ isbn: "978-0201896831", branch: "shore" }], status: "OPEN", returnDate: "2026-03-20" });

const query = parse(`query($id: ID!) {
  loan(id: $id) { id status member { name } items { book { title } branch } }
  record(id: "U-1002") { id createdAt ... on Member { name } }
}`);
log("query        ->", (await execute(SCHEMA, RESOLVERS, query, { variables: { id: "O-9" } })).data);

// 2) MUTATION — try issuing a single-copy book twice
const mutation = parse(`mutation {
  first:  issueLoan(member: "U-1001", isbn: "978-0262033848") { id status }
  second: issueLoan(member: "U-1002", isbn: "978-0262033848") { id status }
}`);
reset();
log("mutation     ->", (await execute(SCHEMA, RESOLVERS, mutation)).data);
console.log("  execution order:", events.join(" | "));

// If the same two fields had run together:
reset();
const parallel = await Promise.all([
  RESOLVERS.Mutation.issueLoan(null, { member: "U-1001", isbn: "978-0262033848" }),
  RESOLVERS.Mutation.issueLoan(null, { member: "U-1002", isbn: "978-0262033848" }),
]);
console.log("  if run together:", events.join(" | "));
console.log("  copies issued if run together:", parallel.filter(Boolean).length, "(the book has 1 copy)");

// 3) SUBSCRIPTION — the same selection set is applied more than once over time
RESOLVERS.Subscription = {
  loanStatus: async function* (_, a) {
    for (const d of ["OPEN", "OVERDUE", "CLOSED"]) {
      await wait(5);
      const o = LOANS.get(a.id);
      yield { ...o, status: d };
    }
  },
};
LOANS.set("O-9", { id: "O-9", createdAt: "2026-02-01", memberId: "U-1001",
  items: [{ isbn: "978-0201896831", branch: "shore" }], status: "OPEN", returnDate: "2026-03-20" });
const subscription = parse(`subscription { loanStatus(id: "O-9") { id status member { name } } }`);
for await (const event of subscribe(SCHEMA, RESOLVERS, subscription)) log("subscription ->", event.data);
query        -> {"loan":{"id":"O-9","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"book":{"title":"The Art of Computer Programming"},"branch":"shore"}]},"record":{"id":"U-1002","createdAt":"2025-06-03","name":"Dana Reyes"}}
mutation     -> {"first":{"id":"O-1","status":"OPEN"},"second":null}
  execution order: issueLoan(3848) started | issueLoan(3848) finished: O-1 | issueLoan(3848) started | issueLoan(3848) finished: no copy available
  if run together: issueLoan(3848) started | issueLoan(3848) started | issueLoan(3848) finished: O-1 | issueLoan(3848) finished: O-2
  copies issued if run together: 2 (the book has 1 copy)
subscription -> {"loanStatus":{"id":"O-9","status":"OPEN","member":{"name":"Alice Carter"}}}
subscription -> {"loanStatus":{"id":"O-9","status":"OVERDUE","member":{"name":"Alice Carter"}}}
subscription -> {"loanStatus":{"id":"O-9","status":"CLOSED","member":{"name":"Alice Carter"}}}

Query: Fields Are Independent

The two root fields in the query response cannot see each other. The order in which the loan and record fields are resolved does not change the result, because both only read. This independence assumption is what legitimizes GraphQL running root query fields together, and in practice it is a clear gain: a screen with four independent root fields waits for the slowest one, not for the combined duration of four separate requests.

The key order in the response, however, is the order in the query, not the resolution order. The engine ensures this by placing the fields with null in advance. The client’s ability to read the response the way it read the query depends on this.

The record field returns an interface, and the ... on Member { name } branch ran. What the concrete type is gets asked of the __type resolver; the schema cannot know which type a record in the data source is, so code that reports this has to be written.

Mutation: Order Is a Guarantee

The mutation output shows the heart of the matter. A single-copy book was requested as a loan twice; first succeeded, second returned null. The execution-order line tells why: the second resolver started only after the first one finished, and it saw zero copies remaining.

When the same two resolvers run together, the order changes: both start, both read the same single copy at the start, and both write a record. The single-copy book ends up issued as a loan twice. This is the same lost-update problem from the database lessons, and it is why GraphQL runs root mutation fields in order.

The guarantee’s boundary must also be stated: sequencing is only for root mutation fields, and it only holds within a single operation. Two separate requests arriving at the same time can still have their mutations collide; what prevents that is the resolver’s own concurrency control. In-operation ordering guarantees that the steps a client wrote in a single document will run in the order it knew — nothing else.

Subscription: One Selection, Many Responses

The three lines in the subscription output were all produced from the same selection set. The resolver returns not a single value but an event stream; the engine formats every event with the same selection set. The member { name } branch was resolved three times.

This is the structural difference: in query and mutation, the root field resolves to a value; in subscription, to a stream. This has two consequences. A subscription operation can hold only one root field — there is no defined answer for how streams would be merged. And the transport layer cannot be request–response; it needs a carrier that can deliver more than one response over a single request.

Summary

  • The three operation types use the same selection set language; where they diverge is the execution rules.
  • Query root fields are assumed independent and run together; the key order in the response is still the order in the query.
  • Mutation root fields run in order; a single-copy book is issued once under sequential execution and twice when run together.
  • The sequencing guarantee is only for root mutation fields and only within a single operation; preventing separate requests from colliding is the resolver’s job.
  • A subscription resolver returns not a value but an event stream; the same selection set is reapplied to every event.
  • A subscription can hold only one root field, and its transport layer cannot be request–response.

Next Step

The engine called a resolver for every field, but how resolvers are written was not discussed. The loan record’s member field has a separate resolver, the id field does not; what is the difference between the two? What information does a resolver access, in what order is it called, and how does its returned value pass to the next level’s resolver? The next lesson defines the resolver signature in its four parts, shows what the default resolver does, and lays out the shape of execution by counting how many resolver calls a query produces.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close