Skip to content
academia.sh

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 400 before execution.
  • Per-field resolution lets the others keep working when one field’s resolver errors; the result is a partial success response with a 200 status 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 POST make 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.

Start typing to search.

↑↓ Esc navigate · open · close