---
title: GraphQL
source: 'https://academia.sh/en/courses/api-design/graphql'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:28+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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: "alice@example.test", 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.

```js
// 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]));
```

```js
// 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

```js
// 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 }));
```

```bash
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.
