Lesson 05 / 34
GraphQL
Client-driven query over a single endpoint: the type system the server publishes, validating the query against the schema before it runs, per-field resolution, and the partial success response that results when one field errors; reusing the same document with variables.
Contents
The previous lesson built the approach that fixes the contract in a schema, and left one gap: because the response shape is the server’s decision, the server has to change when the client’s requirement changes. This was the problem measured in the second lesson.
GraphQL does the opposite. The client determines the response shape, but it does so not with free-form text but inside a type system the server publishes. There is a single endpoint; what is requested is written not in the address but in the body’s query document. This lesson builds that mechanism by writing a small engine.
The Type System Is the Surface the Server Publishes
The type system states which fields exist, what type each field is, and which fields require a subselection. The function that knows how to find a field’s data is called a resolver; if a field has no resolver, its value is read from the source object.
// schema.mjs — the type system and resolvers. The server's whole published surface is here. export const TYPES = { Query: { member: { type: "Member", arg: "id" }, book: { type: "Book", arg: "isbn" } }, Member: { id: "ID", name: "String", email: "String", fine: "Int", loans: { type: "Loan", list: true } }, Loan: { id: "ID", dueDate: "String", overdueDays: "Int", book: { type: "Book" } }, Book: { isbn: "ID", title: "String", author: "String", pages: "Int" }, }; const MEMBERS = [{ id: "u1", name: "Alice Kane", email: "[email protected]", fine: 0 }]; const BOOKS = { "978-0262033848": { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", pages: 1312 }, "978-0201896831": { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", pages: 650 }, }; const LOANS = [{ id: "o11", member: "u1", isbn: "978-0262033848", dueDate: "2024-05-15" }, { id: "o12", member: "u1", isbn: "978-0201896831", dueDate: "2024-05-17" }]; // Resolver: each field fetches its own data. One field's error does not stop the others. export const RESOLVERS = { "Query.member": (_, arg) => MEMBERS.find((m) => m.id === arg), "Query.book": (_, arg) => BOOKS[arg], "Member.loans": (member) => LOANS.filter((l) => l.member === member.id), "Loan.book": (loan) => BOOKS[loan.isbn], "Loan.overdueDays": () => { throw new Error("overdue service is not responding"); }, };
The Loan.overdueDays resolver throwing an error is deliberate: in a real setup, the
overdue amount comes from a separate service, and that service might not respond. This
field exists to demonstrate the partial success response.
The Engine’s Three Steps
The query document passes through three steps: parsing (the text turns into a selection tree), validation (the tree is checked against the type system), and execution (each field is computed by its own resolver). Validation coming before execution is decisive: for an invalid query, no resolver runs, and therefore no data access happens at all.
// execute.mjs — a small query engine: parsing, validation against the type system, per-field execution import { TYPES, RESOLVERS } from "./schema.mjs"; export const parse = (text) => { // converts a query document into a selection tree const s = text.match(/[A-Za-z_][A-Za-z0-9_]*|[{}():]|"[^"]*"|\d+/g) ?? []; let i = 0; const selectionSet = () => { const fields = []; i++; while (s[i] !== "}") { const name = s[i++]; let arg = null; if (s[i] === "(") { arg = s[i + 3].replace(/^"|"$/g, ""); i += 5; } // name ( key : value ) fields.push({ name, arg, sub: s[i] === "{" ? selectionSet() : null }); } i++; return fields; }; s.unshift("{"); s.push("}"); return selectionSet(); }; export const validate = (fields, typeName = "Query", path = []) => { // checks against the type system const defects = []; for (const field of fields) { const def = TYPES[typeName]?.[field.name]; const at = [...path, field.name]; if (!def) { defects.push(`${at.join(".")}: no such field on type ${typeName}`); continue; } const isObject = typeof def === "object"; if (isObject && !field.sub) defects.push(`${at.join(".")}: ${def.type} is an object, a subfield must be selected`); else if (!isObject && field.sub) defects.push(`${at.join(".")}: ${def} is a scalar, a subfield cannot be selected`); else if (isObject) defects.push(...validate(field.sub, def.type, at)); } return defects; }; export const execute = (fields, root) => { const errors = []; const step = (fields, source, typeName, path) => { const output = {}; for (const field of fields) { const def = TYPES[typeName][field.name]; const at = [...path, field.name]; const resolver = RESOLVERS[`${typeName}.${field.name}`] ?? ((k) => k[field.name]); let value; try { value = resolver(source, field.arg); } catch (e) { errors.push({ path: at.join("."), message: e.message }); output[field.name] = null; continue; } if (typeof def !== "object") { output[field.name] = value ?? null; continue; } output[field.name] = def.list ? value.map((d, k) => step(field.sub, d, def.type, [...at, k])) : step(field.sub, value, def.type, at); } return output; }; return { data: step(fields, root, "Query", []), errors }; }; export const substituteVariables = (document, variables) => document.replace(/\$(\w+)/g, (_, name) => JSON.stringify(variables[name]));
// server.mjs — a single endpoint. The path is the same for every query; what is requested is written in the body. import { createServer } from "node:http"; import { parse, validate, execute, substituteVariables } from "./execute.mjs"; createServer(async (req, res) => { res.sendDate = false; let raw = ""; for await (const chunk of req) raw += chunk; const { document, variables = {} } = JSON.parse(raw); const fields = parse(substituteVariables(document, variables)); const defects = validate(fields); // Validation happens before execution: for an invalid query, no resolver runs at all. const body = defects.length ? { errors: defects } : execute(fields, null); const text = JSON.stringify(body); res.setHeader("Content-Type", "application/json; charset=utf-8"); res.setHeader("Content-Length", Buffer.byteLength(text)); res.writeHead(defects.length ? 400 : 200).end(text); }).listen(8471, "127.0.0.1", () => console.log("single endpoint 127.0.0.1:8471/graph"));
Measurement
// measure.mjs — measures the single endpoint's four properties const query = async (document, variables) => { const body = JSON.stringify({ document, variables }); const y = await fetch("http://127.0.0.1:8471/graph", { method: "POST", body }); const text = await y.text(); return { status: y.status, bytes: Buffer.byteLength(text), sent: Buffer.byteLength(body), body: JSON.parse(text) }; }; const print = (label, y) => console.log(` ${label.padEnd(22)} HTTP ${y.status} ` + `sent=${String(y.sent).padStart(3)} B received=${String(y.bytes).padStart(3)} B\n` + ` ${JSON.stringify(y.body)}`); console.log("-- 1) same endpoint, two different client requirements --"); print("shelf display", await query('member(id: "u1") { name }')); print("overdue report", await query('member(id: "u1") { name loans { dueDate book { title author } } }')); console.log("\n-- 2) type system: query is validated before execution --"); print("nonexistent field", await query('member(id: "u1") { name phone }')); print("subselection on scalar", await query('member(id: "u1") { name { length } }')); print("no selection on object", await query('member(id: "u1") { name loans }')); console.log("\n-- 3) partial success: one field's resolver errors --"); print("overdue field", await query('member(id: "u1") { name loans { dueDate overdueDays } }')); console.log("\n-- 4) variables: same document, two different values --"); const DOCUMENT = "book(isbn: $id) { title author pages }"; for (const id of ["978-0262033848", "978-0201896831"]) print(`id=${id.slice(-4)}`, await query(DOCUMENT, { id }));
node server.mjs & p=$! curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null \ -X POST -d '{"document":"book(isbn: \"978-0262033848\") { title }"}' http://127.0.0.1:8471/graph node measure.mjs kill $p
single endpoint 127.0.0.1:8471/graph
-- 1) same endpoint, two different client requirements --
shelf display HTTP 200 sent= 42 B received= 53 B
{"data":{"member":{"name":"Alice Kane"}},"errors":[]}
overdue report HTTP 200 sent= 82 B received=245 B
{"data":{"member":{"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","book":{"title":"Introduction to Algorithms","author":"Cormen"}},{"dueDate":"2024-05-17","book":{"title":"The Art of Computer Programming","author":"Knuth"}}]}},"errors":[]}
-- 2) type system: query is validated before execution --
nonexistent field HTTP 400 sent= 48 B received= 57 B
{"errors":["member.phone: no such field on type Member"]}
subselection on scalar HTTP 400 sent= 53 B received= 77 B
{"errors":["member.name: String is a scalar, a subfield cannot be selected"]}
no selection on object HTTP 400 sent= 48 B received= 75 B
{"errors":["member.loans: Loan is an object, a subfield must be selected"]}
-- 3) partial success: one field's resolver errors --
overdue field HTTP 200 sent= 72 B received=318 B
{"data":{"member":{"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","overdueDays":null},{"dueDate":"2024-05-17","overdueDays":null}]}},"errors":[{"path":"member.loans.0.overdueDays","message":"overdue service is not responding"},{"path":"member.loans.1.overdueDays","message":"overdue service is not responding"}]}
-- 4) variables: same document, two different values --
id=3848 HTTP 200 sent= 89 B received= 99 B
{"data":{"book":{"title":"Introduction to Algorithms","author":"Cormen","pages":1312}},"errors":[]}
id=6831 HTTP 200 sent= 89 B received=102 B
{"data":{"book":{"title":"The Art of Computer Programming","author":"Knuth","pages":650}},"errors":[]}
What the Measurement Says
A single endpoint, two different response shapes. The shelf display received 53 bytes, the overdue report 245 bytes. Both went to the same address, and nothing changed on the server. This is the property the query-based style showed in the second lesson; the difference here is that the selection is made not with free-form text but inside a published type system.
The type system catches three separate errors before execution. A field that does not
exist, a subselection made underneath a scalar, and an object requested without a
subselection — all three are rejected with 400, and no resolver runs. This shows that a
query-based interface is not loose: the client can choose the field combination, but it
cannot step outside the schema.
A partial success response is not an HTTP error. The overdue field’s resolver threw an
error, but the response came back 200. Most of the requested fields in the data portion
are filled in, the field that errored is null, and the error list reports every failure
along with its path: member.loans.0.overdueDays. This behavior is the direct
consequence of per-field resolution — because each field fetches its own data, one field’s
failure does not affect its neighbor.
Its counterpart on the consumer side is that the HTTP status code alone is not a
sufficient signal. A client that receives 200 still has to inspect the body. The notion
of partial success was described from the client side in the Application Architecture
course; here, the same behavior’s source on the server becomes visible.
Variables make the query document reusable. The same document worked for two different books. Sending variables separately has a second benefit too: because the document stays fixed, it can be recognized, cached, and cost-estimated in advance on the server side.
Costs
This flexibility has four costs, and three of them show up in the measurement.
Caching becomes harder. Every query goes to a single address with the POST method.
The entity tag mechanism from the previous lesson does not work directly here, because the
address does not name a record. Caching is set up either on the client side, keyed by the
query document, or at the field level.
The response’s cost is not known in advance. The server cannot compute how much data access an incoming query will trigger before the query arrives. A query with growing depth, or a nested selection inside a long list, can force a lot of work with a single request. This requires protections like query cost and depth limits.
Per-field resolution multiplies data access. The Loan.book resolver ran twice for
the two loans in the measurement; this number grows as the loan count grows. The N+1
pattern seen in the resource-based style in the second lesson has moved inside the server
here. Its solution through batch loading is covered in this course’s GraphQL in Detail
topic.
The error contract splits in two. A single request carries both a transport-level status code and an error list inside the body. If it is not explicitly decided when to use which, consumers are forced to look for errors in two separate places.
Summary
- GraphQL works over a single endpoint; what is requested is written not in the address but in the query document in the body, and the client’s selection set determines the response shape.
- The type system the server publishes bounds the selection: in the measurement, a
nonexistent field, a subselection on a scalar, and an object without a subselection were
all rejected with
400before execution. - Per-field resolution lets the others keep working when one field’s resolver errors; the
result is a partial success response with a
200status that carries both the data and the error list. - Variables keep the document fixed; the same document is reused with different values and becomes recognizable on the server side.
- The costs are reflected in the measurement: a single address and
POSTmake caching harder, the work a response will trigger is not known in advance, per-field resolution multiplies data access, and the error contract splits into two places.
Next Step
Working over a single endpoint, with an operation name carried in the body, is not a new idea. There is a family of protocols that built the same mechanism and left a wide legacy behind it: a family where every message is wrapped in an envelope, the operation name and the error are also carried inside that envelope, and the contract is published in a separate definition file. The next lesson builds this envelope-based approach with its own small parser, measures the envelope’s byte cost, and shows which of today’s interface decisions were inherited from there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.