---
title: 'Web API Design'
source: 'https://academia.sh/en/courses/api-design'
language: en
updated: '2026-08-19T05:19:26+00:00'
license: 'CC BY-SA 4.0'
lessons: 34
outcomes:
    - 'Builds a consistent REST interface through resource modeling and URI design'
    - 'Justifies API versioning and error contract decisions'
    - 'Documents and tests the contract in a machine-readable form'
---

# Web API Design

Establishing the contract through which programs connect to one another: the trade-offs between API styles, resource modeling and URI design, status code and body decisions, the error contract, versioning, and documenting and testing the contract in a machine-readable form.

## API Styles

Different interface styles that meet the same need: the notion of a contract and of compatibility, a comparison of resource-based, remote procedure call, and query-based approaches measured by request count and bytes transferred, REST principles, binary protocols, and communication outside the request-response pattern.

### What Is an API

Source: https://academia.sh/en/courses/api-design/what-is-an-api

What makes an interface a contract: the consumer's unwritten assumptions, defining compatible and breaking change by consumer behavior, the different response a tolerant reader and a strict reader give to the same change, and who owns the contract.

The previous course established the cases where the server hands out a file or a document
to the outside: content a human will read. The library lending service's actual consumers,
however, are programs. The shelf display at the branch queries a book's status, the report
job that runs at night pulls a listing by author, and the audit script checks the shape of
the records. All three call the same endpoint, and all three expect something different
from it.

The concept that emerges at this point is the **application programming interface**: the
callable surface a program opens to other programs. This lesson establishes why that
surface counts as a **contract** and when the contract breaks.

#### The Contract Is More Than the Body

Looking at the JSON body an endpoint returns is not the same as seeing the whole contract.
The contract covers everything the two sides can expect from each other:

- **Surface:** which addresses exist, which methods they accept, which parameters they
  recognize.
- **Format:** the names, types, requiredness, and value sets of the fields in the body.
- **Meaning:** what a field represents. Does the `status` field's `onShelf` value report
  the book's physical location, or its availability for loan?
- **Behavior:** the result of making the same request twice, which status code reports
  errors, whether ordering is guaranteed.

Only some of this is written down. The unwritten part is still part of the contract,
because the consumer relies on it. A consumer who notices that a list always comes back
in the same order writes code that assumes that order; when the order changes, the
consumer breaks even though no "documented" part of the interface has changed. The
contract's boundary is not drawn by the scope of the documentation but by everything the
consumer has come to depend on.

#### Compatibility Is a Measurement Question

Whether a change is **breaking** is defined not by the size of the change but by whether
existing consumers keep working after it. This definition is measurable: it is enough to
run the same consumers against different published contract versions.

The library service's book endpoint is written so it can publish three separate versions.

```js
// server.mjs — the endpoint that publishes a book record. The CONTRACT variable selects the published version.
import { createServer } from "node:http";

const CONTRACT = process.env.CONTRACT ?? "v1";
const RECORD = { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", status: "onShelf" };

const buildBody = () => {
  if (CONTRACT === "v1") return RECORD;
  if (CONTRACT === "v2-addition") return { ...RECORD, pages: 1312 };            // field added
  return { isbn: RECORD.isbn, title: RECORD.title,                              // field name and type changed
           author: { name: RECORD.author }, loanStatus: RECORD.status, pages: 1312 };
};

createServer((req, res) => {
  res.sendDate = false;
  const body = JSON.stringify(buildBody());
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(body));
  res.writeHead(200).end(body);
}).listen(8441, "127.0.0.1");
```

Three consumers represent three separate reading habits. The shelf display reads only the
two fields it needs. The report job also reads few fields, but it depends on one field's
type. The audit script checks the body as a whole and counts an unrecognized field as an
error.

```js
// consumer.mjs — three separate consumers. Usage: node consumer.mjs <shelfDisplay|report|auditor>
const role = process.argv[2];
const record = await (await fetch("http://127.0.0.1:8441/books/978-0262033848")).json();

const ROLES = {
  // Tolerant reader: reads only the fields it needs, ignores fields it does not recognize.
  shelfDisplay: (r) => {
    if (typeof r.status !== "string") throw new Error("status field missing");
    return `shelf display: ${r.title} -> ${r.status}`;
  },
  // Tolerant, but depends on a field's type.
  report: (r) => {
    if (typeof r.author !== "string") throw new Error("author field is not a string");
    return `report line: ${r.author.toUpperCase()} / ${r.title}`;
  },
  // Strict reader: rejects every field not listed in the contract.
  auditor: (r) => {
    const EXPECTED = ["isbn", "title", "author", "status"];
    const extra = Object.keys(r).filter((f) => !EXPECTED.includes(f));
    if (extra.length) throw new Error(`unexpected field: ${extra.join(", ")}`);
    return `check: ${r.isbn} valid`;
  },
};

try { console.log(`  ${role.padEnd(13)} OK     ${ROLES[role](record)}`); }
catch (e) { console.log(`  ${role.padEnd(13)} BROKEN ${e.message}`); }
```

```bash
#!/usr/bin/env bash
# Tests three contract versions against three consumers.
A=http://127.0.0.1:8441/books/978-0262033848
for c in v1 v2-addition v2-change; do
  CONTRACT=$c node server.mjs & p=$!
  echo "== published contract: $c =="
  printf '  body: '
  curl -s --retry 20 --retry-all-errors --retry-delay 0 --retry-connrefused "$A"; echo
  for role in shelfDisplay report auditor; do node consumer.mjs $role; done
  kill $p 2>/dev/null; wait $p 2>/dev/null || true
done
```

```
== published contract: v1 ==
  body: {"isbn":"978-0262033848","title":"Introduction to Algorithms","author":"Cormen","status":"onShelf"}
  shelfDisplay  OK     shelf display: Introduction to Algorithms -> onShelf
  report        OK     report line: CORMEN / Introduction to Algorithms
  auditor       OK     check: 978-0262033848 valid
== published contract: v2-addition ==
  body: {"isbn":"978-0262033848","title":"Introduction to Algorithms","author":"Cormen","status":"onShelf","pages":1312}
  shelfDisplay  OK     shelf display: Introduction to Algorithms -> onShelf
  report        OK     report line: CORMEN / Introduction to Algorithms
  auditor       BROKEN unexpected field: pages
== published contract: v2-change ==
  body: {"isbn":"978-0262033848","title":"Introduction to Algorithms","author":{"name":"Cormen"},"loanStatus":"onShelf","pages":1312}
  shelfDisplay  BROKEN status field missing
  report        BROKEN author field is not a string
  auditor       BROKEN unexpected field: loanStatus, pages
```

Nine results give three rules.

**Adding a field is not inherently compatible.** Adding the `pages` field left two
consumers unaffected and broke the audit script. The provider's "I only added something"
claim showed up as an error on the consumer side. For an addition to count as compatible
depends on consumers ignoring fields they do not recognize.

**Renaming a field is the same as deleting it.** When the `status` field was renamed to
`loanStatus`, that field ceased to exist for the shelf display. Even though the provider's
intent was "fixing the name," what the consumer sees is a deleted field.

**Changing a type is breaking even when the name is preserved.** The `author` field is
still in place, but it is now an object instead of a string. A check that only looks at
field names does not catch this change; the report job breaks at run time.

#### The Two Directions of Compatibility

The measurement tested only one direction: the server new, the consumers old. This
direction is called **backward compatibility**, and it is the criterion that determines
the provider's release decision, because the server is updated before the consumers.

The reverse direction also exists, and it is easy to miss: the consumer new, the server
old. **Forward compatibility** is a consumer written against a contract not yet published
being able to work with the old server. In the library service, this situation arises when
the terminals are updated before the central branch: the new terminal tries to read the
`pages` field, and the old server never sends that field. Whether the terminal crashes or
shows a blank when the field is missing is the consumer's decision, not the contract's.

The two directions together give a release rule: **the server that adds the field is
published first, then the consumer that reads the field; the order reverses when a field
is removed.** A contract change is not a single event but a sequential transition between
two sides.

#### The Tolerant Reader

The audit script in the measurement caused its own break by its own rule: it raised an
error when it saw a field not listed in the body. The opposite of this behavior is the
**tolerant reader**: a consumer that reads only the fields it needs and ignores everything
it does not recognize.

Tolerant reading is a consumer habit, but its result concerns the provider: an interface
stays extensible to the degree that its consumers read tolerantly. In a system with
strict-reading consumers, every addition requires a version bump; in a system with
tolerant-reading consumers, additions roll out silently.

This has a limit in the reverse direction. Tolerance does not apply to input validation.
When the server ignores fields it does not recognize in an incoming body, a consumer's
typo is silently swallowed: a client that writes `dueDat` instead of `dueDate` gets no
error, runs with the default value, and only learns of the mistake from the result. The
rule is therefore different in the two directions: **be strict in what you send, be
tolerant in what you receive.**

#### The Consumer Owns the Contract

The owner of an interface is the party that publishes it, but the party that determines
the contract's validity is the consumer. This distinction has two practical consequences.

First, the cost of a breaking change grows with the number of consumers. In an internal
interface with a single consumer, renaming the `status` field means publishing both sides
together. In an interface distributed to terminals across branches, the same change means
every terminal that has not been updated breaks. The same technical change is two separate
decisions in two contexts.

Second, the wider the written part of the contract, the smaller the share of unwritten
assumptions the consumer has to rely on. When the types, requiredness, and value sets of
fields are explicitly stated, the consumer binds by "reading" rather than by "observing."
If it is written that ordering is not guaranteed, a consumer that relies on ordering is at
fault. This is where the value of a machine-readable definition comes from, and it is
covered later in this course.

#### Summary

- An API's contract is wider than its body format: surface, format, meaning, and behavior
  together make up the contract, and the unwritten assumptions the consumer relies on fall
  within this scope.
- Whether a change is breaking is defined by whether existing consumers keep working after
  it, and it is measured by running the consumers against the new version.
- In the measurement, adding a field broke the strict-reading consumer, renaming a field
  had the same effect as deleting it, and changing a type produced a run-time break even
  though the name was preserved.
- The tolerant reader ignores fields it does not recognize and keeps the interface
  extensible through additions; the same tolerance does not apply to validating incoming
  requests.
- The contract's cost grows with the number of consumers; every undocumented detail is a
  surface the consumer will bind to by observation.

#### Next Step

This lesson established what a contract is but left the contract's shape open: why is a
book record read from an address, why is it not requested with a procedure call, why does
the client not say which fields it wants? The same job can be met with three separate
approaches — resource-based, remote-procedure-call style, and query-based — and the
difference among these three approaches is not a matter of taste. The next lesson writes
all three for the same scenario and shows the difference among them with three measures:
how many requests went out, how many bytes were carried, and how much of the carried data
was used?

### Comparison of API Styles

Source: https://academia.sh/en/courses/api-design/comparison-of-api-styles

Three styles that meet the same job: resource-based, remote-procedure-call style, and query-based approaches are written for the same scenario and compared by measuring request count, bytes on the wire, and the used share of carried fields; the lesson shows which side has to change when the requirement changes.

The previous lesson established what makes an interface a contract but left the contract's
shape open. The following job requested from the library service can be met by three
separate approaches: **list a member's borrowed books, along with their titles and due
dates.**

The three approaches are as follows. In the **resource-based** style, every concept has
its own address, and the client collects the necessary pieces one by one. In the
**remote-procedure-call** style, there is a single address; the client sends a method name
in the body, and the server returns the response prepared for that job. In the
**query-based** style, there is again a single address, but the client writes which fields
it wants in the body. The difference among them is not a matter of taste: who determines
the response shape changes, and this has measurable consequences.

#### Shared Data and the Third Style's Parser

All three styles work on the same data.

```js
// data.mjs — the shared library data all three styles run on
export const MEMBERS = [{ id: 1, name: "Alice Kane", email: "alice@example.test", phone: "0000000000",
  address: "Central Branch, Floor 2", registered: "2024-01-05", fine: 0, membershipType: "full" }];
export const BOOKS = {
  "978-0262033848": { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen",
    pages: 1312, publisher: "MIT", year: 2009, shelf: "R-12", summary: "Algorithm design and analysis." },
  "978-0201896831": { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth",
    pages: 650, publisher: "Addison", year: 1997, shelf: "R-03", summary: "The fundamental algorithms volume." },
  "978-0131103627": { isbn: "978-0131103627", title: "The C Programming Language", author: "Kernighan",
    pages: 272, publisher: "Prentice", year: 1988, shelf: "R-07", summary: "The language's definition and examples." },
};
export const LOANS = [
  { id: 11, memberId: 1, isbn: "978-0262033848", borrowedAt: "2024-05-01", dueDate: "2024-05-15" },
  { id: 12, memberId: 1, isbn: "978-0201896831", borrowedAt: "2024-05-03", dueDate: "2024-05-17" },
  { id: 13, memberId: 1, isbn: "978-0131103627", borrowedAt: "2024-05-06", dueDate: "2024-05-20" },
];
```

The query-based style requires a parser: the selection text the client writes is converted
into a tree, and this tree is then executed against the data. The following thirty lines
do this job.

```js
// query.mjs — a small selection-set parser. Input: "member(1) { name loans { dueDate book { title } } }"
export const parse = (text) => {
  const tokens = text.match(/[A-Za-z_][A-Za-z0-9_]*|\d+|[{}()]/g) ?? [];
  let i = 0;
  const selectionSet = () => {                       // { field field(arg) { ... } ... }
    const fields = [];
    i++;                                             // opening curly brace
    while (tokens[i] !== "}") {
      const name = tokens[i++];
      let arg = null;
      if (tokens[i] === "(") { arg = tokens[i + 1]; i += 3; }
      const sub = tokens[i] === "{" ? selectionSet() : null;
      fields.push({ name, arg, sub });
    }
    i++;                                             // closing curly brace
    return fields;
  };
  tokens.unshift("{"); tokens.push("}");
  return selectionSet();
};

// Executes the selection tree against the data: only the fields the client asked for come back.
export const execute = (fields, root, resolver) => {
  const output = {};
  for (const field of fields) {
    const value = resolver(root, field.name, field.arg);
    if (!field.sub) { output[field.name] = value; continue; }
    output[field.name] = Array.isArray(value)
      ? value.map((d) => execute(field.sub, d, resolver))
      : execute(field.sub, value, resolver);
  }
  return output;
};
```

All three styles sit side by side in a single server; this way the comparison happens on
the same data, the same process, and the same connection.

```js
// server.mjs — a server that meets the same job with three styles: resource-based, remote procedure, query-based
import { createServer } from "node:http";
import { MEMBERS, BOOKS, LOANS } from "./data.mjs";
import { parse, execute } from "./query.mjs";

const readBody = (req) => new Promise((resolve) => {
  let m = ""; req.on("data", (p) => (m += p)); req.on("end", () => resolve(m));
});
const json = (res, code, body) => {
  const text = JSON.stringify(body);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(text));
  res.writeHead(code).end(text);
};

// Resolver for the query-based style: each field name corresponds to a data access.
const resolver = (root, name, arg) => {
  if (name === "member") return MEMBERS.find((m) => m.id === Number(arg));
  if (name === "loans") return LOANS.filter((l) => l.memberId === root.id);
  if (name === "book") return BOOKS[root.isbn];
  return root[name];
};

createServer(async (req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;

  // 1) Resource-based: each concept has a separate address, each address returns the full record.
  let e;
  if ((e = /^\/members\/(\d+)$/.exec(path))) return json(res, 200, MEMBERS.find((m) => m.id === +e[1]));
  if ((e = /^\/members\/(\d+)\/loans$/.exec(path)))
    return json(res, 200, LOANS.filter((l) => l.memberId === +e[1]));
  if ((e = /^\/books\/(.+)$/.exec(path))) return json(res, 200, BOOKS[e[1]]);

  // 2) Remote procedure call: single address, method name in the body; the server decides the response shape.
  if (path === "/rpc") {
    const { method, params } = JSON.parse(await readBody(req));
    if (method !== "memberLoanSummary") return json(res, 200, { error: "unknown method" });
    const member = MEMBERS.find((m) => m.id === params.memberId);
    return json(res, 200, { result: { name: member.name, loans: LOANS
      .filter((l) => l.memberId === member.id)
      .map((l) => ({ dueDate: l.dueDate, bookTitle: BOOKS[l.isbn].title })) } });
  }

  // 3) Query-based: single address, selection set in the body; the client decides the response shape.
  if (path === "/query") {
    const { query } = JSON.parse(await readBody(req));
    return json(res, 200, { data: execute(parse(query), null, resolver) });
  }
  return json(res, 404, { error: "not found" });
}).listen(8451, "127.0.0.1", () => console.log("three styles 127.0.0.1:8451"));
```

#### Three Measures

The comparison rests on three numbers. **Request count** gives the network round trips.
**Bytes on the wire**, headers included, gives the data actually carried; to measure this,
the client uses a layer that wraps the connection. **Carried field count** is the sum of
the scalar values in the responses; how many of these are used quantifies **over-fetching**.

```js
// measure.mjs — measures the three styles in the same scenario: request count, bytes on the wire, used-field ratio
import { Agent, request } from "node:http";
import { connect } from "node:net";

let sent = 0, received = 0, requestCount = 0;
const agent = new Agent({ keepAlive: true, maxSockets: 1 });
agent.createConnection = (option) => {                 // connection that counts bytes on the wire
  const s = connect(option);
  s.on("data", (p) => { received += p.length; });
  const write = s.write.bind(s);
  s.write = (chunk, ...k) => { sent += Buffer.byteLength(chunk); return write(chunk, ...k); };
  return s;
};
const sendRequest = (path, body) => new Promise((resolve, reject) => {
  requestCount++;
  const r = request({ agent, host: "127.0.0.1", port: 8451, path,
    method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json" } : {} },
    (y) => { let m = ""; y.on("data", (p) => (m += p)); y.on("end", () => resolve(JSON.parse(m))); });
  r.on("error", reject);
  r.end(body ? JSON.stringify(body) : undefined);
});

// Sum of scalar fields carried in the responses
const countLeaves = (d) => (typeof d !== "object" || d === null
  ? 1 : Object.values(d).reduce((t, v) => t + countLeaves(v), 0));
const reset = () => { sent = received = requestCount = 0; };
const report = (name, carried, used, note) => console.log(
  `${name.padEnd(17)} requests=${String(requestCount).padStart(2)}  ` +
  `sent=${String(sent).padStart(4)} B  received=${String(received).padStart(4)} B  ` +
  `carried fields=${String(carried).padStart(2)}  used=` +
  (carried ? `%${Math.round((100 * used) / carried)}`.padStart(5) : "    -") +
  (note ? `\n${" ".repeat(18)}${note}` : ""));

const resourceBased = async () => {
  reset();
  let carried = 0;
  const member = await sendRequest("/members/1");               carried += countLeaves(member);
  const loans = await sendRequest("/members/1/loans");          carried += countLeaves(loans);
  for (const l of loans) carried += countLeaves(await sendRequest(`/books/${l.isbn}`));
  return carried;
};

// --- Scenario 1: member name + book title and due date for each loan (7 fields) ---
console.log("-- scenario 1: member name, book title, due date --");
report("resource-based", await resourceBased(), 7);
reset();
const rpc = await sendRequest("/rpc", { method: "memberLoanSummary", params: { memberId: 1 } });
report("remote procedure", countLeaves(rpc), 7, JSON.stringify(rpc.result).slice(0, 78) + " ...");
reset();
const result = await sendRequest("/query", { query: "member(1) { name loans { dueDate book { title } } }" });
report("query-based", countLeaves(result), 7, JSON.stringify(result.data).slice(0, 78) + " ...");

// --- Scenario 2: the client's requirement changed, the author's name is now wanted too (10 fields) ---
console.log("\n-- scenario 2: author name added to the response --");
report("resource-based", await resourceBased(), 10, "server did not change: author was already carried");
reset();
const rpc2 = await sendRequest("/rpc", { method: "memberLoanSummaryWithAuthors", params: { memberId: 1 } });
report("remote procedure", 0, 10, `server response: ${JSON.stringify(rpc2)} -> new method needed`);
reset();
const result2 = await sendRequest("/query",
  { query: "member(1) { name loans { dueDate book { title author } } }" });
report("query-based", countLeaves(result2), 10, "server did not change: author field added to the query");
agent.destroy();
```

```bash
node server.mjs & p=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8451/members/1
node measure.mjs
kill $p
```

```
three styles 127.0.0.1:8451
-- scenario 1: member name, book title, due date --
resource-based    requests= 5  sent= 404 B  received=1702 B  carried fields=47  used=  %15
remote procedure  requests= 1  sent= 174 B  received= 379 B  carried fields= 7  used= %100
                  {"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","bookTitle":"Introductio ...
query-based       requests= 1  sent= 185 B  received= 403 B  carried fields= 7  used= %100
                  {"member":{"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","book":{"title ...

-- scenario 2: author name added to the response --
resource-based    requests= 5  sent= 404 B  received=1702 B  carried fields=47  used=  %21
                  server did not change: author was already carried
remote procedure  requests= 1  sent= 185 B  received= 159 B  carried fields= 0  used=    -
                  server response: {"error":"unknown method"} -> new method needed
query-based       requests= 1  sent= 192 B  received= 459 B  carried fields=10  used= %100
                  server did not change: author field added to the query
```

Byte counts also include headers, so they depend on the runtime's default header set;
field counts and request counts, however, depend on the data and are independent of the
machine.

#### What the Measurement Says

**Under-fetching** shows up in the resource-based style as request count. The member
record is one request, the loan list is one request, then one book request per loan: five
in total. This number is not fixed; it depends on the number of loans. The pattern of
fetching a record list and making a separate request for each row in that list is known as
the **N+1 problem**, and the round-trip count grows as the data grows.

**Over-fetching** shows up in the same style as field count. Forty-seven fields were
carried, and seven were used. The member's address, phone, fine amount; the book's
summary, publisher, shelf number — none of these had been requested. In the
resource-based style, returning the whole record is not a defect but the definition of
that style: an address corresponds to a **record**, not a **view**.

The other two styles solve both problems in a single request: seven fields are carried,
seven are used. The difference between them shows up in the second scenario.

#### Who Determines the Response Shape

In the second scenario, the requirement changed: the author's name is now wanted too. Each
of the three styles had to change from a different place.

**In the resource-based style, nothing changed.** The author's name was already carried;
the used share went from 15% to 21%, and that was all. This is the unexpected benefit of
over-fetching: a field the client needs later is, more often than not, already in its
hands.

**In the remote-procedure style, the server had to change.** The method the client called
does not exist, and the server reports this. To get the new field, either the existing
method's response is extended or a new method is added; either way, the client cannot
proceed until the server is republished. In this style, the response shape is the server's
decision, so a shape change is also the server's job.

**In the query-based style, only the client changed.** `author` was added to the selection
set, the server stayed the same, the carried fields went from seven to ten, and the used
share stayed at 100%. Because the client determines the response shape, a shape change is
also the client's job.

This is the real trade-off among the styles, and its cost lands on the server side. In a
query-based interface, the server must be able to serve every field combination the client
might request; how much data access an incoming query will trigger is not known in
advance. In the remote-procedure style, by contrast, the server knows exactly what each
response will bring, because it fixed the shape itself.

#### What Style Choice Depends On

The measurement does not show that one style is better than another; it shows that every
measure has an owner.

If the number of consumers is small and their requirements resemble each other, the server
fixing the shape produces the fewest surprises. If there are many consumers and each wants
a different field combination, leaving the shape to the client spares the server from
changing for every new screen. If the consumer side relies on caching, every record having
its own address turns into an advantage: the same book requested a second time never goes
out to the network. This last point is the subject of the next lesson.

There is also one item that does not enter the measurement: **understandability**. In a
resource-based interface, addresses are concept names; someone unfamiliar with the system
can learn the domain by looking at the address list. In single-address styles, this
information lives inside the body and can only be seen by reading the contract.

#### Summary

- All three styles meet the same job, but a different side determines the response shape:
  the record itself in the resource-based style, the server in the remote-procedure style,
  the client in the query-based style.
- In the resource-based style, the measurement gave five requests and forty-seven carried
  fields; seven fields were used. Under-fetching shows up in request count, over-fetching
  in the used share.
- A request count that grows with the number of loans is the N+1 pattern; it appears in
  any design that fetches a list and makes a separate request for each row.
- When the requirement changed, the server had to change in the remote-procedure style and
  the client in the query-based style; in the resource-based style, the excess carried data
  made the change unnecessary.
- The cost of the query-based style is on the server side: the data access an incoming
  query will trigger is not known in advance, whereas the cost of a fixed-shape response is
  known.

#### Next Step

The resource-based style was the option that carried the most bytes and made the most
requests in the measurement, yet it is the most widely adopted style. This contradiction
comes from properties the measurement does not capture: every record having its own
address makes it possible for the same record, requested a second time, to never go out to
the network at all; every request being self-contained makes it unimportant which server
copy the request lands on. The next lesson names these properties as a set of constraints
and tests all three by running them: the same request giving the same response in two
separate processes, the concrete counterpart of a uniform interface, and the effect of
cacheability on bytes carried.

### REST Principles

Source: https://academia.sh/en/courses/api-design/rest-principles

The set of constraints behind the resource-based style: demonstrating statelessness by testing the same request in two separate processes, measuring the uniform interface with a client that does not know the resource name, the effect of cacheability with an entity tag on bytes carried, and how a layered system follows from these constraints.

The previous lesson measured the resource-based style and found it the most expensive
option: five requests, forty-seven carried fields, 15% used. Yet most lending services are
written in this style. The contradiction comes from properties the measurement does not
capture.

Those properties are the consequences of a set of constraints. **REST** (representational
state transfer) is not a protocol or a library; it is the name of the constraints an
interface can conform to. This lesson tests three of these constraints —
statelessness, uniform interface, and cacheability — by running them.

#### Constraints and Resource Orientation

REST's fundamental unit is the **resource**: every concept that can be referred to by a
name. In the library service, a book, a member, a loan, and a shelf are each a resource.
What the client sees is not the resource itself but a **representation**: a copy of that
resource in a specific format. The same resource can have a JSON representation as well as
an HTML representation; both describe the same thing.

The required constraints build on top of this unit:

- **Client–server separation:** the two sides evolve separately; the only bond between
  them is the interface.
- **Statelessness:** every request is self-contained; the server does not keep a context
  belonging to the client between requests.
- **Cacheability:** the response itself reports whether it can be stored.
- **Uniform interface:** resources are named the same way and processed with the same
  methods.
- **Layered system:** the client does not have to know whether the party on the other side
  is the final server or an intermediate layer.

There is a sixth constraint — **code on demand**: the server may send executable code to the
client. It is the only optional constraint among them, and this lesson does not measure it.

The following server carries two designs side by side, one that applies these constraints
and one that violates them; because it takes its port from outside, two copies of it can
be run together.

```js
// server.mjs — the port is supplied from outside so the same code can run as two copies:
// node server.mjs 8461
import { createServer } from "node:http";
import { createHash, randomUUID } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8461);
const RESOURCES = {
  books:   [{ id: "978-0262033848", title: "Introduction to Algorithms" }, { id: "978-0201896831", title: "The Art of Computer Programming" },
            { id: "978-0131103627", title: "The C Programming Language" }, { id: "978-0596007126", title: "Head First" }],
  members: [{ id: "u1", name: "Alice Kane" }, { id: "u2", name: "Ben Ortiz" }],
  loans:   [{ id: "o11", member: "u1", book: "978-0262033848" }],
  shelves: [{ id: "R-12", floor: 2 }, { id: "R-03", floor: 1 }],   // resource added later
};
const cursors = new Map();          // state kept on the server side: exists only in this copy

const json = (res, code, body, tag) => {
  const text = JSON.stringify(body);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("X-Process", process.pid);
  if (tag) res.setHeader("ETag", tag);
  res.setHeader("Content-Length", Buffer.byteLength(text));
  res.writeHead(code).end(text);
};
const tagOf = (body) =>
  `"${createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16)}"`;

createServer((req, res) => {
  res.sendDate = false;
  const url = new URL(req.url, "http://local");
  const [, first, second] = url.pathname.split("/");

  if (first === "") return json(res, 200, { resources: Object.keys(RESOURCES) });

  // Stateful pagination: the cursor is kept on the server, the request carries only the cursor id.
  if (first === "cursor" && req.method === "POST") {
    const id = randomUUID().slice(0, 8);
    cursors.set(id, 0);
    return json(res, 201, { cursor: id });
  }
  if (first === "cursor" && req.method === "GET") {
    if (!cursors.has(second)) return json(res, 404, { error: "unknown_cursor" });
    const offset = cursors.get(second);
    cursors.set(second, offset + 2);
    return json(res, 200, { offset, page: RESOURCES.books.slice(offset, offset + 2) });
  }

  // Uniform interface: every resource is named the same way, read with the same methods.
  const collection = RESOURCES[first];
  if (!collection) return json(res, 404, { error: "resource_not_found" });
  if (second) {
    const entry = collection.find((k) => k.id === second);
    if (!entry) return json(res, 404, { error: "not found" });
    const tag = tagOf(entry);
    if (req.headers["if-none-match"] === tag) {         // conditional request: no body is sent
      res.setHeader("ETag", tag);
      res.setHeader("X-Process", process.pid);
      return res.writeHead(304).end();
    }
    return json(res, 200, entry, tag);
  }
  // Stateless pagination: the page information is inside the request.
  const offset = Number(url.searchParams.get("offset") ?? 0);
  const quantity = Number(url.searchParams.get("quantity") ?? collection.length);
  return json(res, 200, { offset, quantity, entries: collection.slice(offset, offset + quantity) });
}).listen(PORT, "127.0.0.1", () => console.log(`copy :${PORT} pid=${process.pid}`));
```

The measurement client consists of three parts and distributes requests alternately
between the two copies.

```js
// measure.mjs — tests three principles: statelessness, uniform interface, cacheability
import { Agent, request } from "node:http";
import { connect } from "node:net";

let received = 0;
const agent = new Agent({ keepAlive: true });
agent.createConnection = (s) => { const b = connect(s); b.on("data", (p) => (received += p.length)); return b; };
const sendRequest = (port, path, option = {}) => new Promise((resolve, reject) => {
  const r = request({ agent, host: "127.0.0.1", port, path,
    method: option.method ?? "GET", headers: option.headers ?? {} }, (y) => {
    let m = ""; y.on("data", (p) => (m += p));
    y.on("end", () => resolve({ status: y.statusCode, tag: y.headers.etag, proc: y.headers["x-process"],
                                body: m ? JSON.parse(m) : null }));
  });
  r.on("error", reject); r.end();
});

// 1) STATELESSNESS — does the same request give the same response in two separate processes?
console.log("-- 1) statelessness: requests are distributed alternately between the two copies --");
const { body: created } = await sendRequest(8461, "/cursor", { method: "POST" });
for (const port of [8461, 8462, 8461]) {
  const y = await sendRequest(port, `/cursor/${created.cursor}`);
  console.log(`  stateful    :${port} pid=${y.proc} -> ${y.status} ${JSON.stringify(y.body).slice(0, 62)}`);
}
for (const port of [8461, 8462, 8461]) {
  const y = await sendRequest(port, "/books?offset=2&quantity=2");
  console.log(`  stateless   :${port} pid=${y.proc} -> ${y.status} ${JSON.stringify(y.body).slice(0, 62)}`);
}

// 2) UNIFORM INTERFACE — does the same client code read every resource without knowing its name?
console.log("\n-- 2) uniform interface: resource names are learned from the server --");
const { body: root } = await sendRequest(8461, "/");
for (const name of root.resources) {
  const list = await sendRequest(8461, `/${name}`);
  const first = list.body.entries[0];
  const single = await sendRequest(8461, `/${name}/${first.id}`);
  console.log(`  ${name.padEnd(9)} list=${list.status} quantity=${list.body.entries.length}` +
              `  single=${single.status} tag=${single.tag}`);
}
const missing = await sendRequest(8461, "/branches");
console.log(`  branches  list=${missing.status} ${JSON.stringify(missing.body)}`);

// 3) CACHEABILITY — how much does a conditional request cut the bytes carried?
console.log("\n-- 3) cacheability: the same record requested ten times --");
const PATH = "/books/978-0262033848";
received = 0;
for (let i = 0; i < 10; i++) await sendRequest(8461, PATH);
const unconditional = received;
received = 0;
const first = await sendRequest(8461, PATH);
for (let i = 0; i < 9; i++) await sendRequest(8461, PATH, { headers: { "If-None-Match": first.tag } });
const conditional = received;
const final = await sendRequest(8461, PATH, { headers: { "If-None-Match": first.tag } });
console.log(`  unconditional  10 requests -> ${unconditional} B`);
console.log(`  conditional    10 requests -> ${conditional} B  (final response ${final.status}, gain %${
  Math.round((100 * (unconditional - conditional)) / unconditional)})`);
agent.destroy();
```

```bash
node server.mjs 8461 & a=$!
node server.mjs 8462 & b=$!
for k in 8461 8462; do
  curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null "http://127.0.0.1:$k/"
done
node measure.mjs
kill $a $b
```

```
copy :8461 pid=24387
copy :8462 pid=24388
-- 1) statelessness: requests are distributed alternately between the two copies --
  stateful    :8461 pid=24387 -> 200 {"offset":0,"page":[{"id":"978-0262033848","title":"Introducti
  stateful    :8462 pid=24388 -> 404 {"error":"unknown_cursor"}
  stateful    :8461 pid=24387 -> 200 {"offset":2,"page":[{"id":"978-0131103627","title":"The C Prog
  stateless   :8461 pid=24387 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti
  stateless   :8462 pid=24388 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti
  stateless   :8461 pid=24387 -> 200 {"offset":2,"quantity":2,"entries":[{"id":"978-0131103627","ti

-- 2) uniform interface: resource names are learned from the server --
  books     list=200 quantity=4  single=200 tag="c80ac47429ab852d"
  members   list=200 quantity=2  single=200 tag="79aa82aae4e5fc1c"
  loans     list=200 quantity=1  single=200 tag="6f2d536c9fff5239"
  shelves   list=200 quantity=2  single=200 tag="bb5d3afab6485c63"
  branches  list=404 {"error":"resource_not_found"}

-- 3) cacheability: the same record requested ten times --
  unconditional  10 requests -> 2370 B
  conditional    10 requests -> 1317 B  (final response 304, gain %44)
```

Process ids change on every run. Because the id is carried in the `X-Process` header, its
digit count shifts the total bytes by a few, but the ratio does not change. Byte counts
also depend on the header set the runtime writes.

#### Statelessness: The Request Being Self-Contained

The first part tests two pagination designs under the same condition: requests are
distributed alternately between the two server copies. This is a small example of the
multi-copy setup behind the reverse proxy built in the previous course.

**In the stateful design,** the cursor sits in the server's memory. The first request
lands on 8461 and runs. When the second request lands on 8462, it gets a `404` because the
same cursor is not there. When the third request lands on 8461 again, the second page
comes back — meaning the error the second copy gave has also broken the cursor's state:
the client has skipped a page without ever seeing it.

**In the stateless design,** the page information is inside the request. The same request
went three times, to two separate processes, and gave the same body all three times. The
server's identity has no effect on the response whatsoever.

The cost of statelessness is resending the context on every request: identity information,
pagination position, and filter criteria are carried each time. What is gained in return is
that server copies become interchangeable. When a copy goes down, the request can be
routed to another copy, the number of copies can be scaled up under load, and none of them
has to carry the client's session.

#### Uniform Interface: The Same Client Code, Four Resources

The client in the second part does not know any resource name. It reads the resource list
from the root address, then makes the same two requests for each resource: fetch the
collection, fetch the single record by the first entry's id. All four were read with the
same code; not a single line changed in the client for the `shelves` resource that was
added later.

These are the three concrete counterparts of the uniform interface in this measurement:
**resources are named by addresses**, **the same methods mean the same thing on every
resource**, and **responses are self-descriptive** — how to read the body is understood
from the content type header, and the record's version from the entity tag. A resource that does not exist is met the
same way too, with a `404`.

The cost of this is a loss of flexibility. When an operation does not sit on a single
resource — something like "lend these three books at once and recompute the overdue fine"
— the uniform interface does not accommodate that operation naturally. Such operations are
either turned into resources themselves, or the interface moves closer to a remote
procedure call at that point.

#### Cacheability: Requesting the Same Record Ten Times

The third part requests the same book ten times. In this run, the unconditional requests
carry 2370 bytes. In the second round, the client saves the entity tag from the first
response and attaches it to the next nine requests with the `If-None-Match` header; the
server sees the record has not changed and returns a bodyless `304`. The bytes carried
drop to 1317 — a 44% reduction.

The entity tag was introduced in the Static File Serving lesson; the difference here is
that the same mechanism is applied to a computed record. The source of the gain is the
body not being sent; the headers are still carried, so the reduction cannot reach 100%.
The ratio rises as the record grows: the record in this measurement carries only two
fields.

#### Layered System

All three measurements together make a fourth constraint possible too. If the response
depends only on the request and reports its own cacheability, a layer can be placed
between the client and the server: a reverse proxy, a cache, or a router. The client does
not know whether the party on the other side is the final server or an intermediate layer,
and it does not need to.

The reverse proxy built in the Web Server Concept lesson of the previous course is exactly
this. That lesson showed that the proxy also brings a loss of information; the additional
observation here is that the proxy's ability to step in depends on the interface being
stateless and cacheable. In a design that keeps sessions on the server, the proxy is
forced to send the request to the specific copy that holds the session, not to just any
copy.

#### Summary

- REST is not a protocol but a set of constraints a resource-oriented interface conforms
  to; the fundamental unit is the resource, and what gets carried is the resource's
  representation.
- Statelessness was seen directly in the measurement: the cursor kept on the server could
  not be found on the second copy and a page was skipped; page information carried inside
  the request gave the same response on both copies.
- The uniform interface means a single client code that does not know the resource name
  can read all four resources; the resource added later required no change in the client.
- A conditional request cut the bytes carried by about 44% when the same record was
  fetched ten times; the gain comes from the body not being sent, and it cannot be
  complete because the headers keep being carried.
- A layered system depends on these two constraints: a proxy or a cache can be placed in
  between only when the response depends solely on the request and is storable.

#### Next Step

REST's constraints lean the interface on the rules of the network: addresses, methods,
status codes, headers. In return, the contract stays textual and loose — a field's type or
a method's signature can only be known by reading the documentation. There is an approach
that does the opposite: writing the contract first in a separate schema file, generating
both the client and server side from that schema, and using the wire in binary form
instead of text. The next lesson builds this approach: it writes a schema file, generates
a validator from it, writes its own binary framing, and compares the byte count of the
same record in binary and text form.

### Remote Procedure Call and gRPC

Source: https://academia.sh/en/courses/api-design/remote-procedure-call-and-grpc

The approach that writes the contract first: a validator generated from a schema file, the field number being the wire's identity, a byte comparison between a custom binary framing and the text format, and how gRPC brings this design together with stream types.

The previous lesson tested REST's constraints. What those constraints have in common is
that they lean the contract on the rules of the network: addresses, methods, status codes.
The contract's detail — a field's type, whether a field is required — stays outside the
wire, in the documentation.

There is an approach that does the opposite. **Remote procedure call** presents an
operation over the network as though it were a local function call: the client calls a
function named `GetBook`, the call goes out over the network, runs on the other side, and
returns a result. For this presentation to work, the two sides must agree on the call's
signature, and this agreement lives not in documentation but in a machine-readable
**schema**.

#### The Limit of the Procedure Call Analogy

The analogy is useful but not exact, and knowing where it breaks down determines the
interface's design. A local call is too fast to measure; a remote call takes at least one
network round trip in the best case. A local call either runs or throws an exception; a
remote call can produce a third outcome — **partial failure**: it is not known whether the
request went out, or whether the work was done. In a local call, arguments are objects in
memory; in a remote call, they have to be converted into a byte sequence, and the rules of
that conversion are the contract itself.

These three differences show up in the design of interfaces that use the
remote-procedure-call style: calls are kept **coarse-grained** (a small number of full
calls instead of a large number of small ones), retry behavior is defined explicitly, and
the data format is tightly specified.

#### The Contract Is Written First

The schema file is the interface's single source of truth. The following file defines a
message type and a service; every field has a **number**, a type, a name, and a
requiredness.

```
message Book {
  1 string  isbn      required
  2 string  title     required
  3 int     pages     optional
  4 bool    onLoan    required
}
service Catalog {
  GetBook(GetBookRequest) -> Book
  SearchBooks(SearchRequest) -> stream Book
}
```

Two things are generated from this file: a **validator** that checks the presence and type
of fields, and an **encoder** that converts an object into a byte sequence. The encoder's
format never puts field names on the wire at all; every field is referred to by its own
number, and numbers are written at variable length.

```js
// generate.mjs — generates a validator and a binary encoder from the schema file (the contract comes first).
import { readFileSync } from "node:fs";

export const readSchema = (file) => {
  const messages = {}, services = {};
  let open = null;
  for (const raw of readFileSync(file, "utf8").split("\n")) {
    const line = raw.trim();
    if (!line) continue;
    let e;
    if ((e = /^message (\w+) \{$/.exec(line))) { open = messages[e[1]] = []; continue; }
    if ((e = /^service (\w+) \{$/.exec(line))) { open = services[e[1]] = []; continue; }
    if (line === "}") { open = null; continue; }
    if ((e = /^(\d+) (\w+)\s+(\w+)\s+(\S+)$/.exec(line)))
      open.push({ number: +e[1], type: e[2], name: e[3], required: e[4] === "required" });
    else if ((e = /^(\w+)\((\w+)\) -> (stream )?(\w+)$/.exec(line)))
      open.push({ method: e[1], input: e[2], stream: Boolean(e[3]), output: e[4] });
  }
  return { messages, services };
};

const TYPES = { string: "string", int: "number", bool: "boolean" };

// Generated validator: checks fields for presence and type against the schema.
export const makeValidator = (fields) => (obj) => {
  const defects = [];
  for (const f of fields) {
    const v = obj[f.name];
    if (v === undefined) { if (f.required) defects.push(`${f.name}: required field missing`); continue; }
    if (typeof v !== TYPES[f.type]) defects.push(`${f.name}: expected ${f.type}, got ${typeof v}`);
  }
  for (const name of Object.keys(obj))
    if (!fields.some((f) => f.name === name)) defects.push(`${name}: not in schema`);
  return defects;
};

// Variable-length integer: small numbers take fewer bytes.
const writeVarint = (n) => {
  const b = [];
  do { let p = n & 0x7f; n >>>= 7; b.push(n ? p | 0x80 : p); } while (n);
  return Buffer.from(b);
};
const readVarint = (b, i) => {
  let n = 0, k = 0;
  while (b[i] & 0x80) { n |= (b[i++] & 0x7f) << k; k += 7; }
  return [n | (b[i++] << k), i];
};

// Binary framing: each field is written with a (number<<3 | wireType) key; the field name never reaches the wire.
export const makeEncoder = (fields) => ({
  encode(obj) {
    const parts = [];
    for (const f of fields) {
      const v = obj[f.name];
      if (v === undefined) continue;
      const wireType = f.type === "string" ? 2 : 0;
      parts.push(writeVarint((f.number << 3) | wireType));
      if (wireType === 0) parts.push(writeVarint(f.type === "bool" ? (v ? 1 : 0) : v));
      else { const buf = Buffer.from(v, "utf8"); parts.push(writeVarint(buf.length), buf); }
    }
    return Buffer.concat(parts);
  },
  decode(bytes) {
    const obj = {};
    let i = 0;
    while (i < bytes.length) {
      let key; [key, i] = readVarint(bytes, i);
      const field = fields.find((f) => f.number === key >> 3);
      if ((key & 7) === 2) {
        let length; [length, i] = readVarint(bytes, i);
        obj[field?.name ?? `unknown_${key >> 3}`] = bytes.toString("utf8", i, i + length);
        i += length;
      } else {
        let value; [value, i] = readVarint(bytes, i);
        obj[field?.name ?? `unknown_${key >> 3}`] = field?.type === "bool" ? Boolean(value) : value;
      }
    }
    return obj;
  },
});
```

#### Measurement

The measurement answers four questions: what surface is generated from the schema, which
records the validator rejects, how many bytes the binary format takes, and what happens to
the bytes on the wire when a field name changes?

```js
// measure.mjs — tests the schema-first contract: validation, binary/text byte comparison, field renaming
import { writeFileSync } from "node:fs";
import { readSchema, makeValidator, makeEncoder } from "./generate.mjs";

const { messages, services } = readSchema("library.schema");
const fields = messages.Book;
const validate = makeValidator(fields);
const { encode, decode } = makeEncoder(fields);

console.log("-- 1) service surface generated from the schema --");
for (const y of services.Catalog)
  console.log(`  ${y.method}(${y.input}) -> ${y.stream ? "stream " : ""}${y.output}`);

console.log("\n-- 2) generated validator --");
const SAMPLES = [
  { isbn: "978-0262033848", title: "Introduction to Algorithms", pages: 1312, onLoan: false },
  { isbn: "978-0201896831", title: "The Art of Computer Programming", onLoan: true },   // no optional field
  { isbn: "978-0131103627", onLoan: true },                                             // required field missing
  { isbn: "978-0596007126", title: "Head First", pages: "272", onLoan: true },          // wrong type
  { isbn: "978-0000000000", title: "Test", onLoan: true, shelf: "R-12" },               // field not in schema
];
for (const o of SAMPLES) {
  const defects = validate(o);
  console.log(`  ${JSON.stringify(o).slice(0, 52).padEnd(54)} ${defects.length ? "FAIL  " + defects[0] : "PASS"}`);
}

console.log("\n-- 3) byte count of binary framing vs. text format --");
const valid = SAMPLES.filter((o) => validate(o).length === 0);
for (const o of valid) {
  const binary = encode(o), text = Buffer.from(JSON.stringify(o), "utf8");
  console.log(`  ${o.isbn}  binary=${String(binary.length).padStart(3)} B  ` +
    `text=${String(text.length).padStart(3)} B  gain=%${Math.round(100 * (1 - binary.length / text.length))}`);
}
const totalBinary = valid.reduce((t, o) => t + encode(o).length, 0);
const totalText = Buffer.byteLength(JSON.stringify(valid));
console.log(`  total           binary=${totalBinary} B  text=${totalText} B  ` +
  `gain=%${Math.round(100 * (1 - totalBinary / totalText))}`);

console.log("\n-- 4) no field name on the wire: the schema changes the field's name --");
const encoded = encode(valid[0]);
console.log(`  decoded with old schema : ${JSON.stringify(decode(encoded))}`);
writeFileSync("new.schema", `message Book {
  1 string  isbn      required
  2 string  heading   required
  3 int     pages     optional
  4 bool    onLoan    required
}
`);
const renamed = makeEncoder(readSchema("new.schema").messages.Book);
console.log(`  decoded with new schema : ${JSON.stringify(renamed.decode(encoded))}`);
console.log(`  same bytes, ${encoded.length} B; the only thing that changed is the name attached to the field number.`);
```

```
-- 1) service surface generated from the schema --
  GetBook(GetBookRequest) -> Book
  SearchBooks(SearchRequest) -> stream Book

-- 2) generated validator --
  {"isbn":"978-0262033848","title":"Introduction to Al   PASS
  {"isbn":"978-0201896831","title":"The Art of Compute   PASS
  {"isbn":"978-0131103627","onLoan":true}                FAIL  title: required field missing
  {"isbn":"978-0596007126","title":"Head First","pages   FAIL  pages: expected int, got string
  {"isbn":"978-0000000000","title":"Test","onLoan":tru   FAIL  shelf: not in schema

-- 3) byte count of binary framing vs. text format --
  978-0262033848  binary= 49 B  text= 90 B  gain=%46
  978-0201896831  binary= 51 B  text= 81 B  gain=%37
  total           binary=100 B  text=174 B  gain=%43

-- 4) no field name on the wire: the schema changes the field's name --
  decoded with old schema : {"isbn":"978-0262033848","title":"Introduction to Algorithms","pages":1312,"onLoan":false}
  decoded with new schema : {"isbn":"978-0262033848","heading":"Introduction to Algorithms","pages":1312,"onLoan":false}
  same bytes, 49 B; the only thing that changed is the name attached to the field number.
```

#### What the Measurement Says

**Validation is generated from the contract, not hand-written.** Three of the five records
were rejected, and the three rejections are of three separate kinds: a missing required
field, a wrong type, a field not in the schema. None of these checks were written in the
application code; they came out of the lines in the schema file. When the schema changes,
validation changes with it, and the two sides' rules cannot drift apart.

**The binary format reduces the same record to roughly half the bytes.** The source of the
gain is not compression but the unnecessary never being written at all: field names,
quotation marks, colons, and commas never reach the wire. Numbers, too, are written not as
text but as a variable-length integer — `1312` takes two bytes instead of four characters.

**The field number is the identity, not the field name.** The fourth part repeats a change
considered breaking in the first lesson: the `title` field is renamed to `heading`. In the
text format, this change had broken the consumer. In the binary format, the same 49-byte
sequence decodes without any trouble under the new schema; only the key in the output
changes. The identity on the wire is the number `2`.

The mirror image of this is also true, and more dangerous: **changing the number breaks
silently.** If two fields' numbers are swapped, the validator says nothing, because the
bytes are valid; only the values land in the wrong fields. This is where schema-first
design's invariant rule comes from: once a field number has been assigned, it is never
given to another field, and a retired number is never reused.

#### What gRPC Brings

The schema, validator, and framing in this lesson are a small example of this design.
**gRPC** offers the same structure as a ready-made whole: messages are defined with a
**Protocol Buffers** schema, both the client and server sides are generated from that
schema, and **HTTP/2** is used as the transport layer.

Choosing HTTP/2 is not a detail; it is what makes four call types possible. The `stream`
marker in the schema file shows this: a call can be single-request–single-response, but it
can also be server-side streaming (one request, many responses), client-side streaming
(many requests, one response), or bidirectional streaming. In the library service, jobs
like "send the shelf count as a stream" or "give catalog matches as they come in" fit these
types.

Its cost is just as clear. The content on the wire cannot be read by eye; inspecting a
request requires a tool that knows the schema. A browser cannot speak this protocol
directly; a translation layer has to sit in between. And using the interface requires
access to the other side's schema — where REST is content with sending a request to an
address and reading the response, here not a single byte can be understood without the
contract.

These costs also explain where gRPC is used the most: among a system's own components, in
places where both sides are managed by the same team and call volume is high.

#### Summary

- Remote procedure call presents a network operation as though it were a local call; the
  analogy breaks down at latency, partial failure, and byte conversion, and these points
  shape the design.
- In schema-first design, the contract is a machine-readable file; the validator and
  encoder are generated from it, so the two sides' rules cannot drift apart.
- In the measurement, binary framing reduced the same records to roughly half the bytes of
  the text format; the gain comes from field names and delimiters never reaching the wire
  at all.
- The identity on the wire is the field number: when the field name changes, the same
  bytes decode without trouble, but when the number changes, the error shows up silently
  as a wrong value.
- gRPC combines this design with a Protocol Buffers schema and HTTP/2 transport; it makes
  four call types possible, and in return readability and direct browser access are lost.

#### Next Step

Schema-first design fixes the response shape in the contract, and this returns to the
problem measured in the second lesson: when the client's requirement changes, the server
has to change. There is also an approach that does the exact opposite — one where the
client determines the shape, but does so not with a loose selection text but inside a type
system the server publishes. The next lesson builds this approach: a single endpoint, a
client-driven query bounded by a type system, per-field resolution, and the notion of a
partial success response.

### GraphQL

Source: https://academia.sh/en/courses/api-design/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.

### Legacy Protocols

Source: https://academia.sh/en/courses/api-design/legacy-protocols

The design and legacy of envelope-based protocols: separating header from body, carrying the operation name inside the envelope, reporting errors inside the envelope, how the envelope's cost changes with record count, and which of today's interface decisions were inherited from here.

The previous lesson built an interface that works over a single endpoint, with a
selection document carried in the body. This mechanism is not new. There is a family of
protocols that built the same idea — single address, single method, naming the operation
in the body — much earlier, and part of today's interface decisions are inherited
directly from there.

This family's common building block is the **envelope**: a container that wraps the
entire message and holds a **header** and a **body** inside it. **SOAP** is the
best-known example of this structure and is built on XML; its `Envelope`, `Header`,
`Body`, and `Fault` elements are exactly what this lesson's `Envelope`, `Header`, `Body`,
and `Fault` elements are built to match.

#### The Idea of the Envelope

The problem the envelope solves is this: carrying a message's own metadata alongside it,
independent of the protocol carrying the message. HTTP headers already do this job, but
the envelope design is not tied to HTTP — the same envelope can travel over a message
queue or a mail message too. Its cost and its gain sit together.

The envelope has three structural decisions:

- **The header carries cross-cutting concerns:** the trace id, credentials, the operation
  name. Intermediate layers can read this part and decide without ever parsing the body.
- **The body carries the payload:** data specific to the operation. The payload's shape
  changes from operation to operation.
- **The error also goes into the body:** failure is reported by a fault element inside the
  body. Even when the transport carries a status code — SOAP's HTTP binding sends the fault
  with a `500` — the error's contract lives inside the envelope; on transports like a queue
  or mail it is the only place.

#### A Small Implementation

The following file contains the functions that build and read the envelope. The XML
parser converts nested elements into an object; it does not support attributes or
namespaces, because what the lesson needs is the structure itself.

```js
// envelope.mjs — a small XML parser and envelope builders.
// Envelope/Header/Body/Fault elements are this lesson's counterpart to SOAP's Envelope/Header/Body/Fault.
const escape = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const unescape = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");

export const writeXml = (name, content) =>
  typeof content === "object" && content !== null
    ? `<${name}>${Object.entries(content).map(([a, d]) => writeXml(a, d)).join("")}</${name}>`
    : `<${name}>${escape(content)}</${name}>`;

// Parser: turns nested elements into an object. Siblings with the same name are collected into an array.
export const readXml = (text) => {
  const element = /<(\w+)>([\s\S]*?)<\/\1>/g;
  const output = {};
  let e, found = false;
  while ((e = element.exec(text))) {
    found = true;
    const value = /<\w+>/.test(e[2]) ? readXml(e[2]) : unescape(e[2]);
    if (e[1] in output) output[e[1]] = [].concat(output[e[1]], value);
    else output[e[1]] = value;
  }
  return found ? output : unescape(text);
};

export const wrap = (operation, trace, body) =>
  `<Envelope>${writeXml("Header", { Operation: operation, Trace: trace })}` +
  `<Body>${Object.entries(body).map(([a, d]) => writeXml(a, d)).join("")}</Body></Envelope>`;

export const faultEnvelope = (code, reason, detail) =>
  `<Envelope><Header><Operation>Fault</Operation></Header><Body>` +
  writeXml("Fault", { Code: code, Reason: reason, Detail: detail }) + `</Body></Envelope>`;

// The one place the intermediary reads: the header can be taken without parsing the body at all.
export const extractHeader = (envelope) => readXml(/<Header>[\s\S]*?<\/Header>/.exec(envelope)[0]).Header;
```

```js
// server.mjs — single endpoint, single method: the operation name is in the envelope's header.
import { createServer } from "node:http";
import { readXml, wrap, faultEnvelope, extractHeader } from "./envelope.mjs";

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 },
};

createServer(async (req, res) => {
  res.sendDate = false;
  let raw = "";
  for await (const p of req) raw += p;
  const header = extractHeader(raw);                  // routing: only the header is read
  const body = readXml(raw).Envelope.Body;
  let output;
  if (header.Operation === "GetBook") {
    const book = BOOKS[body.BookRequest.isbn];
    output = book ? wrap("GetBookResponse", header.Trace, { Book: book })
                  : faultEnvelope("Client", "book not found", `isbn: ${body.BookRequest.isbn}`);
  } else output = faultEnvelope("Client", "unknown operation", header.Operation);
  res.setHeader("Content-Type", "text/xml; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(output));
  // Both faults and successful requests return with the same status code: the error is in the envelope, not the transport layer.
  res.writeHead(200).end(output);
}).listen(8481, "127.0.0.1", () => console.log("envelope endpoint 127.0.0.1:8481/service"));
```

#### Measurement

```js
// measure.mjs — measures three properties of the envelope-based protocol
import { wrap, readXml, extractHeader } from "./envelope.mjs";

const call = async (operation, trace, body) => {
  const request = wrap(operation, trace, body);
  const y = await fetch("http://127.0.0.1:8481/service",
    { method: "POST", headers: { "Content-Type": "text/xml" }, body: request });
  const text = await y.text();
  return { status: y.status, sent: Buffer.byteLength(request), received: Buffer.byteLength(text),
           text, parsed: readXml(text).Envelope.Body };
};

console.log("-- 1) single endpoint, operation name in the envelope --");
const good = await call("GetBook", "trace-1", { BookRequest: { isbn: "978-0262033848" } });
console.log(`  request  : ${wrap("GetBook", "trace-1", { BookRequest: { isbn: "978-0262033848" } })}`);
console.log(`  response : ${good.text}`);
console.log(`  HTTP ${good.status}  sent=${good.sent} B  received=${good.received} B`);

console.log("\n-- 2) envelope cost: the same data in JSON and envelope form --");
const RECORD = { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", pages: 1312 };
for (const quantity of [1, 5, 20]) {
  const list = Array.from({ length: quantity }, () => RECORD);
  const json = Buffer.byteLength(JSON.stringify({ Book: list }));
  const envelope = Buffer.byteLength(wrap("GetBookResponse", "trace-1", { Book: list }));
  console.log(`  ${String(quantity).padStart(2)} record  json=${String(json).padStart(4)} B  ` +
    `envelope=${String(envelope).padStart(4)} B  difference=%${Math.round(100 * (envelope / json - 1))}`);
}

console.log("\n-- 3) the error is in the envelope, not the transport layer --");
const bad = await call("GetBook", "trace-2", { BookRequest: { isbn: "978-0000000000" } });
console.log(`  response : ${bad.text}`);
console.log(`  a client that looks at the status code -> HTTP ${bad.status} : "request successful"`);
console.log(`  a client that parses the body           -> ${JSON.stringify(bad.parsed.Fault)}`);

console.log("\n-- 4) an intermediary reads the header without parsing the body --");
for (const msg of [good.text, bad.text]) {
  const bodyLength = /<Body>[\s\S]*<\/Body>/.exec(msg)[0].length;
  console.log(`  header=${JSON.stringify(extractHeader(msg))}  (body ${bodyLength} B, not parsed)`);
}
```

```bash
node server.mjs & p=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null -X POST \
  -d '<Envelope><Header><Operation>x</Operation></Header><Body></Body></Envelope>' http://127.0.0.1:8481/service
node measure.mjs
kill $p
```

```
envelope endpoint 127.0.0.1:8481/service
-- 1) single endpoint, operation name in the envelope --
  request  : <Envelope><Header><Operation>GetBook</Operation><Trace>trace-1</Trace></Header><Body><BookRequest><isbn>978-0262033848</isbn></BookRequest></Body></Envelope>
  response : <Envelope><Header><Operation>GetBookResponse</Operation><Trace>trace-1</Trace></Header><Body><Book><isbn>978-0262033848</isbn><title>Introduction to Algorithms</title><author>Cormen</author><pages>1312</pages></Book></Body></Envelope>
  HTTP 200  sent=157 B  received=234 B

-- 2) envelope cost: the same data in JSON and envelope form --
   1 record  json= 104 B  envelope= 241 B  difference=%132
   5 record  json= 480 B  envelope= 709 B  difference=%48
  20 record  json=1890 B  envelope=2484 B  difference=%31

-- 3) the error is in the envelope, not the transport layer --
  response : <Envelope><Header><Operation>Fault</Operation></Header><Body><Fault><Code>Client</Code><Reason>book not found</Reason><Detail>isbn: 978-0000000000</Detail></Fault></Body></Envelope>
  a client that looks at the status code -> HTTP 200 : "request successful"
  a client that parses the body           -> {"Code":"Client","Reason":"book not found","Detail":"isbn: 978-0000000000"}

-- 4) an intermediary reads the header without parsing the body --
  header={"Operation":"GetBookResponse","Trace":"trace-1"}  (body 136 B, not parsed)
  header={"Operation":"Fault"}  (body 115 B, not parsed)
```

#### What the Measurement Says

**The envelope's cost is fixed, the payload's cost is variable.** For a single record, the
envelope is 132% larger than the same data's JSON form; at twenty records, this difference
drops to 31%. The envelope itself — the header section, the wrapping elements — is a
fixed cost independent of record count; the per-record overhead comes from the closing
tags and repeats with every record. The envelope is expensive for small, frequent calls;
the ratio drops for large payloads.

**The cost of reporting the error in the envelope lands on the consumer.** When a
nonexistent book was requested, the server returned `200`. A client that looks at the
status code counts the request as successful; seeing the error requires parsing the body.
This is the source of the error contract splitting in two, mentioned in the fourth lesson,
and it comes back in exactly the same shape in GraphQL's partial success response.

**The header can be read independently of the body.** The last part of the measurement
shows that a body over a hundred bytes can have its header taken without being parsed at
all. This means an intermediate layer — a router, an authorization layer, a tracing layer
— can do its job without understanding the payload. The **trace id** the header carries
links the same request's records across different components together.

#### The Legacy Left Behind

The aspects of envelope-based design carried forward to today are a direct continuation of
the properties seen in the measurement.

**A machine-readable contract.** This family's interfaces publish which operations exist
and each operation's input–output types in a separate definition file. Two habits remain
from this: the contract being an executable file separate from documentation, and the
client side being generated from that file. The same idea shows up as the schema file in
the fourth lesson, and later in this course as OpenAPI- and JSON-Schema-based definitions.

**A structured error.** The triple inside the fault element — code, reason, detail — is
the ancestor of today's standard error bodies: a code the machine will read, an
explanation the human will read, and a context-specific extra. This structure is detailed
in the Error Response Format lesson.

**Separating cross-cutting concerns.** Keeping identity, trace, and routing information
apart from the payload has been preserved; in HTTP-based interfaces, this separation lives
in HTTP headers instead of the envelope's header.

**Carrying the operation name in the body.** The pattern of a single endpoint with the
operation name in the body persists in both remote-procedure-call-style HTTP interfaces
and in GraphQL.

What was not inherited is just as clear. The desire to be transport-independent renders
HTTP's own semantics — methods, status codes, conditional requests, cache directives —
unused. The cacheability gain measured in the third lesson cannot be obtained in a design
where every request is `POST`. Together with the text format's overhead of detail, this is
the real reason that opened the way for designs that do the same job with fewer bytes and
fewer layers.

#### Summary

- Envelope-based protocols work with a container that wraps the message as a header and a
  body; the operation name is carried not in the address but in the envelope's header, and
  the transport layer is treated as replaceable.
- In the measurement, the envelope's byte cost came out to 132% for a single record and
  31% for twenty records: the envelope itself is a fixed cost, and the closing tags are a
  cost that repeats per record.
- Because the server in the measurement reports the error only inside the envelope, it
  returns `200` even on a failed operation; a client that looks at the status code cannot
  see the error and is forced to parse the body.
- Because the header can be read independently of the body, intermediate layers can do
  routing, authorization, and tracing without ever parsing the payload.
- The inherited legacy is four items: the machine-readable contract file, the structured
  error shape, separating cross-cutting concerns from the payload, and a named operation
  over a single endpoint.

#### Next Step

The shared assumption of the five styles so far is the same: the client initiates
communication, the server responds, the exchange ends. Some of the library service's jobs
do not fit this pattern. A book returning to the shelf is an event that happens on the
server, and nobody asks the waiting client about it. A count job runs for minutes, and
keeping the request open is not an option. The next lesson builds communication forms
outside the request–response pattern and compares two of them by measurement: how many
connections and how much delay does an event stream versus long polling take to deliver
the same notification?

### Synchronous and Asynchronous APIs

Source: https://academia.sh/en/courses/api-design/synchronous-and-asynchronous-apis

Communication forms outside the request–response pattern: event stream, long polling, and short polling are compared on the same event sequence by connection count, bytes carried, and delivery delay; a long-running job is modeled with an accepted request and a separate status resource.

The five styles so far shared a common assumption: the client initiates communication,
the server responds, the exchange ends. Some of the library service's jobs do not fit
this pattern.

Three situations break this pattern. First, **the event occurs on the server**: nobody
notifies a client waiting for a book to return to the shelf, and the client does not know
when to ask either. Second, **the work takes a long time**: a branch count job runs for
minutes, and keeping the request open for that whole time holds up both the connection
and the client. Third, **the same notification goes out to many consumers**: each
consumer asking separately repeats the same work.

This lesson writes and measures the first situation in three forms, then builds the
pattern for the second situation.

#### Publishing the Same Event in Three Forms

The server publishes six events — books returning to the shelf — and serves the same
event sequence from three separate endpoints. **Event stream** keeps the connection open
and writes as an event occurs. **Long polling** holds the request without a response
until a new event comes up. **Short polling** answers every request immediately; it can
come back empty, and the client asks again at fixed intervals.

In all three, the client sends the id of the last event it has seen. This is required in
the polling forms: events that occur in the gap between two requests would otherwise be
lost.

```js
// server.mjs — publishes the same event stream in three forms: event stream, long polling, short polling
import { createServer } from "node:http";

const EVENTS = [];               // published events; the id is the array index
const waiters = [];              // requests waiting for a response in long polling
const streams = new Set();       // open event-stream connections
const jobs = new Map();          // state of long-running jobs
let jobCounter = 0;

const publish = (event) => {
  EVENTS.push(event);
  for (const y of streams) y.write(`id: ${EVENTS.length}\ndata: ${JSON.stringify(event)}\n\n`);
  while (waiters.length) waiters.pop()();
};

const json = (res, body) => {
  const text = JSON.stringify(body);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(text));
  res.writeHead(200).end(text);
};
const after = (n) => EVENTS.slice(n).map((o, i) => ({ id: n + i + 1, ...o }));

createServer((req, res) => {
  res.sendDate = false;
  const url = new URL(req.url, "http://local");
  const since = Number(url.searchParams.get("after") ?? 0);

  // 1) Event stream: the connection stays open, the server writes as events occur.
  if (url.pathname === "/stream") {
    res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store",
                         Connection: "keep-alive" });
    for (const o of after(since)) res.write(`id: ${o.id}\ndata: ${JSON.stringify(o)}\n\n`);
    streams.add(res);
    req.on("close", () => streams.delete(res));
    return;
  }
  // 2) Long polling: if there is no new event, the response is held; it is written once the event arrives.
  if (url.pathname === "/long") {
    if (EVENTS.length > since) return json(res, { events: after(since) });
    const timeout = setTimeout(() => {
      const k = waiters.indexOf(resume); if (k >= 0) waiters.splice(k, 1);
      json(res, { events: [] });
    }, 2000);
    const resume = () => { clearTimeout(timeout); json(res, { events: after(since) }); };
    waiters.push(resume);
    return;
  }
  // 3) Short polling: every request is answered immediately, the client asks again at fixed intervals.
  if (url.pathname === "/short") return json(res, { events: after(since) });
  // 4) Long-running job: the request is accepted, the result is tracked from a separate resource.
  if (url.pathname === "/counts" && req.method === "POST") {
    const id = `job-${++jobCounter}`;
    jobs.set(id, { status: "running", result: null });
    setTimeout(() => jobs.set(id, { status: "done", result: { missing: 3 } }), 600);
    res.setHeader("Location", `/jobs/${id}`);
    res.setHeader("Content-Type", "application/json; charset=utf-8");
    return res.writeHead(202).end(JSON.stringify({ job: id }));
  }
  if (url.pathname.startsWith("/jobs/")) return json(res, jobs.get(url.pathname.slice(6)));
  // The publication is triggered from outside so the measurements run under the same condition.
  if (url.pathname === "/start") { start(); return json(res, { started: true }); }
  return json(res, { error: "not found" });
}).listen(8491, "127.0.0.1", () => console.log("event endpoint 127.0.0.1:8491"));

// Six events, at 250 ms intervals: a book returning to the shelf is an event that occurs on the server.
const start = () => {
  let n = 0;
  const t = setInterval(() => {
    publish({ type: "book_back_on_shelf", isbn: `978-000000000${n}`, at: Date.now() });
    if (++n === 6) clearInterval(t);
  }, 250);
};
```

For the measurement to be valid, all three clients have to watch the same event sequence
at the same time. So the publication does not start on its own: it is triggered from
outside once all three clients are connected.

```js
// measure.mjs — measures three forms on the same event stream, at the same time: connections, bytes, delivery delay
import { Agent, request } from "node:http";
import { connect } from "node:net";

const createClient = () => {
  const state = { connections: 0, requests: 0, bytes: 0, delays: [] };
  const agent = new Agent({ keepAlive: false });     // every request opens a new connection
  agent.createConnection = (s) => {
    state.connections++;
    const b = connect(s);
    b.on("data", (p) => (state.bytes += p.length));
    return b;
  };
  return { state, agent };
};
const record = (state, events) => {
  for (const o of events) state.delays.push(Date.now() - o.at);
  return events.length ? events[events.length - 1].id : null;
};
const get = (client, path) => new Promise((resolve) => {
  client.state.requests++;
  request({ agent: client.agent, host: "127.0.0.1", port: 8491, path }, (y) => {
    let m = ""; y.on("data", (p) => (m += p)); y.on("end", () => resolve(JSON.parse(m)));
  }).end();
});

// 1) EVENT STREAM — a single connection stays open, the client reads as the server writes
const stream = createClient();
const streamTask = new Promise((done) => {
  stream.state.requests++;
  request({ agent: stream.agent, host: "127.0.0.1", port: 8491, path: "/stream?after=0" }, (y) => {
    y.on("data", (p) => {
      for (const block of String(p).split("\n\n")) {
        const e = /data: (.+)/.exec(block);
        if (e) record(stream.state, [JSON.parse(e[1])]);
      }
      if (stream.state.delays.length === 6) { y.destroy(); done(); }
    });
  }).end();
});

// 2) LONG POLLING — the server holds the response until an event arrives; the client reconnects
const long = createClient();
const longTask = (async () => {
  let last = 0;
  while (long.state.delays.length < 6)
    last = record(long.state, (await get(long, `/long?after=${last}`)).events) ?? last;
})();

// 3) SHORT POLLING — asking again at a fixed interval
const short = createClient();
const shortTask = (async () => {
  let last = 0;
  while (short.state.delays.length < 6) {
    last = record(short.state, (await get(short, `/short?after=${last}`)).events) ?? last;
    if (short.state.delays.length < 6) await new Promise((c) => setTimeout(c, 500));
  }
})();

await new Promise((c) => setTimeout(c, 200));       // publication starts once all three clients are connected
await fetch("http://127.0.0.1:8491/start");
await Promise.all([streamTask, longTask, shortTask]);

const summarize = (label, state) => {
  const d = state.delays;
  console.log(`  ${label.padEnd(14)} connections=${String(state.connections).padStart(2)}  ` +
    `bytes=${String(state.bytes).padStart(4)}  events=${d.length}  ` +
    `average delay=${String(Math.round(d.reduce((t, v) => t + v, 0) / d.length)).padStart(3)} ms  ` +
    `max=${String(Math.max(...d)).padStart(3)} ms`);
};
console.log("-- six events, published at 250 ms intervals --");
summarize("event stream", stream.state);
summarize("long polling", long.state);
summarize("short polling", short.state);

// 4) LONG-RUNNING JOB — the request is accepted, the result is tracked from a separate resource
console.log("\n-- long-running job: accepted request --");
const t0 = Date.now();
const accepted = await fetch("http://127.0.0.1:8491/counts", { method: "POST" });
const location = accepted.headers.get("location");
console.log(`  POST /counts -> HTTP ${accepted.status}  Location: ${location}`);
for (let i = 0; i < 5; i++) {
  const d = await (await fetch(`http://127.0.0.1:8491${location}`)).json();
  console.log(`  GET ${location} (+${Date.now() - t0} ms) -> ${JSON.stringify(d)}`);
  if (d.status === "done") break;
  await new Promise((c) => setTimeout(c, 200));
}
```

```bash
node server.mjs > /dev/null & p=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null "http://127.0.0.1:8491/short?after=0"
node measure.mjs
kill $p
```

```
-- six events, published at 250 ms intervals --
  event stream   connections= 1  bytes= 681  events=6  average delay=  3 ms  max=  4 ms
  long polling   connections= 6  bytes=1182  events=6  average delay=  2 ms  max=  2 ms
  short polling  connections= 5  bytes=1068  events=6  average delay=168 ms  max=294 ms

-- long-running job: accepted request --
  POST /counts -> HTTP 202  Location: /jobs/job-1
  GET /jobs/job-1 (+8 ms) -> {"status":"running","result":null}
  GET /jobs/job-1 (+212 ms) -> {"status":"running","result":null}
  GET /jobs/job-1 (+417 ms) -> {"status":"running","result":null}
  GET /jobs/job-1 (+624 ms) -> {"status":"done","result":{"missing":3}}
```

Delay values and byte counts depend on the machine and the headers the runtime writes;
the count of six events, the connection counts, and the ordering by size do not.

#### What the Measurement Says

**Event stream carries six events over a single connection.** The client connects once
and reads as the server writes; the delay is on the order of milliseconds. Bytes carried
are also the lowest, because a single set of headers was sent for all six events.

**Long polling achieves the same delay with six connections.** The response closes after
every event, and the client reconnects; every reconnection means a new request line and a
new set of headers. The byte count is roughly double that of the event stream. The delay
is low because the server held the request until the event arrived — the cost of waiting
is not in bytes but in the number of open requests: every waiting client holds a response
object and a connection on the server.

**Short polling ties the delay to the polling interval.** The average delay is 168
milliseconds, the largest 294 milliseconds; the polling interval is half a second, and the
expected average delay is about half the interval. Connection count here depends not on
the number of events but on the time elapsed: even with no events at all, the client keeps
asking, and every empty response adds to the bytes carried.

The choice among the three is not made by a single measure. Short polling is the simplest
and is enough where delay does not matter; to intermediate layers, it is just an ordinary
request. Long polling lowers the delay but raises the number of requests held open on the
server. Event stream wins on both counts, and in exchange it requires a long-lived
connection: intermediate layers must not close the connection, the server must be able to
allocate a connection per client, and the stream must be able to reconnect if it breaks.

This last point corresponds to the `after` parameter in the measurement.
**Server-Sent Events** sends back the id of the last event received with the
`Last-Event-ID` header when reconnecting a dropped connection, and the server picks up
where it left off. Without this mechanism, no asynchronous publication is reliable: events
that occur at the moment of disconnection are silently lost.

#### One-Way and Two-Way

Event stream is one-way: server to client. If the client also needs to send data
continuously — like a handheld terminal reporting every tag it reads during a shelf count
instantly — a two-way connection is needed, and its counterpart is the **WebSocket**
protocol. Its cost is stepping outside HTTP's semantics: the notions of method, status
code, cache directive, and conditional request no longer apply once the connection is
established. None of the gains measured in the third lesson hold over an open socket.

The selection criterion is simple: if data comes only from the server, an event stream is
enough; if both sides send data continuously, a two-way connection is needed.

#### The Accepted Request

The last part of the measurement builds the second situation. When a branch count is
requested, the server does not try to finish the work; it **accepts** the request,
returns `202`, and reports the resource the job will be tracked from with the `Location`
header. The client learns the status by asking that resource; once the job finishes, the
same resource carries the result.

This pattern has three benefits. Request duration is separated from the job's duration, so
the timeout problem disappears. Because the job's status is a resource, it stays inside
the uniform interface; it can be listed, canceled, stored. And what happens when the same
job is requested twice becomes a separate decision — this is the subject of this course's
Idempotency Keys lesson.

It is not even required for the client to ask in order to learn that the job is done: when
the status resource is combined with an event stream, the notification reaches the client
on its own once the job finishes. The two patterns are used together, not as substitutes
for each other.

#### Summary

- The request–response pattern falls short in three situations: an event occurring on the
  server, work taking a long time, and the same notification going out to many consumers.
- In the measurement, event stream carried six events over one connection and 681 bytes,
  long polling over six connections and 1182 bytes; both stayed at millisecond-level
  delay.
- In short polling, the delay depends on the polling interval: at a half-second interval,
  an average of 168 and a maximum of 294 milliseconds were measured, and the connection
  count tracked the time elapsed rather than the event count.
- Every polling form has to carry the id of the last event seen; events that occur during
  a disconnection or in the gap between requests would otherwise be lost.
- A long-running job is accepted with `202` and tracked with a separate status resource;
  this separates request duration from job duration, and the job turns into a resource
  inside the uniform interface.

#### Next Step

This topic compared the styles and measured what each one makes easier and what it makes
harder. What comes next is the detail of a choice: actually designing the resource-based
style. In the measurements, resources were assumed ready-made — book, member, loan, job —
yet their being resources was a decision. Which domain concepts turn into a resource, and
which stay as another resource's field? Which resource does an action like "lend the book"
sit on? The next topic's first lesson starts with this question and derives the resource
model from the library domain's concepts.

## Resource and Contract Design

Turning domain concepts into resources, URI layout, mapping HTTP methods and status codes correctly, body format, pagination, filtering, field selection, idempotency keys, and link-oriented responses.

### Resource Modeling

Source: https://academia.sh/en/courses/api-design/resource-modeling

Turning domain concepts into addressable resources: an endpoint list that grows with action names, modeling an operation as a resource's state change, computed resources with no table of their own, and the cost of choosing an identity.

The API Styles topic finished the work of choosing: it established which constraint favors
resource-oriented, remote procedure call, and query-based approaches over one another, and
where synchronous and asynchronous communication diverge. When the decision for the library
loan service falls on the resource-oriented style, the work does not end — the real design
starts there. The style says "address resources and speak through a uniform interface," but
it does not say what counts as a resource.

This lesson fills that gap. There is a domain at hand — book, member, loan, branch, overdue
fine, loan period — and these concepts are not all of the same kind. Some are addressed,
some live only as a field inside another resource, and some become resources without a row
in any table. The criterion that draws the line is identity.

#### What a Resource Is, and Is Not

A **resource** is something the client will want to refer to individually and that has an
identity that stays the same over time. Both halves of this definition are functional.
Something without an identity cannot be addressed; addressing something no one refers to
only inflates the surface.

Apply the criterion to the loan service. A **book** is a resource: it is searched for on its
own in the catalog, its detail is shown, it is updated. A **member** is a resource. A
**loan** is a resource — though it looks like an action at first glance, it is something
with its own identity, a start, an end, and a history. A **branch** is a resource. The
**loan period**, by contrast, is not a resource: the fourteen-day period is a rule, and it
lives inside the loan record or in the service's configuration. The **overdue fine** is not
a resource on its own either; it is a component of a member's debt.

The resource itself is abstract. The JSON body sent to the client is its **representation**
at that moment. The same resource can have more than one representation: a summary list row
and a detail page describe the same loan record at different levels of detail. Separating
the resource from its representation is what makes field selection and partial response
possible in later lessons.

#### The Cost of Turning Actions into Address Names

When a service is written without resource thinking, addresses turn into action names:
`/issueLoan`, `/returnLoan`, `/memberLoans`. Every new client need opens a new route. The two
servers below answer the same questions over the same data; what differs is how their
address sets grow.

```js
// action-server.mjs — a design that opens a new route for every client need
import { createServer } from "node:http";

const BOOKS = [
  { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", branch: "S-01" },
  { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", branch: "S-02" },
];
const LOANS = [
  { id: 1, member: "U-1001", isbn: "978-0262033848", return: null },
  { id: 2, member: "U-1002", isbn: "978-0201896831", return: "2026-03-04" },
];

const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Each line is one client need; the line count grows as needs grow.
const ROUTES = {
  "/bookList":      () => BOOKS,
  "/bookDetail":    (s) => BOOKS.find((b) => b.isbn === s.get("isbn")) ?? null,
  "/addBook":       () => ({ result: "added" }),
  "/removeBook":    () => ({ result: "removed" }),
  "/loanList":      () => LOANS,
  "/loanDetail":    (s) => LOANS.find((l) => l.id === Number(s.get("id"))) ?? null,
  "/issueLoan":     () => ({ result: "issued" }),
  "/returnLoan":    () => ({ result: "return received" }),
  "/memberLoans":   (s) => LOANS.filter((l) => l.member === s.get("member")),
  "/branchBooks":   (s) => BOOKS.filter((b) => b.branch === s.get("branch")),
  "/openLoans":     () => LOANS.filter((l) => l.return === null),
};

const server = createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  if (url.pathname === "/routes") return respond(response, 200, { count: Object.keys(ROUTES).length });
  const handler = ROUTES[url.pathname];
  if (!handler) return respond(response, 404, { error: "path_not_found" });
  respond(response, 200, handler(url.searchParams));
});

server.listen(8471, "127.0.0.1", () => console.log("action server 127.0.0.1:8471"));
```

```js
// resource-server.mjs — a design that meets the same needs with resource addresses
import { createServer } from "node:http";

const BOOKS = [
  { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", branch: "S-01" },
  { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", branch: "S-02" },
];
const LOANS = [
  { id: 1, member: "U-1001", isbn: "978-0262033848", return: null },
  { id: 2, member: "U-1002", isbn: "978-0201896831", return: "2026-03-04" },
];

const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Three address patterns; filter criteria travel in the query part.
const PATTERNS = [
  [/^\/books$/, (e, s) => {
    const branch = s.get("branch");
    return branch ? BOOKS.filter((b) => b.branch === branch) : BOOKS;
  }],
  [/^\/books\/([\w-]+)$/, (e) => BOOKS.find((b) => b.isbn === e[1]) ?? null],
  [/^\/loans$/, (e, s) => {
    let result = LOANS;
    if (s.get("member")) result = result.filter((l) => l.member === s.get("member"));
    if (s.get("status") === "open") result = result.filter((l) => l.return === null);
    return result;
  }],
  [/^\/loans\/(\d+)$/, (e) => LOANS.find((l) => l.id === Number(e[1])) ?? null],
];

const server = createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  if (url.pathname === "/patterns") return respond(response, 200, { count: PATTERNS.length });
  for (const [pattern, handler] of PATTERNS) {
    const match = pattern.exec(url.pathname);
    if (match) return respond(response, 200, handler(match, url.searchParams));
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8472, "127.0.0.1", () => console.log("resource server 127.0.0.1:8472"));
```

Both servers are long-running; the script below starts both, asks the same questions, and
stops them at the end.

```bash
# Starts both servers in turn, asks each the same four questions, stops them.
node action-server.mjs & action=$!
sleep 0.4
node resource-server.mjs & resource=$!
sleep 0.4

ask() { printf '%-22s %s\n' "$1" "$(curl -sS "$2")"; }

echo "--- action based ---"
ask "S-01 branch books" "http://127.0.0.1:8471/branchBooks?branch=S-01"
ask "U-1001 loans"      "http://127.0.0.1:8471/memberLoans?member=U-1001"
ask "open loans"        "http://127.0.0.1:8471/openLoans"
ask "route count"       "http://127.0.0.1:8471/routes"

echo "--- resource based ---"
ask "S-01 branch books" "http://127.0.0.1:8472/books?branch=S-01"
ask "U-1001 loans"      "http://127.0.0.1:8472/loans?member=U-1001"
ask "open loans"        "http://127.0.0.1:8472/loans?status=open"
ask "address pattern count" "http://127.0.0.1:8472/patterns"

kill $action $resource
```

```
action server 127.0.0.1:8471
resource server 127.0.0.1:8472
--- action based ---
S-01 branch books      [{"isbn":"978-0201896831","title":"The Art of Computer Programming","author":"Knuth","branch":"S-01"}]
U-1001 loans           [{"id":1,"member":"U-1001","isbn":"978-0262033848","return":null}]
open loans             [{"id":1,"member":"U-1001","isbn":"978-0262033848","return":null}]
route count            {"count":11}
--- resource based ---
S-01 branch books      [{"isbn":"978-0201896831","title":"The Art of Computer Programming","author":"Knuth","branch":"S-01"}]
U-1001 loans           [{"id":1,"member":"U-1001","isbn":"978-0262033848","return":null}]
open loans             [{"id":1,"member":"U-1001","isbn":"978-0262033848","return":null}]
address pattern count  {"count":4}
```

The response bodies match line for line; the numbers do not. Eleven routes against four
address patterns. The difference comes from the patterns naming the **resource, not the
client need**. "Branch books" and "open loans" are not separate concepts; both are filtered
views of an existing collection, and the filter criterion travels in the address's query
part.

The number itself is not the real problem. The real problem is that the action-based list is
**not closed**. If one screen wants "overdue loans," the list grows to twelve; if another
wants "overdue loans at a branch," it grows to thirteen. On the resource-based side, a new
query criterion is added and the address set stays the same. Breaking this link between
endpoint count and need count is resource modeling's measurable output.

#### An Operation, Too, Is a Resource's State Change

The most eye-catching line in the action-based list was `/returnLoan`. A return is a verb,
so it does not look like a resource. But what gets returned is a loan record that already
has an identity in the system; a return is that record's `return` field going from empty to
filled. No new address is needed — an existing resource's state changes.

The service below shows this on the library schema. The schema stays the same throughout
the lesson: the branch, book, member, and loan tables.

```sql
-- schema.sql — the core schema of the library loan service
CREATE TABLE branch (
  code TEXT PRIMARY KEY,
  name TEXT NOT NULL
);
CREATE TABLE book (
  isbn   TEXT PRIMARY KEY,
  title  TEXT NOT NULL,
  author TEXT NOT NULL,
  year   INTEGER NOT NULL,
  branch TEXT NOT NULL REFERENCES branch(code)
);
CREATE TABLE member (
  code   TEXT PRIMARY KEY,
  name   TEXT NOT NULL,
  branch TEXT NOT NULL REFERENCES branch(code)
);
CREATE TABLE loan (
  id       INTEGER PRIMARY KEY,
  member   TEXT NOT NULL REFERENCES member(code),
  isbn     TEXT NOT NULL REFERENCES book(isbn),
  issuedAt TEXT NOT NULL,
  return   TEXT
);

INSERT INTO branch VALUES ('S-01','Central'), ('S-02','Lakeside');
INSERT INTO book VALUES
  ('978-0201896831','The Art of Computer Programming','Knuth',1968,'S-01'),
  ('978-0262033848','Introduction to Algorithms','Cormen',1990,'S-02'),
  ('978-0131103627','The C Programming Language','Ritchie',1978,'S-01');
INSERT INTO member VALUES ('U-1001','Alina Drake','S-01'), ('U-1002','Ben Turner','S-02');
INSERT INTO loan (member, isbn, issuedAt, return) VALUES
  ('U-1001','978-0262033848','2026-03-01',NULL),
  ('U-1001','978-0131103627','2026-02-10','2026-02-24'),
  ('U-1002','978-0201896831','2026-03-04',NULL);
```

```js
// resource-service.mjs — a loan is a resource; a return is its state change
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const loan = /^\/loans\/(\d+)$/.exec(path);
  const status = /^\/members\/([\w-]+)\/status$/.exec(path);

  // A single loan record: corresponds to one table row.
  if (loan && request.method === "GET") {
    const row = db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(loan[1]));
    return row ? respond(response, 200, row) : respond(response, 404, { error: "loan_not_found" });
  }
  // Return: not a new address, a state change on an existing resource.
  if (loan && request.method === "PATCH") {
    const body = await readBody(request);
    db.prepare("UPDATE loan SET return = ? WHERE id = ? AND return IS NULL")
      .run(body.return, Number(loan[1]));
    return respond(response, 200, db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(loan[1])));
  }
  // Member status: has no row in any table, computed from two tables.
  if (status && request.method === "GET") {
    const counts = db.prepare(`
      SELECT COUNT(*) AS total, SUM(return IS NULL) AS open
      FROM loan WHERE member = ?`).get(status[1]);
    const member = db.prepare("SELECT name, branch FROM member WHERE code = ?").get(status[1]);
    if (!member) return respond(response, 404, { error: "member_not_found" });
    return respond(response, 200, {
      member: status[1], name: member.name, branch: member.branch,
      openLoan: counts.open, totalLoans: counts.total,
      canBorrowMore: counts.open < 2,
    });
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8473, "127.0.0.1", () => console.log("resource service 127.0.0.1:8473"));
```

```bash
# Builds the schema, starts the server, reads a loan record, returns it, asks for status.
rm -f library.db && sqlite3 library.db < schema.sql
node resource-service.mjs & server=$!
sleep 0.4

curl -sS http://127.0.0.1:8473/loans/1; echo
curl -sS -X PATCH -H 'content-type: application/json' \
     -d '{"return":"2026-03-12"}' http://127.0.0.1:8473/loans/1; echo
curl -sS http://127.0.0.1:8473/members/U-1001/status; echo
sqlite3 library.db "SELECT COUNT(*) || ' rows, open: ' || SUM(return IS NULL) FROM loan;"

kill $server
```

```
resource service 127.0.0.1:8473
{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":null}
{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":"2026-03-12"}
{"member":"U-1001","name":"Alina Drake","branch":"S-01","openLoan":0,"totalLoans":2,"canBorrowMore":true}
3 rows, open: 1
```

The last line matters: the return did not produce a new row, it filled a field on an
existing row. The table's record count is three, and it stays three. Opening a separate
address for a return creates the illusion that something separate was created in the
system, when in fact nothing was.

The reverse is also true, and it should not be overstated. If a transition has its own
rules, its own authorization, and its own record — a loan's transfer to another member, say
— opening that transition as a separate address tied to the resource is defensible. The
criterion is again identity: if the transition itself leaves a queryable record, it earns
the right to be a resource; if it does not, it is a field change.

#### A Table and a Resource Are Not the Same Thing

The output's third line belongs to the `/members/U-1001/status` address, and this resource
has no row in any table. Open loan count, total loan count, and eligibility for another loan
are computed from two tables. It is still a resource: its identity is fixed, the client
refers to it directly, and the result is a single representation. The clerk's screen calls
this one address; it does not run three separate queries and apply the rule on the client.

The correspondence is not one-directional either. A single table can feed more than one
resource: the `loan` table feeds both the loan record collection and a member's loan
history. The resource model is not the database schema projected outward; the schema looks
for the best shape for storage, and the resource model looks for the concepts the client
will want to refer to. That the two often overlap does not mean they are the same thing —
and if this distinction is not kept, every reorganization of the database breaks the outside
contract.

#### The Cost of Choosing an Identity

What to use as a resource's identity is a design decision. There are two options: the
**natural key** defined in the Data Modeling and Relational Theory course — the ISBN for a
book — and the **surrogate key** — a generated number for a loan record. A natural key is
readable and meaningful; a surrogate key is meaningless but stable.

The difference shows up when data gets corrected.

```bash
# What happens to references when a resource's identity changes?
rm -f identity.db
sqlite3 identity.db <<'SQL'
CREATE TABLE book (isbn TEXT PRIMARY KEY, title TEXT NOT NULL);
CREATE TABLE loan (id INTEGER PRIMARY KEY, isbn TEXT NOT NULL REFERENCES book(isbn));
INSERT INTO book VALUES ('978-0262033848', 'Introduction to Algorithms');
INSERT INTO loan (isbn) VALUES ('978-0262033848');
-- The ISBN in the catalog record was entered wrong; it is being fixed.
UPDATE book SET isbn = '978-0262046305' WHERE isbn = '978-0262033848';
SELECT 'book table    : ' || isbn FROM book;
SELECT 'loan table    : ' || isbn FROM loan;
SELECT 'orphan record : ' || COUNT(*)
  FROM loan l LEFT JOIN book b ON b.isbn = l.isbn WHERE b.isbn IS NULL;
SQL
```

```
book table    : 978-0262046305
loan table    : 978-0262033848
orphan record : 1
```

A single correction produced one orphan record. In this engine, foreign key checking is off
by default and is turned on with `PRAGMA foreign_keys = ON`; if it were on, the update would
be rejected and the correction could not be made. Either way there is a cost: either the
reference breaks or the correction is blocked.

The counterpart on the API side is heavier, because references live outside the system. The
address `/books/978-0262033848` may be written into a log, put in an email, embedded in
another service's configuration. Once the identity changes, that address can no longer be
found. This is why the criterion for a resource's identity is not "is it meaningful," but
**"are we sure it will not change."** Natural keys that can change stay as a field on the
resource and are offered as a search criterion; a surrogate key is used for the identity.

#### Summary

- A resource is something whose identity stays fixed over time and that the client refers to
  directly; not every domain concept is a resource — some are a resource's field or the
  service's rule.
- The resource itself is abstract; the body sent to the client is its representation, and
  the same resource can have more than one representation.
- The number of endpoints opened with action names grows together with client needs; when
  the same needs are met with four address patterns, this link breaks.
- Transitions like a return do not create a new resource, they change an existing resource's
  state; transitions that leave their own record, however, earn the right to be a separate
  resource.
- The resource model is not a reflection of the database schema: a resource may correspond
  to no table at all, and a table may feed more than one resource.
- The criterion for identity is not meaningfulness but stability; natural keys that can
  change are carried as a field instead of an identity.

#### Next Step

Resources are identified, but how their addresses are written has not been decided yet.
This lesson used the forms `/books`, `/loans/1`, and `/members/U-1001/status` without
questioning them, yet each was a choice. Is a collection name singular or plural, is a loan
record referenced through `/loans/1` or through `/members/U-1001/loans/1`, and if both paths
are open, which one counts as correct? The next lesson takes on path layout by building a
matcher table and measures where nested addresses produce ambiguity.

### URI Design

Source: https://academia.sh/en/courses/api-design/uri-design

The path layout of resource addresses: the division of labor between path and query part, naming collections and singular resources, the clash between static and parameter segments, the parent segment of nested paths, and the canonical-address decision.

The previous lesson identified the resources: book, member, loan record, branch, and a
member status with no table of its own. Its examples used addresses like `/books`,
`/loans/1`, and `/members/U-1001/status` without questioning them. Yet each one was a
decision, and every decision had an alternative.

This lesson opens those decisions up: which part of an address carries identity and which
sets the view, how a collection name is written, which path counts as valid when a loan
record has two, and what a trailing slash changes. All of it produces measurable results
once a matcher table runs.

#### The Division of Labor Between Path and Query

The How the Internet Works course introduced the parts of an address: scheme, host, path,
query, and fragment. In API design there is a sharp division of labor among these parts.

**The path carries a resource's identity.** `/books/978-0131103627` points to a specific
book; that book is not found at any other path. **The query part sets the resource's view.**
`/loans?status=open` is not a new resource, it is a filtered view of the loan collection.
The same division holds for sorting, pagination, and field selection; all of these are the
subject of later lessons and all of them live in the query part.

The practical consequence of this division is this: **the path carries no verb.** Addresses
like `/returnLoan`, `/searchBook`, `/removeMember` name the operation, not the resource. What
says what to do is the method; the path only says what to do it to. The next lesson takes up
in detail how methods take on that job.

Collection names are written in the plural: `/books`, `/members`, `/loans`. The reasoning is
not aesthetic. When `/book/978-0131103627` is used alongside `/books`, every address-building
spot on the client has to remember separately whether it is singular or plural. One rule —
the collection is plural, its member sits under it — removes that burden. Which rule is
chosen matters less than that it never changes.

#### The Clash Between a Static Segment and a Parameter Segment

Address patterns form a table, and the table is scanned in order. When the order is wrong,
the error is silent. The matcher below reports which pattern matched inside the response, so
the decision becomes visible.

```js
// path-matcher.mjs — matcher that tries address patterns in order; reports the matched pattern
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Order matters: the list is scanned top to bottom, first match wins.
const PATTERNS = [
  ["/books/{isbn}", /^\/books\/([^/]+)$/, (e) =>
    db.prepare("SELECT * FROM book WHERE isbn = ?").get(e[1]) ?? null],
  ["/books/search",    /^\/books\/search$/, () =>
    db.prepare("SELECT isbn, title FROM book WHERE author LIKE 'K%'").all()],
  ["/members/{code}/loans/{id}", /^\/members\/([^/]+)\/loans\/(\d+)$/, (e) =>
    db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(e[2])) ?? null],
];

const server = createServer((request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  for (const [name, pattern, handler] of PATTERNS) {
    const match = pattern.exec(path);
    if (match) return respond(response, 200, { matched: name, result: handler(match) });
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8474, "127.0.0.1", () => console.log("path matcher 127.0.0.1:8474"));
```

The schema is the `schema.sql` file set up in the previous lesson; the script rebuilds the
database on every run.

```bash
rm -f library.db && sqlite3 library.db < schema.sql
node path-matcher.mjs & server=$!
sleep 0.4

request() { printf '%-34s %s\n' "$1" "$(curl -sS "http://127.0.0.1:8474$1")"; }
request "/books/978-0131103627"
request "/books/search"
request "/members/U-1002/loans/1"
request "/books/978-0131103627/"
request "/Books/978-0131103627"

kill $server
```

```
path matcher 127.0.0.1:8474
/books/978-0131103627              {"matched":"/books/{isbn}","result":{"isbn":"978-0131103627","title":"The C Programming Language","author":"Ritchie","year":1978,"branch":"S-01"}}
/books/search                      {"matched":"/books/{isbn}","result":null}
/members/U-1002/loans/1            {"matched":"/members/{code}/loans/{id}","result":{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":null}}
/books/978-0131103627/             {"error":"path_not_found"}
/Books/978-0131103627              {"error":"path_not_found"}
```

Five lines show four separate problems.

The second line is the clash. `/books/search` never reached the search pattern; the
parameter segment `{isbn}` caught it first and treated `search` as an ISBN, looking it up in
the database. The result came back `null` — the client concludes not that the search
endpoint is broken, but that the book does not exist. The **specificity** criterion defined
in the Application Architecture course applies here too: static-segment patterns should be
tried before parameter-segment ones. If you do not want to rely on order, there is an
alternative — move the search criterion into the collection address's query part:
`/books?author=Knuth`. In this form no segment is left to clash.

The third line is more serious. `/members/U-1002/loans/1` returned member U-1001's loan
record. The pattern captured the parent segment but the handler never used it, querying with
only `{id}`. The nested path falls into being decoration: the address carries a claim of
ownership, and the server never checks that claim.

The fourth and fifth lines are a canonical-form problem. The trailing slash and the capital
letter each caused the path to fail to match. Both came back as "not found," even though the
resource is right there.

#### Validating the Parent Segment and Canonical Form

The matcher below applies all three fixes together: the static segment moves to the front,
the parent segment joins the query, and a trailing slash redirects to the canonical address.

```js
// path-matcher-2.mjs — matcher that tries specificity first and validates the parent segment
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Static-segment patterns first; the parameter segment stays last.
const PATTERNS = [
  ["/books/search",    /^\/books\/search$/, () =>
    [200, db.prepare("SELECT isbn, title FROM book WHERE author LIKE 'K%'").all()]],
  ["/books/{isbn}", /^\/books\/([^/]+)$/, (e) => {
    const b = db.prepare("SELECT * FROM book WHERE isbn = ?").get(e[1]);
    return b ? [200, b] : [404, { error: "book_not_found" }];
  }],
  ["/members/{code}/loans/{id}", /^\/members\/([^/]+)\/loans\/(\d+)$/, (e) => {
    // Saying the parent segment is decoration is wrong: no path if the record is not that member's.
    const l = db.prepare("SELECT * FROM loan WHERE id = ? AND member = ?").get(Number(e[2]), e[1]);
    return l ? [200, l] : [404, { error: "loan_not_found" }];
  }],
];

const server = createServer((request, response) => {
  let path = new URL(request.url, "http://127.0.0.1").pathname;
  // Canonical form: a trailing slash collapses to a single address.
  if (path.length > 1 && path.endsWith("/")) {
    response.writeHead(308, { location: path.slice(0, -1) });
    return response.end();
  }
  for (const [name, pattern, handler] of PATTERNS) {
    const match = pattern.exec(path);
    if (match) {
      const [status, result] = handler(match);
      return respond(response, status, { matched: name, result });
    }
  }
  respond(response, 404, { error: "path_not_found" });
});

server.listen(8475, "127.0.0.1", () => console.log("fixed matcher 127.0.0.1:8475"));
```

```bash
rm -f library.db && sqlite3 library.db < schema.sql
node path-matcher-2.mjs & server=$!
sleep 0.4

request() { printf '%-30s %3s  %s\n' "$1" \
  "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8475$1")" \
  "$(curl -sS "http://127.0.0.1:8475$1")"; }
request "/books/search"
request "/members/U-1002/loans/1"
request "/members/U-1001/loans/1"
request "/books/978-0131103627/"

# Same address, following the redirect:
printf '%-30s %3s  %s\n' "/books/978-0131103627/ (-L)" \
  "$(curl -sSL -o /dev/null -w '%{http_code}' http://127.0.0.1:8475/books/978-0131103627/)" \
  "$(curl -sSL http://127.0.0.1:8475/books/978-0131103627/)"

kill $server
```

```
fixed matcher 127.0.0.1:8475
/books/search                  200  {"matched":"/books/search","result":[{"isbn":"978-0201896831","title":"The Art of Computer Programming"}]}
/members/U-1002/loans/1        404  {"matched":"/members/{code}/loans/{id}","result":{"error":"loan_not_found"}}
/members/U-1001/loans/1        200  {"matched":"/members/{code}/loans/{id}","result":{"id":1,"member":"U-1001","isbn":"978-0262033848","issuedAt":"2026-03-01","return":null}}
/books/978-0131103627/         308
/books/978-0131103627/ (-L)    200  {"matched":"/books/{isbn}","result":{"isbn":"978-0131103627","title":"The C Programming Language","author":"Ritchie","year":1978,"branch":"S-01"}}
```

Read the second and third lines together: the same loan record gives two different results
with two different parent segments. The parent segment is now a condition, not a claim. This
is a correctness decision, not a security one; authorization is a separate topic, covered in
the Authentication and Authorization course. The gain here is that whatever the address
claims, the server verifies it.

The fourth line shows the redirect: the trailing-slash address turns into the canonical one
with a 308, body empty. The fifth line makes the same request following the redirect and
reaches the resource with a 200. This kind of redirect preserves the method and the body — a
distinction that becomes decisive whenever a write request needs redirecting.

The capital-letter problem was deliberately left unfixed. A path is case-sensitive and
should stay that way; accepting `/Books` as well would mean producing an unbounded number of
addresses for the same resource. The right decision is to document one spelling and reject
the rest.

#### Separators Inside an Identity

The character that separates path segments is the slash; if an identity value contains that
character, the address breaks.

```bash
# What happens when a path separator appears inside an identity?
node -e '
const code = "S-01/Central";
console.log("raw      :", "/branches/" + code);
console.log("encoded  :", "/branches/" + encodeURIComponent(code));
const url = new URL("http://127.0.0.1/branches/" + encodeURIComponent(code));
console.log("decoded  :", decodeURIComponent(url.pathname.split("/")[2]));
'
```

```
raw      : /branches/S-01/Central
encoded  : /branches/S-01%2FCentral
decoded  : S-01/Central
```

In the raw form, a single identity gets split into two segments and the matcher mistakes it
for a sub-resource. In the encoded form, the segment keeps its integrity. This is the
client's responsibility and it should be written into the contract. The more robust path is
restricting the identity's format from the start: identities that contain no separator, no
space, and no capital letter never raise this problem at all.

#### The Canonical-Address Decision

A resource can be reached by more than one path: `/loans/1` and `/members/U-1001/loans/1`
return the same record. Both can be valid, but one must be chosen as the **canonical
address**. The selection criterion is this: if the resource has an independent identity, the
canonical address is the shorter one. A loan record has its own identity, so `/loans/1` is
canonical; the nested form is a shortcut used while browsing a member's records.

Settling on a canonical address does work in three places. When a response links to a
resource, which form to write is settled. When a new record is created, the address to put
in the `Location` header is settled. When the address is used as a cache key, the same
resource is not stored under two different keys.

Depth is bounded by this same criterion. A four-level path like
`/branches/S-01/members/U-1001/loans/1` needs four separate checks, and all four have to run
on every request. As a rule, one level is enough: the resource that owns the collection,
then the collection, then its member. Needing more depth than that is a sign that the
relationship is better written as a query criterion instead.

#### Summary

- The path carries a resource's identity, the query part sets its view; the path carries no
  verb, the method says what to do.
- Collections are named in the plural and this decision does not change throughout the
  course; consistency matters more than which rule was chosen.
- Static-segment patterns must be tried before parameter-segment ones; otherwise the address
  `/books/search` is mistaken for an ISBN and the error looks like "not found."
- A nested path's parent segment is a condition, not decoration: if the record does not
  belong to that member, the response must be a 404.
- Every resource gets one settled canonical address; variants like a trailing slash are
  redirected to it with a 308, and capitalization variants are never accepted at all.
- If identity values contain the path separator, the address must be encoded; the more
  robust fix is restricting the identity's format from the start.

#### Next Step

Now that addresses are settled, the question of where the verb comes from remains. This
lesson said the path layout carries no verb and moved on with "the method says what to do."
But choosing a method is not choosing a name: every method has properties that the client
and the components in between rely on. The assumption that a read does not change data, and
that sending the same request twice produces a single effect, both come from here. The next
lesson defines these properties and tests each one by counting rows in the database.

### Correct Use of HTTP Methods

Source: https://academia.sh/en/courses/api-design/correct-use-of-http-methods

The promises a method carries: a safe method not changing data, and an idempotent method producing a single effect when repeated; testing each property by counting rows in the database, PATCH's increment and assignment faces, and the identity choice between PUT and POST.

The previous lesson set the path aside for a resource's identity and moved on with "the
method says what to do." That sentence holds more than a division of labor. An HTTP method
is not just a command sent to the server; it is a promise that the client, caches, and every
component in between rely on when they look at the request.

This lesson defines those promises and measures each one. The measurement is uniform: the
same request is sent twice in a row, and the row count and field values in the database are
counted at every step. Whether a method keeps its promise is settled by numbers, not by
argument.

#### A Method Is Not a Name, It Is a Promise

The How the Internet Works course introduced two properties. A **safe** method leaves no
observable change on the server; it reads, it does not write. An **idempotent** method makes
no difference to the server's final state whether the same request is sent once or five
times in a row. The two are independent: every safe method is idempotent, but not every
idempotent method is safe.

| Method | Safe | Idempotent | Carries a body |
|---|---|---|---|
| GET | yes | yes | no |
| HEAD | yes | yes | no |
| OPTIONS | yes | yes | no |
| POST | no | no | yes |
| PUT | no | yes | yes |
| PATCH | no | depends on design | yes |
| DELETE | no | yes | usually no |

These properties are not the server's internal business; they are commitments made outward.
A cache can store a GET response and answer the next request without going to the server —
trusting that GET has no side effect. A client can resend a PUT request after a network
error — trusting that repeating it is harmless. The request layer in the Application
Architecture course encoded this trust as **retryability**.

If the commitment is not kept, what breaks is not the server, but the parties that trust it.

#### Safety: A Read Does Not Change Data

The service below carries a deliberate flaw for the measurement: a loan return can be made
both the right way and through a read-shaped address.

```js
// method-server.mjs — a service set up to measure method properties
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(data === null ? "" : JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const method = request.method;
  const book = /^\/books\/([^/]+)$/.exec(path);
  const loan = /^\/loans\/(\d+)$/.exec(path);
  const returnPath = /^\/loans\/(\d+)\/return$/.exec(path);

  if (method === "GET" && path === "/loans")
    return respond(response, 200, db.prepare("SELECT * FROM loan").all());

  // WRONG: a read-shaped address changes data.
  if (method === "GET" && returnPath) {
    db.prepare("UPDATE loan SET return = '2026-03-12' WHERE id = ? AND return IS NULL")
      .run(Number(returnPath[1]));
    return respond(response, 200, { result: "return received" });
  }

  if (method === "POST" && path === "/loans") {
    const g = await readBody(request);
    const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)")
                .run(g.member, g.isbn, g.issuedAt);
    return respond(response, 201, { id: Number(s.lastInsertRowid) });
  }

  if (method === "PUT" && book) {
    const g = await readBody(request);
    // Full replacement: the sent representation determines the whole record.
    const exists = db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(book[1]);
    db.prepare(`INSERT INTO book (isbn, title, author, year, branch, copies) VALUES (?,?,?,?,?,?)
                ON CONFLICT(isbn) DO UPDATE SET
                  title=excluded.title, author=excluded.author, year=excluded.year,
                  branch=excluded.branch, copies=excluded.copies`)
      .run(book[1], g.title, g.author, g.year, g.branch, g.copies);
    return respond(response, exists ? 200 : 201, db.prepare("SELECT * FROM book WHERE isbn = ?").get(book[1]));
  }

  if (method === "PATCH" && book) {
    const g = await readBody(request);
    if ("addCopies" in g)  // increment: depends on the previous value
      db.prepare("UPDATE book SET copies = copies + ? WHERE isbn = ?").run(g.addCopies, book[1]);
    if ("copies" in g)     // assignment: independent of the previous value
      db.prepare("UPDATE book SET copies = ? WHERE isbn = ?").run(g.copies, book[1]);
    return respond(response, 200, db.prepare("SELECT isbn, copies FROM book WHERE isbn = ?").get(book[1]));
  }

  if (method === "DELETE" && loan) {
    db.prepare("DELETE FROM loan WHERE id = ?").run(Number(loan[1]));
    return respond(response, 204, null);
  }

  respond(response, 404, { error: "path_not_found" });
});

server.listen(8476, "127.0.0.1", () => console.log("method server 127.0.0.1:8476"));
```

Now suppose a client that follows the links on a page in order arrives at this service. Such
a client only walks GET; it does not know what is behind a link.

```bash
# What happens when a link crawler follows a read-shaped address?
rm -f library.db && sqlite3 library.db < schema.sql
sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;"
node method-server.mjs & server=$!
sleep 0.4

open() { sqlite3 library.db "SELECT COUNT(*) FROM loan WHERE return IS NULL;"; }
printf 'open loans at start : %s\n' "$(open)"

# A client that follows every link on a page: it only walks GET.
for path in /loans /loans/1/return /loans/3/return; do
  printf 'GET %-22s -> %s\n' "$path" "$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8476$path")"
done
printf 'open loans after GET: %s\n' "$(open)"

kill $server
```

```
method server 127.0.0.1:8476
open loans at start : 2
GET /loans                 -> 200
GET /loans/1/return        -> 200
GET /loans/3/return        -> 200
open loans after GET: 0
```

Two open loan records closed without any user performing a return. The client broke no
rule: for a link-following program, GET is a question it is free to ask. The server is the
one that broke the rule.

The scale of this flaw goes beyond a design mistake. A page-preview tool, a link-checking
script, a browser that prefetches an address bar entry — all operate on the same assumption.
In an example schema with two tables, two rows are lost; in a real catalog, the loss is as
large as the number of links followed.

In a correct design, an address like `/loans/{id}/return` is never opened; a return is made
with a `PATCH /loans/{id}` request, as shown in the previous lesson. If the address is
read-shaped, the method must be a read too, and the reverse holds as well.

#### Idempotency: Sending the Same Request Twice

Idempotency measurement is direct. Every method is called twice in a row; if the result of
two calls differs from that of one, the method is not idempotent.

```bash
# Every method is called twice in a row; the effect on the database is counted after each call.
rm -f library.db && sqlite3 library.db < schema.sql
sqlite3 library.db "ALTER TABLE book ADD COLUMN copies INTEGER NOT NULL DEFAULT 1;"
node method-server.mjs & server=$!
sleep 0.4

T=http://127.0.0.1:8476
say() { printf '%-26s loans=%s  copies=%s\n' "$1" \
  "$(sqlite3 library.db 'SELECT COUNT(*) FROM loan;')" \
  "$(sqlite3 library.db "SELECT copies FROM book WHERE isbn='978-0131103627';")"; }

say "start"
curl -sS -o /dev/null $T/loans; curl -sS -o /dev/null $T/loans
say "GET x2"
for i in 1 2; do curl -sS -o /dev/null -X POST -H 'content-type: application/json' \
  -d '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-10"}' $T/loans; done
say "POST x2"
for i in 1 2; do curl -sS -o /dev/null -X PUT -H 'content-type: application/json' \
  -d '{"title":"The C Programming Language","author":"Ritchie","year":1988,"branch":"S-01","copies":4}' \
  $T/books/978-0131103627; done
say "PUT x2"
for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \
  -d '{"addCopies":2}' $T/books/978-0131103627; done
say "PATCH increment x2"
for i in 1 2; do curl -sS -o /dev/null -X PATCH -H 'content-type: application/json' \
  -d '{"copies":6}' $T/books/978-0131103627; done
say "PATCH assignment x2"
printf '%-26s %s %s\n' "DELETE x2 (status code)" \
  "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)" \
  "$(curl -sS -o /dev/null -w '%{http_code}' -X DELETE $T/loans/2)"
say "DELETE x2"

kill $server
```

```
method server 127.0.0.1:8476
start                      loans=3  copies=1
GET x2                     loans=3  copies=1
POST x2                    loans=5  copies=1
PUT x2                     loans=5  copies=4
PATCH increment x2         loans=5  copies=8
PATCH assignment x2        loans=5  copies=6
DELETE x2 (status code)    204 204
DELETE x2                  loans=4  copies=6
```

Read the lines one at a time.

**GET x2** changed no number: the method is safe, so it is idempotent.

**POST x2** raised the loan count from three to five: two requests, two records. This is
POST's counterpart in this design — a new member is added to the collection and the server
determines its identity. HTTP does not bind POST to one operation; it asks that the body be
processed according to the resource's own semantics, and appending to a collection is the
most common form of that processing. A POST resent because the network dropped produces a
second loan record. This is not a design flaw, it is POST's nature; its fix is the ninth
lesson's subject.

**PUT x2** set the copy count to 4 and left it there. Because the sent body determines the
whole record, the second request produces the same final state as the first. How many times
it is sent does not change the result.

**PATCH increment x2** raised the copy count from 4 to 8. The body carries an `addCopies`
field, so the new value depends on the old one. This is not a flaw in PATCH; it is proof
that PATCH's idempotency depends on the body. **PATCH assignment x2** used the same method to
produce the value 6, and the second request did not change it.

The design rule this yields is clear: a partial-update body built on assignment rather than
increment makes the method idempotent. If an increment is genuinely needed — a counter, a
balance, stock — idempotency cannot be achieved through the body; the key mechanism from the
ninth lesson is needed.

**DELETE x2** returned 204 to both requests, and the loan count dropped from five to four,
not dropping further on the second delete. This is idempotency's definition: the final state
is the same. Returning 404 to the second request is also a defensible choice and does not
break idempotency, because idempotency is a property of server state, not of the response
code. Which one to choose is the next lesson's subject.

#### The Choice Between PUT and POST

Both write; what draws the line is who determines the identity.

**POST** is used when the server determines the identity. The client sends a body to the
`/loans` collection, the server generates the new record's identity and reports it in the
response. The client cannot know the target address before sending the request.

**PUT** is used when the client knows the identity. A PUT to `/books/978-0131103627` creates
the resource at that address if it does not exist, or replaces it with the sent
representation if it does. The server above reflects this distinction in the status code
too: 201 if the record did not exist, 200 if it did.

PUT's second property is often overlooked: **fields left out are emptied.** PUT is a full
replacement, not a partial update. If the `author` field is absent from the body, the
record's author counts as cleared. If this behavior is not wanted, the right method is
PATCH. A service that runs PUT like a partial update does not break the client's idempotency
assumption, but it makes the contract ambiguous: the same body produces two different
results across two services.

#### Method Mismatch

If an address exists but does not support the requested method — say, a DELETE arrives at
the `/members/U-1001/status` resource — the response should not be "not found." The resource
is right there; the method just cannot be applied. HTTP defines a separate status code for
this and expects the response to report which methods are accepted. The server above does
not make this distinction; it returns 404 to anything that does not match. The next lesson
measures this gap and closes it.

#### Summary

- A method is a commitment the server makes outward: a safe method leaves no observable
  change, an idempotent one does not change the final state when repeated.
- A read-shaped address changing data turns every link-following program into something
  destructive; in the measurement, three GET requests closed two open loan records.
- POST is not idempotent and does not try to be; because the server generates the identity,
  every request produces a new record.
- PUT is a full replacement and is idempotent; fields left out are emptied, so it is not
  suited to partial updates.
- PATCH's idempotency depends on the body: an assignment-shaped body is idempotent, an
  increment-shaped one is not — in the measurement, the copy count rose from 4 to 8.
- DELETE is idempotent; a second delete request returning a different status code does not
  break this property, because idempotency is a property of server state.

#### Next Step

This lesson largely assumed the response status codes: 201 on create, 204 on delete, 404 for
everything unmatched. Yet each of these is a choice, and when the choice is made wrong, the
client does not notice the error. Does a resource's absence return the same code as lacking
permission to access it? How is a request rejected by a business rule told apart from one
with a malformed body? The next lesson asks the same set of scenarios of two different
servers, sets the codes they produce side by side, and shows what a wrong mapping produces
on the client.

### Status Code Selection

Source: https://academia.sh/en/courses/api-design/status-code-selection

Answering the same set of scenarios with two different code mappings and measuring the difference on the client: distinguishing close codes, the meanings of the 2xx family, the silent success a wrong mapping produces, and retries spent for nothing.

The previous lesson measured the promises a method carries, and ended on a gap: a server
that returns 404 to anything unmatched collapses a missing resource and an inapplicable
method into the same number. Throughout that lesson, the codes 201, 204, and 409 were used
without justification too.

This lesson builds that justification. The status code is the one part of a response the
client can read **without interpretation**. The body's `error` field is written to be read
by a human and differs across services; the status code, by contrast, is standard, and
components like the request layer, the cache, and the retry mechanism make their decisions
by looking at it. Choosing the wrong code means these components decide wrong — and that is
a measurable difference.

#### The Same Scenarios, Two Mappings

The server below is a single file that runs in two modes. In `strict` mode, every outcome is
reported with its own code. In `loose` mode, a common shortcut is applied: 200 with an
`error` field in the body for anything not found, and 500 for every other failure.

```js
// code-server.mjs — answers the same scenarios in two different status-code mapping modes
// Usage: node code-server.mjs <port> <strict|loose>
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const [PORT, MODE] = [Number(process.argv[2]), process.argv[3]];
const MEMBER_LIMIT = 2;
const db = new DatabaseSync("library.db");

const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data));
});
const send = (response, status, data, extraHeaders = {}) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", ...extraHeaders });
  response.end(data === null ? "" : JSON.stringify(data));
};
// Loose mode: reports every outcome as 200 or 500, leaving the distinction to the body.
const report = (response, status, data, extraHeaders = {}) => {
  if (MODE === "strict") return send(response, status, data, extraHeaders);
  if (status >= 400 && status < 500) return send(response, status === 404 ? 200 : 500, data ?? {});
  return send(response, 200, data ?? { result: "ok" });
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const book = /^\/books\/([^/]+)$/.exec(path);
  const loan = /^\/loans\/(\d+)$/.exec(path);

  if (request.method === "GET" && book) {
    const b = db.prepare("SELECT * FROM book WHERE isbn = ?").get(book[1]);
    return b ? report(response, 200, b) : report(response, 404, { error: "book_not_found" });
  }

  if (request.method === "POST" && path === "/loans") {
    const type = request.headers["content-type"] ?? "";
    if (!type.startsWith("application/json"))
      return report(response, 415, { error: "unsupported_type", expected: "application/json" });
    const raw = await readBody(request);
    let g;
    try { g = JSON.parse(raw); }
    catch { return report(response, 400, { error: "body_not_parsed" }); }

    if (!db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(g.isbn))
      return report(response, 422, { error: "validation", field: "isbn", reason: "not_in_catalog" });

    const open = db.prepare("SELECT COUNT(*) AS n FROM loan WHERE member = ? AND return IS NULL").get(g.member).n;
    if (open >= MEMBER_LIMIT)
      return report(response, 409, { error: "member_limit", limit: MEMBER_LIMIT, open });

    const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)")
                .run(g.member, g.isbn, g.issuedAt);
    const id = Number(s.lastInsertRowid);
    return report(response, 201, { id }, { location: `/loans/${id}` });
  }

  if (request.method === "DELETE" && loan) {
    db.prepare("DELETE FROM loan WHERE id = ?").run(Number(loan[1]));
    return report(response, 204, null);
  }

  // Resource exists but the method cannot be applied: not "not found."
  if (/^\/members\/[^/]+\/status$/.test(path))
    return report(response, 405, { error: "method_not_allowed" }, { allow: "GET" });

  report(response, 404, { error: "path_not_found" });
});

server.listen(PORT, "127.0.0.1", () => console.log(`${MODE} mode 127.0.0.1:${PORT}`));
```

On the other side stands a simplified version of the request layer from the Application
Architecture course. It has one rule: the 5xx class counts as retryable, the 4xx class does
not.

```js
// client.mjs — client that decides based on the status code; counts retries
// Usage: node client.mjs <base-address>
const BASE = process.argv[2];
const JSON_HEADER = { "content-type": "application/json" };

const SCENARIOS = [
  ["read",      "GET",    "/books/978-0131103627", null, null],
  ["missing",   "GET",    "/books/000-0000000000", null, null],
  ["create",    "POST",   "/loans", JSON_HEADER, '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-10"}'],
  ["limit",     "POST",   "/loans", JSON_HEADER, '{"member":"U-1002","isbn":"978-0131103627","issuedAt":"2026-03-11"}'],
  ["isbn",      "POST",   "/loans", JSON_HEADER, '{"member":"U-1001","isbn":"000-0000000000","issuedAt":"2026-03-10"}'],
  ["malformed", "POST",   "/loans", JSON_HEADER, "{broken"],
  ["type",      "POST",   "/loans", { "content-type": "text/plain" }, "hello"],
  ["delete",    "DELETE", "/loans/2", null, null],
  ["method",    "DELETE", "/members/U-1001/status", null, null],
];

let totalRequests = 0, silentSuccess = 0;
for (const [name, method, path, headers, body] of SCENARIOS) {
  let attempt = 0, response;
  do {
    attempt++; totalRequests++;
    response = await fetch(BASE + path, { method, headers: headers ?? {}, body });
  } while (response.status >= 500 && attempt < 4);

  const text = await response.text();
  const ok = response.status < 400;
  const bodyHasError = text.includes('"error"');
  if (ok && bodyHasError) silentSuccess++;
  const location = response.headers.get("location");
  console.log(
    `${name.padEnd(9)} ${String(response.status).padEnd(4)} ` +
    `${(ok ? "success" : "error").padEnd(7)} attempt: ${attempt}` +
    (location ? `  location: ${location}` : ""),
  );
}
console.log(`total requests: ${totalRequests}, success-counted errors: ${silentSuccess}`);
```

```bash
# The same client is run against both modes; the request counts are compared.
setup() { rm -f library.db && sqlite3 library.db < schema.sql; }

setup; node code-server.mjs 8477 strict & a=$!; sleep 0.4
echo "--- strict mode ---"; node client.mjs http://127.0.0.1:8477
kill $a

setup; node code-server.mjs 8478 loose & b=$!; sleep 0.4
echo "--- loose mode ---"; node client.mjs http://127.0.0.1:8478
kill $b
```

```
strict mode 127.0.0.1:8477
--- strict mode ---
read      200  success attempt: 1
missing   404  error   attempt: 1
create    201  success attempt: 1  location: /loans/4
limit     409  error   attempt: 1
isbn      422  error   attempt: 1
malformed 400  error   attempt: 1
type      415  error   attempt: 1
delete    204  success attempt: 1
method    405  error   attempt: 1
total requests: 9, success-counted errors: 0
loose mode 127.0.0.1:8478
--- loose mode ---
read      200  success attempt: 1
missing   200  success attempt: 1
create    200  success attempt: 1
limit     500  error   attempt: 4
isbn      500  error   attempt: 4
malformed 500  error   attempt: 4
type      500  error   attempt: 4
delete    200  success attempt: 1
method    500  error   attempt: 4
total requests: 24, success-counted errors: 1
```

#### Reading the Measurement

Nine scenarios finished in nine requests under the strict mapping; the loose mapping needed
twenty-four. The difference comes from four retried scenarios that share one trait: **none
of them get fixed by repeating.** The member limit is full, the ISBN is not in the catalog,
the body is malformed, the content type is wrong. Reporting these with 500 tells the client
"I have a temporary problem, try again." The client keeps that promise and tries three more
times — the request count triples, the result does not change.

The second number is sneakier. Under the loose mapping, one failure **counted as success**:
a client asking for a missing book got a 200 and, not reading the body's `error` field,
assumed it had one. An empty book card, an error never logged, a bug never found — all from
this one line.

The third difference is not in the lines but in what is missing. In the strict mapping, the
`create` scenario carries a `Location` header alongside its 201; in the loose mapping, that
header is gone. It is the only place that reports the new resource's address. Without it,
the client has to either read the identity from the body and build the address itself, or
refetch the collection, just to reach the loan record it created.

#### Distinguishing Close Codes

The question that drives code selection is this: **which part of the request was not
accepted?**

- **400** — The body could not be read. The request is broken at the format level; the JSON
  did not parse, a required header is missing. The server cannot make sense of the request,
  so it cannot say anything about the content.
- **415** — The body might be readable, but the content type is not supported. The problem
  is not the content, it is the format it was sent in.
- **422** — The body parsed, the fields were read, but the values were not accepted. An ISBN
  not in the catalog is like this: the syntax is right, the sent value is not valid. The
  practical value of this distinction is that the client can trace the error back to the
  form — it can say which field was rejected. Field-level error reporting is a separate
  lesson's subject.
- **409** — Both the body and the values are valid, but the system's current state does not
  allow the request to be fulfilled. A full member limit is exactly this. The difference
  between 422 and 409 is time: 422 is always invalid for the same body, 409 can become valid
  once the state changes.
- **404** — The addressed resource does not exist. The problem is in the path, not the body.
- **405** — The resource exists, the method cannot be applied. The response reports which
  methods are accepted through the `Allow` header; the server above sends `Allow: GET`.
  Confusing this with 404 pushes the client to look in the wrong place: it tries to fix the
  address, when it needs to fix the method instead.

Identity and permission codes — 401 and 403 — were deliberately left off this list. The
distinction between them rests on whether the identity is known, and it is covered in the
Authentication and Authorization course. The only rule here is that neither collapses into
404.

#### The 2xx Family

Success, too, is not a single code.

**200** is general success and carries a body. **201** says a new resource was created; when the new
resource's address cannot be read off the request address — as with a POST to a collection —
it must carry a `Location` header alongside it, because the client cannot build that address
itself. **204** reports success with no body; it is used for deletions and whenever there is
no new representation to return. **202** says the request was accepted but not yet
completed; it needs a separate resource to track the work's outcome, and it falls under
asynchronous processing.

The selection criterion, again, is the client's decision. A client that gets 204 does not
try to read the body; one that gets 200 does. Returning 200 with an empty body for a delete
forces the client to parse a body that is not there, and produces a body error.

#### Collapsing the Unknown into 500

The loose mode's real flaw is not using the 500 code, it is using it **for everything.** 500
is the code the server uses to report its own fault: an unexpected exception, an unreachable
database, a broken assumption. Using it for the client's error breaks two things at once: the
client does not fix a problem it could fix, since it was told the problem is not on its
side; and on the server, real faults mix with client errors and become unmeasurable.

The right behavior is to map every known failure to its own code and reserve 500 for the
case that cannot be classified. The body of an unclassified case gives no detail either;
writing the internal error message outward leaks the server's internal structure into the
contract.

#### Summary

- The status code is the part of a response the client reads without interpretation; the
  cache, the request layer, and the retry mechanism decide by looking at it.
- The same nine scenarios finished in nine requests under the strict mapping and produced
  twenty-four under the loose one; the difference comes from errors that will not be fixed
  being reported with 5xx and retried.
- Under the loose mapping, one failure counted as success: a "not found" returned with 200
  turned into an error the client never noticed.
- Error code selection looks at which part of the request was not accepted: 400 for format,
  415 for content type, 422 for field values, 409 for the system's current state, 404 for
  the address, 405 for the method.
- A 201 response is incomplete without a `Location` header; 204 reports success with no body
  and does not force the client to parse one.
- 500 is reserved only for a fault the server cannot classify; used for client errors, it
  misleads the client and makes real faults unmeasurable.

#### Next Step

The codes are settled, but what goes inside the response bodies is still open. This lesson
wrote bodies haphazardly: in one place just an identity field, in another an error name and
a limit value. What casing will field names use, how will dates be written, does a missing
field count the same as an empty value, will a collection response be a bare array? The next
lesson takes on body format and shows by running it where wrong choices silently corrupt
data — in parsing large numbers and in date notation.

### Request and Response Bodies

Source: https://academia.sh/en/courses/api-design/request-and-response-bodies

The contract value of body format: one spelling for field naming, the silent loss of large integers and decimal amounts, writing dates with zone information, separating a missing field from an empty value, and the envelope decision for collection responses.

The previous lesson settled the response's machine-read part — the status code. What is
left is the body. In the measurement servers, bodies were written haphazardly: in one place
just an identity field, elsewhere an error name and a limit value, in another the table's
column names as they stood.

Body format is as much a contract as the status code. If a field name changes, the client
breaks; if how a number is written is chosen wrong, the client does not break — something
worse happens, it silently reads the wrong value. This lesson lays out the body decisions,
and two of them are shown by measurement: parsing numbers and dates, and separating a
missing field from an empty value.

#### The Body Is a Contract

The first decision is field naming, and what matters is consistency, not the choice itself.
If one response has `issuedAt`, another `issuedDate`, and a third `issued_date`, the client
writes a separate parser for every endpoint. One casing is chosen for the whole course and
it does not change.

The second decision is that the body is not a copy of the database schema. In the previous
lesson the loan record was returned directly as a `SELECT *` result; this leaks column names
into the contract. When a column is renamed or added, the client is affected. The right
behavior is to build the representation explicitly: which fields go out is written down, the
rest stays inside.

The third decision concerns enumerated values. A loan record's status is reported with fixed
codes like `open`, `returned`, `overdue` — not display text like "Open" or "Returned." Text
depends on language and wording, while the code is part of the contract and can be compared.
What appears on screen is the client's decision.

#### Numbers: A Silent Loss

In the library catalog, every copy has an accession number that comes from another system.
An overdue fine is also a decimal amount. What happens when both are written as numbers in
JSON is worth examining.

```bash
# Two silent losses in a JSON body: a large integer and an unformatted date.
node -e '
const body = `{"accession": 9007199254740993, "fine": 0.1, "extra": 0.2}`;
const c = JSON.parse(body);
console.log("sent accession        :", body.match(/\d{16}/)[0]);
console.log("parsed accession      :", String(c.accession));
console.log("equal                 :", String(c.accession) === body.match(/\d{16}/)[0]);
console.log("fine + extra          :", c.fine + c.extra);
'
echo "---"
TZ=Europe/Istanbul node -e '
for (const d of ["12/03/2026", "2026-03-12", "2026-03-12T00:00:00", "2026-03-12T00:00:00+03:00"])
  console.log(d.padEnd(28), new Date(d).toISOString());
'
```

```
sent accession        : 9007199254740993
parsed accession      : 9007199254740992
equal                 : false
fine + extra          : 0.30000000000000004
---
12/03/2026                   2026-12-02T21:00:00.000Z
2026-03-12                   2026-03-12T00:00:00.000Z
2026-03-12T00:00:00          2026-03-11T21:00:00.000Z
2026-03-12T00:00:00+03:00    2026-03-11T21:00:00.000Z
```

The first three lines show an identity breaking during parsing. JSON's number type's
precision depends on the parser's floating-point representation; once the double-precision
representation defined in the How Computers Work course exceeds its integer limit, a value
rounds to the nearest representable one. `...993` is sent to the server, `...992` is read on
the client. No error is raised; the loss stays invisible until a query made with that
identity comes back empty.

The fourth line is a second problem from the same root: the result of `0.1 + 0.2` is not
exactly `0.3`. When monetary amounts travel as decimal numbers, addition produces
accumulating errors.

Both problems have the same fix: **numbers that will not be computed on travel as strings.**
Identities are strings — no arithmetic is done on them anyway. Monetary amounts travel
either as an integer in the smallest unit (cents) or as a string, and comparison is done in
that form. An identity looking like a number does not make it one.

#### Dates: No Such Thing as a Format-Free String

The output's second section shows date fields, and all four lines hit a different trap.
This block was run with `TZ=Europe/Istanbul`; the third line's result changes when the local
time zone changes — and that is exactly the problem.

`12/03/2026` was parsed not as March 12 but as December 2. Because the order of day and
month cannot be read from the notation itself, the parser applied its own assumption. If a
field like this exists in a contract, half its clients read eight months into the future.

`2026-03-12` is a zone-free day and was taken as midnight UTC. `2026-03-12T00:00:00`, carrying
no zone information, was counted as **local** time and shifted back a day once converted to
UTC. The same string corresponds to two different instants on two different machines. Only
the last line — the form that writes the zone offset explicitly — points to the same instant
everywhere.

The rule is this: **if an instant is being reported, the zone offset is written.** Fields
like the moment a loan is issued travel in the full form. If only a calendar day is being
reported — a due date, say — the field's name should say so and its notation should carry
no time of day at all. Mixing the two kinds in the same field produces one-day shifts across
daylight-saving transitions.

#### Absent and Empty Are Not the Same Thing

In a partial update, the client can say three things: set this field to this value, clear
this field, do not touch this field. In a JSON body, the first two are conveyed with a
value, the third with **the field being absent altogether.** A form that ignores this
distinction can never carry out a "clear" request.

```js
// field-server.mjs — the "absent" vs. "empty" distinction in a partial update
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const loose = /^\/loose\/loans\/(\d+)$/.exec(path);
  const strict = /^\/strict\/loans\/(\d+)$/.exec(path);
  if (request.method !== "PATCH" || !(loose || strict))
    return respond(response, 404, { error: "path_not_found" });

  const id = Number((loose ?? strict)[1]);
  const g = await readBody(request);
  const existing = db.prepare("SELECT * FROM loan WHERE id = ?").get(id);
  if (!existing) return respond(response, 404, { error: "loan_not_found" });

  if (loose) {
    // A form that does not separate an empty value from an absent field: null can never be written.
    const next = g.return ?? existing.return;
    db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(next, id);
  } else {
    // Checks whether the field is present in the body; its value is evaluated separately.
    if ("return" in g) db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(g.return, id);
  }
  respond(response, 200, db.prepare("SELECT id, return FROM loan WHERE id = ?").get(id));
});

server.listen(8479, "127.0.0.1", () => console.log("field server 127.0.0.1:8479"));
```

```bash
# The same three bodies are sent to both forms; the return field's final value is compared.
rm -f library.db && sqlite3 library.db < schema.sql
node field-server.mjs & server=$!
sleep 0.4

send() { curl -sS -X PATCH -H 'content-type: application/json' -d "$2" "http://127.0.0.1:8479$1"; echo; }
for mode in loose strict; do
  echo "--- $mode ---"
  send "/$mode/loans/2" '{"return":"2026-03-20"}'
  send "/$mode/loans/2" '{"member":"U-1001"}'
  send "/$mode/loans/2" '{"return":null}'
done

kill $server
```

```
field server 127.0.0.1:8479
--- loose ---
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
--- strict ---
{"id":2,"return":"2026-03-20"}
{"id":2,"return":"2026-03-20"}
{"id":2,"return":null}
```

The first two lines of both modes match: a value is assigned, the field is kept if absent
from the body. The third line diverges. In the loose form, the `return` field could not be
cleared, because the `??` operator treats `null` as "not given" and restores the old value.
There is no way to fix a loan record closed by mistake; the client sends the request, gets a
200, and nothing changes.

In the strict form, the distinction is read from the body's structure: if the field is
present, its value — `null` included — is applied; if not, it is left untouched. This
behavior must be written into the contract, because the client needs to knowingly use the
difference between "not sending the field" and "sending it empty."

The same distinction holds on the response side. Sending `null` for a field with no value
says something different from omitting it: the first says "this field exists but is
empty," the second says "this field is not in this representation." One rule is chosen for
the whole course; sometimes `null`, sometimes an absent field in a response forces the
client to handle both possibilities.

#### Collection Response: Bare Array or Envelope

A collection response's shortest form is a bare array. Being short is its only advantage. No
extra information can be attached to an array: how many records there are, where the next
page starts, what criteria the query ran with. This information can travel in headers, but
headers may not be cached alongside the body, and reading them takes separate work on the
client.

The **envelope** form wraps the body in an object: records sit in one field, pagination
information in another. Its cost is one extra level of nesting, its gain is that the
response can describe itself. The next two lessons — pagination and connection-based
responses — will use that field, so the envelope form is chosen for the whole course.

Singular resource responses, however, are not wrapped. The body returned from `/loans/2` is
directly the loan record's representation; wrapping it in a field adds no information and
just forces every client to descend one more level.

#### Summary

- One casing is chosen for field naming and does not change through the course; the body is
  an explicitly built representation, not a copy of the database schema.
- Numbers that will not be computed on — identities — travel as strings; in the measurement,
  `9007199254740993` became `9007199254740992` when parsed, with no error raised.
- Monetary amounts do not travel as decimal numbers; an integer in the smallest unit or a
  string is used instead.
- Fields that report an instant write the zone offset explicitly; zone-free notations
  correspond to different instants on different machines, and notations with an ambiguous
  day/month order can drift by months.
- In a partial update, whether a field is present in the body and whether its value is empty
  are separate pieces of information; a form that ignores this distinction makes clearing a
  field impossible.
- Collection responses are wrapped in an envelope, because pagination and navigation
  information will travel inside the body; singular resource responses are not wrapped.

#### Next Step

The envelope's empty field is the next lesson's subject. While the loan collection was
limited to three rows, sending every record in a single response was not a problem; at tens
of thousands of records, that response overwhelms both server and client. Records need to be
handed out in pieces, and there is more than one way to do it. The next lesson builds
offset-based pagination, actually produces a reader seeing a record twice or not at all once
a record is inserted in between, and then shows a method that does not drift under the same
scenario.

### Pagination

Source: https://academia.sh/en/courses/api-design/pagination

Serving a collection in pieces: the repeats and skips offset-based pagination produces when a record is inserted in between, cursor-based pagination not drifting under the same scenario, and deep pagination's cost in query plan and duration.

The previous lesson settled on wrapping collection responses in an envelope and left the
envelope's `pagination` field empty. This lesson fills that field.

The problem starts with numbers. With twenty records in the loan collection, sending all of
them in a single response is not a problem; at two hundred thousand records, the same
response strains the server's memory, the network, and the client's parser together. The
collection has to be handed out in pieces. There are two ways to say where a piece starts,
and the difference between them shows up once the collection changes between requests.

#### Two Methods

In **offset-based** pagination, the client says which page number it wants; the server skips
that many rows from the start of the sorted result and returns the next ones. The `LIMIT`
and `OFFSET` clauses from the SQL Fundamentals course map to this directly. The client
writes `?page=3`, the server applies `OFFSET 10`.

In **cursor-based** pagination, the client reports the position of the last record it saw;
the server returns what comes after it. There is no page number; every response carries the
starting point for the next request. The criterion is the sort key, so the method only works
under **deterministic ordering**: if the sort key can produce ties, a unique field is added
alongside it.

The server below serves both: the same table, the same ordering, two addresses.

```js
// page-server.mjs — serves the same collection with two pagination methods
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

const server = createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  const s = url.searchParams;
  const size = Math.min(Number(s.get("size") ?? 5), 50);

  if (url.pathname === "/offset/loans") {
    const page = Number(s.get("page") ?? 1);
    const rows = db.prepare(
      "SELECT id, issuedAt FROM loan ORDER BY id DESC LIMIT ? OFFSET ?"
    ).all(size, (page - 1) * size);
    return respond(response, 200, {
      data: rows.map((r) => r.id),
      pagination: { page, size },
    });
  }

  if (url.pathname === "/cursor/loans") {
    // No cursor: start from the top; a cursor asks for identities below it.
    const cursor = s.get("cursor");
    const rows = cursor === null
      ? db.prepare("SELECT id FROM loan ORDER BY id DESC LIMIT ?").all(size)
      : db.prepare("SELECT id FROM loan WHERE id < ? ORDER BY id DESC LIMIT ?")
          .all(Number(cursor), size);
    const last = rows.at(-1);
    return respond(response, 200, {
      data: rows.map((r) => r.id),
      pagination: { nextCursor: last ? String(last.id) : null },
    });
  }

  respond(response, 404, { error: "path_not_found" });
});

server.listen(8480, "127.0.0.1", () => console.log("page server 127.0.0.1:8480"));
```

The page size is taken from the client but capped at an upper bound. This is an inseparable
part of the contract: an unbounded `size` parameter leaves the door open to pulling the
entire collection in a single request.

#### Drift Measurement

The measurement sets up this scenario: the client fetches the first page, the collection
changes in the meantime, then it asks for the second page. Two kinds of change are tried
separately — inserting a record in between and deleting one. Data is reseeded at the start
of every round, so all four scenarios run from the same starting point.

```bash
# A twenty-record collection; the first page is fetched, the collection changes, the second page is fetched.
rm -f library.db && sqlite3 library.db < schema.sql
seed() {
  sqlite3 library.db <<'SQL'
DELETE FROM loan;
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 20)
INSERT INTO loan (member, isbn, issuedAt, return)
SELECT 'U-1001', '978-0262033848', date('2026-01-01', '+' || n || ' day'), NULL FROM s;
SQL
}
seed
node page-server.mjs & server=$!
sleep 0.4

insert() { sqlite3 library.db "INSERT INTO loan (member, isbn, issuedAt, return)
  VALUES ('U-1002','978-0201896831','2026-02-01',NULL),
         ('U-1002','978-0201896831','2026-02-02',NULL);"; }
delete() { sqlite3 library.db "DELETE FROM loan WHERE id IN (20, 19);"; }
get() { curl -sS "http://127.0.0.1:8480$1"; echo; }

for effect in insert delete; do
  seed; printf 'offset / %s  page 1 : ' "$effect"; get "/offset/loans?page=1&size=5"
  $effect;  printf 'offset / %s  page 2 : ' "$effect"; get "/offset/loans?page=2&size=5"
  seed; printf 'cursor / %s  page 1 : ' "$effect"; get "/cursor/loans?size=5"
  $effect;  printf 'cursor / %s  page 2 : ' "$effect"; get "/cursor/loans?cursor=16&size=5"
done

kill $server
```

```
page server 127.0.0.1:8480
offset / insert  page 1 : {"data":[20,19,18,17,16],"pagination":{"page":1,"size":5}}
offset / insert  page 2 : {"data":[17,16,15,14,13],"pagination":{"page":2,"size":5}}
cursor / insert  page 1 : {"data":[20,19,18,17,16],"pagination":{"nextCursor":"16"}}
cursor / insert  page 2 : {"data":[15,14,13,12,11],"pagination":{"nextCursor":"11"}}
offset / delete  page 1 : {"data":[20,19,18,17,16],"pagination":{"page":1,"size":5}}
offset / delete  page 2 : {"data":[13,12,11,10,9],"pagination":{"page":2,"size":5}}
cursor / delete  page 1 : {"data":[20,19,18,17,16],"pagination":{"nextCursor":"16"}}
cursor / delete  page 2 : {"data":[15,14,13,12,11],"pagination":{"nextCursor":"11"}}
```

Two of the four scenarios are flawed.

**Offset produced repeats on insertion.** The first page ran from 20 down to 16. Once two
new records entered in between, the start of the ordering shifted by two positions; the
second page now starts at 17. The client saw records 17 and 16 **twice**. On a screen that
accumulates pages into a list, the same loan record shows up as two rows.

**Offset produced skips on deletion.** Once two records were deleted, the ordering pulled up
by two positions and the second page started at 13. Records 15 and 14 never appeared on any
page. This is more dangerous than a repeat: what is missing goes unnoticed.

**Cursor gave the same result in both cases:** 15 down to 11. The reason is clear. A cursor
carries not which position a page is in, but **where the reader left off.** Inserting a
record at the front of the collection, or deleting one from the front, does not affect the
criterion "identities smaller than 16." Newly inserted records are seen once they come to
the front on a later round; that is a result of freshness, not drift.

#### The Cost of Deep Pagination

There is a second difference independent of drift, and it shows up as the page number
grows. Skipped rows are not skipped for free: the engine still has to find them.

```bash
# Deep pagination over two million rows: comparing plan and duration.
rm -f deep.db
sqlite3 deep.db <<'SQL'
CREATE TABLE loan (id INTEGER PRIMARY KEY, member TEXT NOT NULL, issuedAt TEXT NOT NULL);
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 2000000)
INSERT INTO loan (member, issuedAt)
SELECT 'U-' || (1000 + n % 500), date('2020-01-01', '+' || (n % 2000) || ' day') FROM s;
CREATE INDEX loan_issued_at ON loan (issuedAt DESC, id DESC);
SQL

sqlite3 deep.db <<'SQL'
.echo on
EXPLAIN QUERY PLAN
SELECT id FROM loan ORDER BY issuedAt DESC, id DESC LIMIT 20 OFFSET 1999980;
EXPLAIN QUERY PLAN
SELECT id FROM loan WHERE (issuedAt, id) < ('2020-01-02', 100) ORDER BY issuedAt DESC, id DESC LIMIT 20;
SQL

echo "--- timings ---"
sqlite3 deep.db <<'SQL'
.timer on
SELECT count(*) FROM (SELECT id FROM loan ORDER BY issuedAt DESC, id DESC LIMIT 20 OFFSET 1999980);
SELECT count(*) FROM (SELECT id FROM loan WHERE (issuedAt, id) < ('2020-01-02', 100) ORDER BY issuedAt DESC, id DESC LIMIT 20);
SQL
```

```
EXPLAIN QUERY PLAN
SELECT id FROM loan ORDER BY issuedAt DESC, id DESC LIMIT 20 OFFSET 1999980;
QUERY PLAN
`--SCAN loan USING COVERING INDEX loan_issued_at
EXPLAIN QUERY PLAN
SELECT id FROM loan WHERE (issuedAt, id) < ('2020-01-02', 100) ORDER BY issuedAt DESC, id DESC LIMIT 20;
QUERY PLAN
`--SEARCH loan USING COVERING INDEX loan_issued_at (issuedAt<?)
--- timings ---
20
Run Time: real 0.014 user 0.009015 sys 0.005149
20
Run Time: real 0.000 user 0.000068 sys 0.000015
```

Duration values vary by machine and by run; what stays fixed is the two plan lines. Per the
plan glossary read in the Advanced SQL course, the first says **SCAN**, the second
**SEARCH**. The offset-based query has to scan the index from the start and discard close to
two million entries; the cursor-based query lands directly on the point it is looking for.
Both return the same twenty rows, both use a covering index, but one grows more expensive
with page depth and the other does not.

The measurement showed a two-order-of-magnitude difference. The gap widens as scale grows,
because offset's cost is directly proportional to the page number: the last page means
reading the entire table.

#### Which Method, Where

Both methods have their place, and the selection criterion is the usage pattern.

**Offset-based** pagination fits admin screens where the user can jump to a page number:
"412 records total, 21 pages" can be reported, and page seven can be reached directly. Its
condition is that the data set stays largely static between requests and the page count
remains reasonable. If the drift risk is accepted, it should be accepted explicitly.

**Cursor-based** pagination fits lists that move as a stream: infinite-scroll record lists,
export jobs, sync jobs. It cannot report a total count or a page number; in exchange, it does
not drift and is not affected by depth.

The cursor itself is also a design decision. In the server above, the cursor travels as a
raw identity, which tells the outside that identity is the sort criterion. If sorting is by
a different field, the cursor needs to carry that field together with a tiebreaker field.
Sending the cursor as a single encoded string keeps the client from becoming dependent on
its contents: the cursor is a meaningless token to the client, only sent back as given. That
old cursors are invalidated and rejected once the sort criterion changes is also part of the
contract.

#### Summary

- Collections are handed out in pieces; where a piece starts is said either with a page
  number or with a cursor reporting the last record's position.
- In offset-based pagination, records repeat when one is inserted in between and are skipped
  when one is deleted; in the measurement, two records appeared twice and two never appeared.
- Cursor-based pagination gave the same result in both scenarios, because it carries not a
  position number but a place in the collection.
- A cursor only works with deterministic ordering; if the sort key is not unique, a unique
  field is added alongside it.
- Under deep pagination, the offset-based query scans the index from the start, the
  cursor-based one lands on the point it seeks; the plan lines split into SCAN and SEARCH.
- Page size is taken from the client but capped at an upper bound; an unbounded size leaves
  the door open to pulling the entire collection in one request.

#### Next Step

Pagination settled how much of the collection to give; it did not settle which records to
give. The clerk comes with requests like "only overdue loans," "books at the Central
branch," "sorted by author name." All of these will travel in the query part, all will turn
into SQL, and two traps will appear during that conversion: how a client-supplied value
travels into the query, and how a non-unique sort breaks pagination. The next lesson takes
on query parameter design together with both of these traps.

### Filtering, Sorting, and Search

Source: https://academia.sh/en/courses/api-design/filtering-sorting-and-search

Designing query parameters: translating filter and sort names to columns through an allowlist, carrying client values with bound variables, rejecting requests outside the allowlist, and how sort ties cause pagination to lose records.

The Pagination lesson settled how much of a collection to give. Which records to give, and
in what order, stayed open. The clerk searches the catalog for "Aho's books," "editions
after 1990," "titles containing Network"; wants the result sorted by year or by author.

All of this travels in the query part and all of it turns into SQL. The conversion needs a
design decision in two places: which column a client-supplied **name** corresponds to, and
how a client-supplied **value** enters the query. Keeping these two questions apart is this
lesson's core distinction.

#### Separating Name from Value

A query parameter can carry two different kinds of information. In the request
`?author=Aho`, `author` is a name — it says which column to look at — and `Aho` is a value.
The two cannot enter SQL by the same path.

The value never enters the query text; it travels separately as a **bound variable.** The
engine parses the query text once and slots in the value afterward, so the value's content
cannot change the query's structure. The Dynamic SQL Risks lesson in the Advanced SQL course
spells out why this is mandatory: when values are written into query text through string
concatenation, the characters inside a value become part of the query's structure.

A name, however, cannot be a bound variable — a column name is the query's structure, not
its value. This is why names need a different mechanism: an **allowlist**. A client-supplied
name is looked up in a fixed mapping on the server; if it is not in the mapping, the request
is rejected. This way, only column names the server itself wrote enter the query text.

```js
// filter-server.mjs — carries query parameters into SQL through an allowlist and bound variables
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Allowlists: a name coming from the client is translated to a column name here.
const FILTERS = {
  author:  "author = ?",
  yearMin: "year >= ?",
  branch:  "branch = ?",
  search:  "title LIKE ?",
};
const SORTS = { year: "year", title: "title", author: "author" };

const server = createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  if (url.pathname !== "/books") return respond(response, 404, { error: "path_not_found" });
  const s = url.searchParams;
  const size = Math.min(Number(s.get("size") ?? 3), 50);

  // Filters: only names in the allowlist turn into a condition.
  const conditions = [], values = [];
  for (const [name, clause] of Object.entries(FILTERS)) {
    const value = s.get(name);
    if (value === null) continue;
    conditions.push(clause);
    values.push(name === "search" ? `%${value}%` : value);   // value never enters the query text
  }

  // Sort: rejected if not in the allowlist, never falls back to a default.
  const sortName = s.get("sort") ?? "year";
  const column = SORTS[sortName];
  if (!column)
    return respond(response, 422, {
      error: "validation", field: "sort", allowed: Object.keys(SORTS),
    });

  // Cursor: loose mode carries only the sort column, strict mode also carries the tiebreaker.
  const cursor = s.get("cursor");
  const strict = s.get("mode") !== "loose";
  if (cursor !== null) {
    if (strict) {
      const [d, k] = cursor.split("|");
      conditions.push(`(${column}, isbn) > (?, ?)`);
      values.push(d, k);
    } else {
      conditions.push(`${column} > ?`);
      values.push(cursor);
    }
  }

  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
  const order = strict ? `${column}, isbn` : column;
  const rows = db.prepare(
    `SELECT isbn, title, author, year FROM book ${where} ORDER BY ${order} LIMIT ?`
  ).all(...values, size);

  const last = rows.at(-1);
  respond(response, 200, {
    data: rows.map((r) => `${r.year} ${r.isbn}`),
    pagination: {
      nextCursor: last ? (strict ? `${last[column]}|${last.isbn}` : String(last[column])) : null,
    },
  });
});

server.listen(8481, "127.0.0.1", () => console.log("filter server 127.0.0.1:8481"));
```

Notice how the query text gets built. Every piece entering the `conditions` array is a fixed
string the server itself wrote; no character coming from the client reaches it. Everything
the client sends lives in the `values` array and is handed to the query as a bound variable.

The catalog is expanded so sort ties become visible:

```sql
-- catalog.sql — an expanded catalog containing sort ties
DELETE FROM book;
INSERT INTO book (isbn, title, author, year, branch) VALUES
  ('K-01','Computer Networks','Tanenbaum',1978,'S-01'),
  ('K-02','Data Structures and Algorithms','Aho',1983,'S-01'),
  ('K-03','Compilers','Aho',1983,'S-02'),
  ('K-04','Operating Systems','Tanenbaum',1983,'S-02'),
  ('K-05','Shell Scripting',"O'Reilly",1986,'S-01'),
  ('K-06','Graph Algorithms','Sedgewick',1990,'S-01'),
  ('K-07','Numerical Methods','Press',1990,'S-02'),
  ('K-08','Network Programming','Stevens',1990,'S-01'),
  ('K-09','Formal Languages','Hopcroft',1995,'S-02');
```

The client that walks the pages is a separate file too; it does not build the cursor itself,
it takes it from the response.

```js
// walk.mjs — client that walks every page by following the cursor
// Usage: node walk.mjs <loose|strict>
const MODE = process.argv[2];
let cursor = null, page = 0;
const collected = [];

while (page < 10) {
  const url = new URL("http://127.0.0.1:8481/books");
  url.searchParams.set("mode", MODE);
  url.searchParams.set("sort", "year");
  if (cursor !== null) url.searchParams.set("cursor", cursor);

  const data = await (await fetch(url)).json();
  if (data.data.length === 0) break;
  page++;
  collected.push(...data.data);
  console.log(`${MODE.padEnd(7)} page ${page}: ${data.data.join("  ")}`);
  cursor = data.pagination.nextCursor;
}
console.log(`${MODE.padEnd(7)} total  : ${collected.length} rows\n`);
```

```bash
# The effect of the allowlist, bound variables, and sort ties on pagination.
rm -f library.db && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
node filter-server.mjs & server=$!
sleep 0.4

get() { curl -sS -G "http://127.0.0.1:8481/books" "$@"; echo; }
echo "--- filter and allowlist ---"
printf 'author=Aho     : '; get --data-urlencode "author=Aho"
printf "author=O'Reilly: "; get --data-urlencode "author=O'Reilly"
printf 'search=Network : '; get --data-urlencode "search=Network"
printf 'sort=branch    : '; get --data-urlencode "sort=branch"

echo "--- pagination on a column with sort ties (catalog has 9 books) ---"
node walk.mjs loose
node walk.mjs strict

kill $server
```

```
filter server 127.0.0.1:8481
--- filter and allowlist ---
author=Aho     : {"data":["1983 K-02","1983 K-03"],"pagination":{"nextCursor":"1983|K-03"}}
author=O'Reilly: {"data":["1986 K-05"],"pagination":{"nextCursor":"1986|K-05"}}
search=Network : {"data":["1978 K-01","1990 K-08"],"pagination":{"nextCursor":"1990|K-08"}}
sort=branch    : {"error":"validation","field":"sort","allowed":["year","title","author"]}
--- pagination on a column with sort ties (catalog has 9 books) ---
loose   page 1: 1978 K-01  1983 K-02  1983 K-03
loose   page 2: 1986 K-05  1990 K-06  1990 K-07
loose   page 3: 1995 K-09
loose   total  : 7 rows

strict  page 1: 1978 K-01  1983 K-02  1983 K-03
strict  page 2: 1983 K-04  1986 K-05  1990 K-06
strict  page 3: 1990 K-07  1990 K-08  1995 K-09
strict  total  : 9 rows
```

#### Keeping the Value Out of the Query Text

The second line is the most concrete proof of this distinction. The value `O'Reilly`
contains an apostrophe, and that is exactly the character that ends an SQL string literal.
If the value had been written into the query text, the query would split at that point and
the rest would be read as SQL. Carried as a bound variable, none of that happens: the value
is only a value, it finds the record and returns it.

This is not a problem of "escaping" apostrophe-carrying names. An escape character looks
like a solution too, but it needs a separate rule for every data type and dialect, and gets
forgotten somewhere. With a bound variable there is nothing to forget; the query text stays
fixed regardless of the client.

The `search=Network` filter in the third line carries the same principle one step further.
Wildcard characters are added on the server — the client-supplied value `Network` is turned
into `%Network%` — so the client cannot write a pattern, it can only supply the text to
search for.

#### Rejecting What Is Outside the Allowlist

The fourth line returned a 422 to the `sort=branch` request and reported the allowed names
in the response. Both decisions here are deliberate.

First, the request was **rejected**; it did not silently fall back to a default sort.
Falling back to a default means the client never learns it sent a wrong name; the screen
shows the wrong order and no one goes looking for why. The criterion from the Status Code
Selection lesson applies here too: an unaccepted field value is reported with 422.

Second, the response lists the allowed names. This is a self-describing part of the
contract; it steers the client toward fixing the request without going to the
documentation.

The allowlist also keeps sorting's cost under control. If sorting were allowed by any
column, a request to sort by a column with no index would force a full table read. Only
columns that have been decided to be supported — and indexed if needed — go on the list.

#### A Sort Tie Loses Records

The output's last section loops back to the Pagination lesson. There are nine books in the
catalog; loose mode showed seven of them. K-04 and K-08 never appeared on any page.

The reason is measurably plain. In loose mode, the cursor carries only the year. The first
page ends at 1983 and the cursor becomes `1983`; the second page arrives with the condition
`year > 1983`, and the K-04 record sharing that same year falls outside it. The same thing
repeats at 1990, and K-08 is lost. As long as the sort key is not unique, the criterion
"after this value" always leaves out part of the ties.

In strict mode, the cursor carries two fields: the sort column and a unique identity. Both
the condition and the sort now work over two fields. Because a definite order is established
among records sharing the same year, "after this point" is well defined, and all nine of the
nine records appear.

Two rules follow from this. Whatever the sort criterion, a unique field is appended to the
end of it — this is what **deterministic ordering** requires. And the cursor carries the
entire sort criterion: a single-field cursor can only describe a single-field sort.

#### Summary

- A query parameter carries two kinds of information: name and value. A name is translated
  to a column through an allowlist, a value travels as a bound variable; the two do not
  enter SQL by the same path.
- Only fixed pieces the server wrote enter the query text; no character from the client
  reaches the query's structure. An apostrophe-carrying author name is found without
  incident because of this.
- Search patterns are built on the server; the client supplies the text to search for, not
  the wildcard.
- A sort request outside the allowlist is rejected with 422 and the allowed names are
  reported in the response; silently falling back to a default makes the error invisible.
- The allowlist also bounds sorting's cost: only columns decided to be supported go on the
  list.
- If the sort key is not unique, cursor-based pagination skips part of the ties; in the
  measurement, seven of nine records appeared. Appending a unique field to the sort and the
  cursor removes this loss.

#### Next Step

Which records come back, and in what order, is settled. What is left is **how much** of each
record comes back. While the catalog list screen shows only a book's title and author, the
server sends every field on every row; the loan history screen, meanwhile, fires off
separate requests for information it cannot find in a single response. Both problems come
from the same place: the server alone decides the representation's level of detail. The next
lesson builds the mechanism that lets the client choose fields and measures the difference
in transferred bytes.

### Partial Response and Field Selection

Source: https://academia.sh/en/courses/api-design/partial-response-and-field-selection

Letting the client take part in a representation's level of detail: measuring over- and under-fetching in request count and bytes, building field selection and expansion through an allowlist, and the cost this flexibility adds to the contract.

The previous two lessons settled which records a collection returns, in what order, and how
many. How much **of each record** comes back stayed open.

The library interface has two screens. The loan list screen shows only a book's title, a
member's name, and the issue date on every row. The loan detail screen wants every field of
the record. Both draw from the same collection. If the server returns a single
representation, that representation is either too narrow for the list or too wide for the
detail. Both cases carry a measurable cost.

#### Over-Fetching and Under-Fetching

**Over-fetching** is sending fields the client does not use. If the loan list screen shows
three fields while the server sends ten, the difference travels the network, gets parsed on
the client, and is thrown away.

**Under-fetching** is a single request failing to feed the screen. If a loan record carries
only an ISBN and a member code, getting the book's title and the member's name needs two
more requests per row. A forty-row list means eighty extra requests.

The two look like opposites but come from the same root: the server alone decides the
representation's level of detail. The fix is to hand part of that decision to the client —
but as flexibility whose boundaries the server draws.

```js
// field-selection-server.mjs — loan collection supporting field selection and expansion
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// Allowlist: a field the client names is translated to a column expression here.
const BASE = { id: "o.id", member: "o.member", isbn: "o.isbn", issuedAt: "o.issuedAt", return: "o.return" };
const EXPANDED = { bookTitle: "k.title", bookAuthor: "k.author", memberName: "u.name", memberBranch: "u.branch" };

const server = createServer((request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  const s = url.searchParams;

  if (/^\/books\/[^/]+$/.test(url.pathname)) {
    const isbn = url.pathname.split("/")[2];
    const b = db.prepare("SELECT * FROM book WHERE isbn = ?").get(isbn);
    return b ? respond(response, 200, b) : respond(response, 404, { error: "book_not_found" });
  }
  if (/^\/members\/[^/]+$/.test(url.pathname)) {
    const code = url.pathname.split("/")[2];
    const m = db.prepare("SELECT * FROM member WHERE code = ?").get(code);
    return m ? respond(response, 200, m) : respond(response, 404, { error: "member_not_found" });
  }
  if (url.pathname !== "/loans") return respond(response, 404, { error: "path_not_found" });

  // Expansion: fields of related resources are folded into the response.
  const expand = (s.get("expand") ?? "").split(",").filter(Boolean);
  const offered = { ...BASE };
  if (expand.includes("book")) Object.assign(offered, { bookTitle: EXPANDED.bookTitle, bookAuthor: EXPANDED.bookAuthor });
  if (expand.includes("member")) Object.assign(offered, { memberName: EXPANDED.memberName, memberBranch: EXPANDED.memberBranch });

  // Field selection: requested names are intersected with what's offered, unknown names are rejected.
  const requested = (s.get("fields") ?? "").split(",").filter(Boolean);
  const unknown = requested.filter((a) => !(a in offered));
  if (unknown.length)
    return respond(response, 422, { error: "validation", field: "fields", unknown, offered: Object.keys(offered) });
  const selected = requested.length ? requested : Object.keys(offered);

  const selection = selected.map((a) => `${offered[a]} AS ${a}`).join(", ");
  const rows = db.prepare(
    `SELECT ${selection} FROM loan o
       LEFT JOIN book k ON k.isbn = o.isbn
       LEFT JOIN member u ON u.code = o.member
      ORDER BY o.id LIMIT ?`
  ).all(Number(s.get("size") ?? 40));

  respond(response, 200, { data: rows, pagination: { size: rows.length } });
});

server.listen(8482, "127.0.0.1", () => console.log("field selection server 127.0.0.1:8482"));
```

Column names come from an allowlist again; the previous lesson's distinction applies here
too. The client sends the name `bookTitle`, the server translates it to the expression
`k.title`. A name not on the list can never reach the query text.

#### Measurement

Three different strategies feed the same screen. The measurement counts the request count
and the transferred body bytes.

```js
// measurement.mjs — measures the request count and transferred bytes of three strategies
const T = "http://127.0.0.1:8482";
let requests = 0, bytes = 0;
const call = async (path) => {
  requests++;
  const text = await (await fetch(T + path)).text();
  bytes += Buffer.byteLength(text);
  return JSON.parse(text);
};
const report = (name) => { console.log(`${name.padEnd(34)} requests: ${String(requests).padStart(3)}  bytes: ${bytes}`); requests = 0; bytes = 0; };

// 1) Separately: the loan list, then the book and member for every record.
const list = await call("/loans");
for (const l of list.data) { await call(`/books/${l.isbn}`); await call(`/members/${l.member}`); }
report("separate requests (under-fetching)");

// 2) Expansion: a single request, but every field comes along.
await call("/loans?expand=book,member");
report("expansion (over-fetching)");

// 3) Expansion + field selection: a single request, only the fields the screen uses.
await call("/loans?expand=book,member&fields=id,issuedAt,bookTitle,memberName");
report("expansion + field selection");
```

```bash
# Forty loan records; the same screen is fed by three different strategies.
rm -f library.db && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
sqlite3 library.db <<'SQL'
DELETE FROM loan;
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM s WHERE n < 40)
INSERT INTO loan (member, isbn, issuedAt, return)
SELECT CASE WHEN n % 2 = 0 THEN 'U-1001' ELSE 'U-1002' END,
       'K-0' || (1 + n % 9), date('2026-01-01', '+' || n || ' day'), NULL
FROM s;
SQL
node field-selection-server.mjs & server=$!
sleep 0.4

node measurement.mjs
echo "--- sample response row ---"
curl -sS "http://127.0.0.1:8482/loans?expand=book,member&fields=id,issuedAt,bookTitle,memberName&size=2"; echo
curl -sS "http://127.0.0.1:8482/loans?fields=id,bookTitle&size=2"; echo

kill $server
```

```
field selection server 127.0.0.1:8482
separate requests (under-fetching) requests:  81  bytes: 8968
expansion (over-fetching)          requests:   1  bytes: 7368
expansion + field selection        requests:   1  bytes: 3741
--- sample response row ---
{"data":[{"id":1,"issuedAt":"2026-01-02","bookTitle":"Data Structures and Algorithms","memberName":"Ben Turner"},{"id":2,"issuedAt":"2026-01-03","bookTitle":"Compilers","memberName":"Alina Drake"}],"pagination":{"size":2}}
{"error":"validation","field":"fields","unknown":["bookTitle"],"offered":["id","member","isbn","issuedAt","return"]}
```

The first line shows under-fetching's cost: eighty-one requests for thirty-nine records. The
byte difference is not the real issue here; the request count is. Every request means a
network round trip, and when trips wait on each other, latency multiplies directly. There is
also repetition, since the same book and the same member get requested over and over.

The second line drops to a single request but carries 7368 bytes. Five fields the screen does
not use — ISBN, member code, return date, book author, member branch — appear on every row.

The third line solves both together: a single request, 3741 bytes. The same screen is fed
with roughly half the data. The ratio depends on the data; here the savings come from how
large a share field names and unused values take up in the total body.

The last line is the contract defending itself. The request `fields=id,bookTitle` arrived
without `expand=book`; `bookTitle` is not among the fields offered in that request. The
server rejects the request with 422 and reports both the unknown name and the fields offered
in that request. Silently dropping the field would have led the client to expect a field
that was never there and render the screen with an `undefined` value.

#### The Contract's Cost for This Flexibility

Field selection is not free, and its cost is paid on the contract surface.

First, **cache keys multiply**. The address is a cache key, and because the field list
enters the address, `fields=id,title` and `fields=title,id` produce two separate keys. To
avoid storing the same data twice, a canonical form for the field list must be settled:
names are sorted and duplicates dropped. This canonical-form decision is the same kind as
the one in the URI Design lesson.

Second, **the combinations to test multiply**. At an endpoint with five base fields and four
expansion fields, the number of field sets a client could request is too large to test by
hand. This is why the allowlist stays narrow: not every column is offered, only the fields
screens actually want.

Third, **expansion depth must be bounded**. This server offers one level of expansion: from
loan to book and member. If two levels were allowed — from book to branch, from branch to
the responsible member — a single request would turn into a query tree branching on the
server side. The single request the client sees hides the cost the server pays.

#### Field Selection or a Named View

The alternative to field selection is the server defining a handful of ready-made
representations: `?view=list` and `?view=detail`, say. Flexibility drops under this
approach, but three things are gained. The number of cache keys falls to two. No
combinations are left to test. The server can write one query per view and optimize it.

The selection criterion is the number of clients. If a single interface uses the server, a
named view is enough; the view's definition changes as the screen does. If there are many
independent clients — a mobile app, an admin panel, another service — defining a separate
view for each bloats the contract; field selection needs less upkeep in that case.

The two approaches are also used together: named views for common cases, field selection for
the rest. This is the resource-oriented style's counterpart to a problem query-based styles
solve from the start; the trade-off discussed in the API Styles topic turns into concrete
numbers here.

#### Summary

- Over-fetching is carrying unused fields; under-fetching is a single request failing to
  feed the screen. Both come from the server alone deciding the representation's level of
  detail.
- In the measurement, the same screen consumed 81 requests and 8968 bytes with separate
  requests, 1 request and 7368 bytes with expansion, and 1 request and 3741 bytes with
  expansion and field selection.
- Field names and expansion names pass through an allowlist; only column expressions the
  server wrote enter the query text.
- A request for an undefined field is not silently dropped, it is rejected with 422, and the
  fields offered in that request are reported in the response.
- Field selection multiplies cache keys and increases the combinations to test; the field
  list's canonical form must be settled and the allowlist kept narrow.
- In services with few clients, named views need less upkeep than field selection; the two
  approaches can also be used together.

#### Next Step

The four lessons up to here fixed the read side: which records, in what order, how much, in
how many requests. On the write side, a gap remains from the third lesson. POST is not
idempotent; when the network drops or the response gets lost on the way back, the client
retries the request and a second loan record is born. The client cannot know whether the
request arrived, and the server cannot see that two requests came from the same intent. The
next lesson closes this gap with a key the client generates, and shows by counting that two
requests sent with the same key produce a single effect.

### Idempotency Keys

Source: https://academia.sh/en/courses/api-design/idempotency-keys

The idempotency key that prevents a repeated effect on retry: matching the key to its scope and a body digest, a second request with the same key replaying the first response, rejecting a conflict from a different body, and the key store's lifetime.

The third lesson measured that POST is not idempotent: sending the same request twice
produced two loan records. Back then, that was noted as POST's nature and left alone. Once
the eighth lesson closed the read side, this gap remained.

The gap comes from a real situation. The client sends a request to create a loan record, and
the network drops. The network-error class of the error contract from the Application
Architecture course described exactly this: **the request may have reached the server, or
it may not have.** If the response was lost on the way back, the record was created but the
client does not know it. If the client retries, there is a risk of a second record; if it
does not, the member may end up without the book. Neither option is acceptable.

This lesson builds a third way: attaching a client-generated identity to the request.

#### What the Key Does

An **idempotency key** is the unique name a client gives to an operation's intent. It
travels in the `Idempotency-Key` header and has three rules.

The key is generated **per intent, not per request.** A key is generated when the user
presses the "issue loan" button; three attempts made because the network dropped all carry
that same key. If the user wants to check out a second book, a new key is generated.

The key **is not enough on its own**. The server must also store which operation the key is
tied to: which address, with which body. Otherwise, if the client mistakenly reuses the same
key on a different request, that request gets silently swallowed.

The key **is stored together with the response**. When the second request arrives, the
operation is not redone; the response generated the first time is returned as is. The client
learns the result even if it never saw the first response.

```js
// key-server.mjs — loan creation guarded by an idempotency key
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import { createHash } from "node:crypto";

const db = new DatabaseSync("library.db");
db.exec(`CREATE TABLE IF NOT EXISTS idempotency (
  key       TEXT PRIMARY KEY,
  path      TEXT NOT NULL,
  digest    TEXT NOT NULL,
  status    INTEGER,
  response  TEXT,
  createdAt TEXT NOT NULL
)`);

const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data));
});
const send = (response, status, data, extra = {}) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", ...extra });
  response.end(JSON.stringify(data));
};

const server = createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  if (request.method !== "POST" || path !== "/loans") return send(response, 404, { error: "path_not_found" });

  const raw = await readBody(request);
  const key = request.headers["idempotency-key"];
  const digest = createHash("sha256").update(raw).digest("hex").slice(0, 16);
  const body = JSON.parse(raw);

  // No key: every call produces a new record.
  if (!key) return send(response, ...create(body));

  const record = db.prepare("SELECT * FROM idempotency WHERE key = ?").get(key);
  if (record) {
    // Same key, different body: the client reused the key.
    if (record.path !== path || record.digest !== digest)
      return send(response, 409, { error: "key_conflict", key });
    return send(response, record.status, JSON.parse(record.response), { "idempotency-replayed": "true" });
  }

  // Reserving the key and doing the work happen in one block, with no wait in between.
  db.prepare("INSERT INTO idempotency (key, path, digest, createdAt) VALUES (?,?,?,datetime('now'))")
    .run(key, path, digest);
  const [status, data] = create(body);
  db.prepare("UPDATE idempotency SET status = ?, response = ? WHERE key = ?")
    .run(status, JSON.stringify(data), key);
  send(response, status, data);
});

function create(body) {
  if (!db.prepare("SELECT 1 FROM book WHERE isbn = ?").get(body.isbn))
    return [422, { error: "validation", field: "isbn" }];
  const s = db.prepare("INSERT INTO loan (member, isbn, issuedAt, return) VALUES (?,?,?,NULL)")
              .run(body.member, body.isbn, body.issuedAt);
  return [201, { id: Number(s.lastInsertRowid), member: body.member, isbn: body.isbn, issuedAt: body.issuedAt }];
}

server.listen(8483, "127.0.0.1", () => console.log("key server 127.0.0.1:8483"));
```

#### Measurement

The same body is sent twice: first without a key, then with one. The catalog is set up with
the `catalog.sql` file from the seventh lesson.

```bash
# The same request is sent twice: first without a key, then with an idempotency key.
rm -f library.db && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
sqlite3 library.db "DELETE FROM loan;"
node key-server.mjs & server=$!
sleep 0.4

BODY='{"member":"U-1001","isbn":"K-02","issuedAt":"2026-03-10"}'
say() { printf '%-30s loan rows: %s\n' "$1" "$(sqlite3 library.db 'SELECT COUNT(*) FROM loan;')"; }
send() { curl -sS -D - -o /tmp/body.$$ -X POST -H 'content-type: application/json' "$@" \
            -d "$BODY" http://127.0.0.1:8483/loans \
          | grep -iE '^(HTTP/|idempotency-replayed)' | tr -d '\r'; cat /tmp/body.$$; echo; rm -f /tmp/body.$$; }

echo "--- no key, twice ---"
send; send; say "no key x2"

sqlite3 library.db "DELETE FROM loan; DELETE FROM idempotency;"
echo "--- same key, twice ---"
send -H 'Idempotency-Key: OD-7f21'
send -H 'Idempotency-Key: OD-7f21'
say "with key x2"

echo "--- same key, different body ---"
curl -sS -o /dev/null -w 'status: %{http_code}\n' -X POST -H 'content-type: application/json' \
  -H 'Idempotency-Key: OD-7f21' -d '{"member":"U-1002","isbn":"K-03","issuedAt":"2026-03-11"}' \
  http://127.0.0.1:8483/loans
say "after conflict"

kill $server
```

```
key server 127.0.0.1:8483
--- no key, twice ---
HTTP/1.1 201 Created
{"id":1,"member":"U-1001","isbn":"K-02","issuedAt":"2026-03-10"}
HTTP/1.1 201 Created
{"id":2,"member":"U-1001","isbn":"K-02","issuedAt":"2026-03-10"}
no key x2                      loan rows: 2
--- same key, twice ---
HTTP/1.1 201 Created
{"id":1,"member":"U-1001","isbn":"K-02","issuedAt":"2026-03-10"}
HTTP/1.1 201 Created
idempotency-replayed: true
{"id":1,"member":"U-1001","isbn":"K-02","issuedAt":"2026-03-10"}
with key x2                    loan rows: 1
--- same key, different body ---
status: 409
after conflict                 loan rows: 1
```

The two requests without a key produced two records and returned two different identities.
The two requests with a key produced a single record and returned **the same body**: the
second response's `id` field is also 1. The second response also carries the
`Idempotency-Replayed` header; this header is not mandatory, but it lets the client and the
logs distinguish the situation.

The last section tests reusing the key. The same `OD-7f21` key arrived with a different
body, and the request was rejected with 409; the loan record count stayed at one. Had the
body digest not been stored, this request would have been silently swallowed and the client
would have assumed a second loan was created. Rejecting it is the right behavior: only the
client can fix a key used incorrectly.

#### Concurrent Requests

Two requests arriving one after another is the easy case. The hard case is both arriving at
the same time: while the client times out and retries, the first request may still be being
processed.

```bash
# Five requests with the same key are sent concurrently; the resulting records are counted.
rm -f library.db response-*.json && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
sqlite3 library.db "DELETE FROM loan;"
node key-server.mjs & server=$!
sleep 0.4

pids=""
for i in 1 2 3 4 5; do
  curl -sS -o "response-$i.json" -X POST -H 'content-type: application/json' \
    -H 'Idempotency-Key: OD-9c04' \
    -d '{"member":"U-1002","isbn":"K-05","issuedAt":"2026-03-12"}' \
    http://127.0.0.1:8483/loans &
  pids="$pids $!"
done
wait $pids

for f in response-*.json; do cat "$f"; echo; done | sort -u > variants.txt
echo "distinct response bodies : $(wc -l < variants.txt | tr -d ' ')"
echo "body                      : $(cat variants.txt)"
echo "loan rows                 : $(sqlite3 library.db 'SELECT COUNT(*) FROM loan;')"

echo "--- idempotency store age ---"
sqlite3 library.db "
  INSERT INTO idempotency (key, path, digest, status, response, createdAt)
  VALUES ('OD-old','/loans','0000',201,'{}',datetime('now','-30 day'));
  SELECT key || '  age: ' || CAST(julianday('now') - julianday(createdAt) AS INT) || ' days'
    FROM idempotency ORDER BY createdAt;
  DELETE FROM idempotency WHERE createdAt < datetime('now','-7 day');
  SELECT 'remaining after cleanup: ' || COUNT(*) FROM idempotency;"

kill $server
```

```
key server 127.0.0.1:8483
distinct response bodies : 1
body                      : {"id":1,"member":"U-1002","isbn":"K-05","issuedAt":"2026-03-12"}
loan rows                 : 1
--- idempotency store age ---
OD-old  age: 30 days
OD-9c04  age: 0 days
remaining after cleanup: 1
```

Five concurrent requests produced a single record, and all five got the same body. What
makes this work is the code's structure: there is no wait point between recording the key
and doing the work. If a wait were introduced in between, both requests could see "no key"
and both could create a record.

This arrangement being sufficient on a single-process server does not mean it stays
sufficient in a multi-process deployment. The general fix is **writing the key as a primary
key**: the second write attempt is rejected by the database, and that request is routed to
wait and reread, or to an "operation in progress" response. How races of this kind are
isolated at the transaction level is the subject of the Transactions topic in the Advanced
SQL course.

#### The Key Store's Lifetime

The output's last section shows the key store aging. A key written thirty days ago was
cleaned up under a seven-day retention window.

The retention decision has two bounds. The lower bound is set by the client's **longest
retry window**: if a client is still retrying twenty-four hours later, the key must be kept
that long. The retry budget defined in the Retry and Backoff lesson of the Application
Architecture course gives this window. The upper bound is set by storage cost and the size
of the response bodies written into the table.

Once the window closes, a request arriving with the same key counts as a new operation and
produces a new record. This is the design's accepted limit; the duration is chosen with this
in mind.

One last boundary is scope. A key must be unique together with the authenticated client, not
on its own: two different clients producing the same key string must not lead one's request
to receive the other's response. In the table above, the key alone is the primary key; in a
real deployment, the client's identity also becomes part of the key.

#### Summary

- Under a network error, whether the request reached the server is unknown; retrying risks a
  repeated effect, not retrying risks a missing one.
- An idempotency key is generated per intent, not per request; every attempt of the same
  operation carries the same key.
- The server stores the key together with the path and a body digest; in the measurement,
  two requests without a key produced two records, two requests with one produced one
  record, and the second response matched the first exactly.
- The same key arriving with a different body is rejected with 409; silently swallowing it
  would show the client an operation that never happened as if it had.
- No wait must fall between recording the key and doing the work; five concurrent requests
  produced a single record under this structure.
- The key store's retention period cannot be shorter than the client's longest retry window;
  once the window closes, the same key counts as a new operation.

#### Next Step

Across nine lessons, the client was assumed to build every address itself: the collection
address, the page cursor, the field list, even the address of a newly created record. To do
this, the client embeds the contract's shape into itself — and when the contract changes,
every client gets updated separately. The next lesson loosens this dependency: the response
carries within itself the addresses the client will use in its next request. Which
operations a loan record is open to is also read from the same place; a returned record no
longer offers a return link.

### Connection-Based Responses

Source: https://academia.sh/en/courses/api-design/connection-based-responses

The response carrying navigation and action links within itself: walking pages by following only the next link, action links that open and close based on a resource's state, and measuring the client's address-building burden.

Across nine lessons, the client was assumed to build addresses itself. It knew the
collection address, the page cursor, the field list, even the address of a newly created
record. This has a cost: the contract's shape gets embedded into the client. If the cursor
parameter's name changes, if the method used for a return changes, or if an operation
becomes conditional, every client has to be updated separately.

This lesson loosens that dependency. The response carries within itself the addresses the
client will use in its next request; the client knows only the entry address and reads the
rest. The second, more important consequence is this: which operations a resource **is open
to** is also read from the response. A loan record that has been returned no longer offers a
return link.

#### The Structure of a Link

A link must carry two pieces of information: which address to go to and with which method. A
link that carries only an address makes read and write indistinguishable. Action links need
a third piece: which fields are expected in the body.

Which links a response carries depends on the resource's kind. A collection response carries
navigation links: itself, and the next page if there is one. A singular resource response
carries its own address, the addresses of related resources, and the operations applicable
at that moment.

```js
// link-server.mjs — a service whose responses carry navigation and action links inside them
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("library.db");
const readBody = (request) => new Promise((resolve) => {
  let data = ""; request.on("data", (p) => (data += p));
  request.on("end", () => resolve(data ? JSON.parse(data) : {}));
});
const respond = (response, status, data) => {
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(data));
};

// A record's current state determines which operations are open.
const recordView = (o) => ({
  id: o.id, member: o.member, isbn: o.isbn, issuedAt: o.issuedAt, return: o.return,
  links: {
    self:   { path: `/loans/${o.id}`, method: "GET" },
    member: { path: `/members/${o.member}`, method: "GET" },
    ...(o.return === null
      ? { return: { path: `/loans/${o.id}`, method: "PATCH", fields: ["return"] } }
      : {}),
  },
});

const server = createServer(async (request, response) => {
  const url = new URL(request.url, "http://127.0.0.1");
  const single = /^\/loans\/(\d+)$/.exec(url.pathname);
  const member = /^\/members\/([\w-]+)$/.exec(url.pathname);

  if (request.method === "GET" && member) {
    const m = db.prepare("SELECT * FROM member WHERE code = ?").get(member[1]);
    return m ? respond(response, 200, m) : respond(response, 404, { error: "member_not_found" });
  }

  if (request.method === "GET" && url.pathname === "/loans") {
    const size = Math.min(Number(url.searchParams.get("size") ?? 3), 50);
    const cursor = url.searchParams.get("cursor");
    const rows = cursor === null
      ? db.prepare("SELECT * FROM loan ORDER BY id LIMIT ?").all(size)
      : db.prepare("SELECT * FROM loan WHERE id > ? ORDER BY id LIMIT ?").all(Number(cursor), size);
    const last = rows.at(-1);
    // The next page is reported only if it actually exists.
    const remaining = last
      ? db.prepare("SELECT COUNT(*) AS n FROM loan WHERE id > ?").get(last.id).n : 0;
    return respond(response, 200, {
      data: rows.map(recordView),
      links: {
        self: { path: `/loans?size=${size}`, method: "GET" },
        ...(remaining > 0
          ? { next: { path: `/loans?size=${size}&cursor=${last.id}`, method: "GET" } }
          : {}),
      },
    });
  }

  if (request.method === "GET" && single) {
    const o = db.prepare("SELECT * FROM loan WHERE id = ?").get(Number(single[1]));
    return o ? respond(response, 200, recordView(o)) : respond(response, 404, { error: "loan_not_found" });
  }

  if (request.method === "PATCH" && single) {
    const g = await readBody(request);
    const id = Number(single[1]);
    if ("return" in g) db.prepare("UPDATE loan SET return = ? WHERE id = ?").run(g.return, id);
    return respond(response, 200, recordView(db.prepare("SELECT * FROM loan WHERE id = ?").get(id)));
  }

  respond(response, 404, { error: "path_not_found" });
});

server.listen(8484, "127.0.0.1", () => console.log("link server 127.0.0.1:8484"));
```

Two details matter in particular. The `next` link is generated only if a record genuinely
remains, so the client never needs to write a rule like "stop if an empty page arrives." The
`return` link is generated only while the record's `return` field is empty; the business
rule ends up embedded inside the representation.

#### A Client That Knows Only the Entry Address

The client on the other side writes a single address and builds no other.

```js
// navigator.mjs — client that knows only the entry address, reads the rest from responses
const BASE = "http://127.0.0.1:8484";
const ENTRY = "/loans?size=3";
const addressesBuilt = 1;   // only the entry address is written on the client

const call = async (link, body) => {
  const options = { method: link.method };
  if (body) {
    options.headers = { "content-type": "application/json" };
    options.body = JSON.stringify(body);
  }
  return (await fetch(BASE + link.path, options)).json();
};

// 1) Pages are walked only by following the "next" link.
let page = await (await fetch(BASE + ENTRY)).json();
let pageCount = 1;
const records = [];
while (true) {
  records.push(...page.data);
  console.log(`page ${pageCount}: ${page.data.map((r) => r.id).join(", ")}` +
              `   next: ${page.links.next?.path ?? "none"}`);
  if (!page.links.next) break;
  page = await call(page.links.next);
  pageCount++;
}
console.log(`total ${records.length} records, ${pageCount} pages`);

// 2) The first record with an open action link is found and that link is followed.
const open = records.find((r) => r.links.return);
console.log(`\nbefore action id=${open.id} links: ${Object.keys(open.links).join(", ")}`);
const updated = await call(open.links.return, { return: "2026-03-15" });
console.log(`after action  id=${updated.id} links: ${Object.keys(updated.links).join(", ")}`);

// 3) The record's own address is also read from the response.
const reread = await call(updated.links.self);
console.log(`reread id=${reread.id} return=${reread.return} ` +
            `links: ${Object.keys(reread.links).join(", ")}`);
console.log(`\naddresses built on the client: ${addressesBuilt}`);
```

```bash
# Seven loan records; the client runs the entire flow knowing only the entry address.
rm -f library.db && sqlite3 library.db < schema.sql && sqlite3 library.db < catalog.sql
sqlite3 library.db <<'SQL'
DELETE FROM loan;
INSERT INTO loan (id, member, isbn, issuedAt, return) VALUES
  (1,'U-1001','K-01','2026-02-01','2026-02-14'),
  (2,'U-1002','K-02','2026-02-05','2026-02-19'),
  (3,'U-1001','K-03','2026-03-01',NULL),
  (4,'U-1002','K-04','2026-03-02',NULL),
  (5,'U-1001','K-05','2026-03-03',NULL),
  (6,'U-1002','K-06','2026-03-04',NULL),
  (7,'U-1001','K-07','2026-03-05',NULL);
SQL
node link-server.mjs & server=$!
sleep 0.4

node navigator.mjs
echo "--- raw view of a single record ---"
curl -sS http://127.0.0.1:8484/loans/4; echo

kill $server
```

```
link server 127.0.0.1:8484
page 1: 1, 2, 3   next: /loans?size=3&cursor=3
page 2: 4, 5, 6   next: /loans?size=3&cursor=6
page 3: 7   next: none
total 7 records, 3 pages

before action id=3 links: self, member, return
after action  id=3 links: self, member
reread id=3 return=2026-03-15 links: self, member

addresses built on the client: 1
--- raw view of a single record ---
{"id":4,"member":"U-1002","isbn":"K-04","issuedAt":"2026-03-02","return":null,"links":{"self":{"path":"/loans/4","method":"GET"},"member":{"path":"/members/U-1002","method":"GET"},"return":{"path":"/loans/4","method":"PATCH","fields":["return"]}}}
```

Three pages, seven records, one written address. The word `cursor` never appears in the
client's code; how the cursor is encoded, what parameter name carries it, even whether
pagination is cursor-based or offset-based, is none of the client's concern. When the server
changes that decision, the client does not change.

The second section is more interesting. **Before** the return operation, `return` was among
the record's links; after the operation, it was gone. The client does not answer "can this
record be returned" by looking at a field's value and applying its own rule; it looks at
whether the link exists. The rule lives on the server, and it lives in exactly one place.

Its counterpart on the interface side is direct: the return button's visibility is tied to
whether the `return` link exists. When the business rule changes — say, members with a fine
now have to return books at a branch — the server stops generating the link and the button
disappears on its own.

#### The Raw Representation and the Contract's Boundary

The output's last line gives a single record's raw representation. Links are gathered into a
separate field of the body, not mixed in with the record's own fields. This separation must
be kept, or the name `return` would be both a date field and a link name.

Connection-based design has a cost too, and it should not be overstated.

The body grows. Every record in a seven-record list carrying three links dedicates a
significant portion of the body to links. The criterion from the Partial Response lesson
applies here too: the link block should be closeable through field selection.

The client's dependency does not disappear, it relocates. The client is now dependent not on
the address format but on **link names.** The name `next` changing is just as breaking as
the cursor parameter's name changing. The gain is that link names change less often than
address formats do.

Generating links costs the server work. The collection endpoint above runs an extra count
query just to find out whether a next page exists. This count is expensive on large
collections; the common fix is fetching one more record than the page size and deciding a
next page exists if that extra one comes back.

Finally, not all links carry the same weight of necessity. Without navigation links, the
client has to know the pagination format; without action links, it has to reimplement the
business rules. The first pays for itself in almost every service; the second is valuable in
services where business rules are complex and change often.

#### Summary

- A link carries at least two pieces of information: address and method; action links also
  report the fields expected in the body.
- A collection response carries navigation links, a singular resource response carries
  related-resource and action links; links are gathered into a separate field of the body.
- In the measurement, the client walked three pages and collected seven records by writing a
  single address; the cursor parameter's name never appeared in the client's code.
- Action links are generated from a resource's current state: a returned record carries no
  `return` link, so the business rule lives in exactly one place.
- The next-page link is generated only while a record remains; the client never needs to
  write a rule to stop on an empty page.
- Its cost is the body's growth, the server's burden of generating links, and the dependency
  shifting from address format to link names.

#### Next Step

Resource and contract design is complete: resources were identified, their addresses
settled, methods and status codes mapped, body format decided, collections paginated and
filtered, write requests guarded against a repeated effect, responses came to carry their
own navigation information. All of this holds when things go right. In the error case,
though, every endpoint still makes up its own body: an `error` field in one place, `field`
and `reason` in another, just a code somewhere else. The next topic starts from here, and
its first lesson settles error responses into a single structure: a standard problem-details
format, which fields it carries, and how the client uses it.

## Errors, Versions and Documentation

Binding error responses to a single shape, reporting field-level validation, path-, header- and content-based versioning, detecting breaking changes from a schema diff, measuring the deprecation window, and testing the contract with machine-readable documentation, contract tests and mock servers.

### Error Response Format

Source: https://academia.sh/en/courses/api-design/error-response-format

The cost to the client of improvising the error body per endpoint, the five core fields of the standard problem details format, and verifying with a check that a single-layer problem catalog produces the body.

The Connection-Based Responses lesson put navigation information inside the successful
response: when the client received a loan record, it could read from the response itself
how to reach the return action and the member. Resource modeling, address layout, status
code selection, body naming, and links — everything designed so far described the case
where **the request succeeded**.

Failed requests fell outside that design. In the loan service's real traffic there are
requests where the book cannot be found, no copy remains, a limit is exceeded, or the body
is malformed. This lesson answers a single question: what should the error response's body
look like so the client does not have to relearn it for every endpoint?

#### Two Error Formats, One Service

The server below serves the loan service's familiar core — looking up a book, lending it,
returning it — and can produce the error body in two separate formats. The routing code is
identical in both; only the body that the `sendError()` call produces changes. The
`SCATTERED` table is the shape a codebase settles into over time when the error body is
never written into the contract: each condition is reported with whatever names occurred to
the person who wrote that line. The `PROBLEM` table instead maps every condition to a
problem type catalog.

```js
// problem.mjs — the problem details layer: the single place that produces the body
export const BASE_TYPE = "https://example.library/problems/";

// Catalog: each problem type is defined once. Type -> permanent id, title, status code.
export const CATALOG = {
  resource_not_found: { title: "Resource not found",                status: 404 },
  malformed_body:      { title: "Request body could not be parsed", status: 400 },
  no_copy_available:   { title: "No copy available to loan",        status: 409 },
  member_limit:        { title: "Member reached the loan limit",    status: 409 },
  validation:          { title: "Request body failed validation",   status: 422 },
};

let counter = 0;
const occurrenceId = () => `oc-${String(++counter).padStart(4, "0")}`;

// problem(code, detail, extra) -> { body, status }
export function problem(code, detail, extra = {}) {
  const record = CATALOG[code];
  if (!record) throw new Error(`unknown problem type in catalog: ${code}`);
  return {
    status: record.status,
    body: {
      type: BASE_TYPE + code.replaceAll("_", "-"),
      title: record.title,
      status: record.status,
      detail,
      instance: occurrenceId(),
      ...extra,
    },
  };
}
```

```js
// server.mjs — loan service; same routes, two separate error formats
// Usage: node server.mjs scattered   (body improvised per endpoint)
//        node server.mjs problem     (problem details format)
import { createServer } from "node:http";
import { problem } from "./problem.mjs";

const STYLE = process.argv[2] ?? "scattered";

// Scattered format: whatever body was written for each error condition on the day.
const SCATTERED = {
  book_not_found:    (b) => [404, { error: "not_found", resource: "book", id: b.id }],
  loan_not_found:    (b) => [404, { message: `loan not found: ${b.id}` }],
  member_not_found:  ()  => [404, { error: { code: 4041, description: "member not found" } }],
  malformed_body:    ()  => [400, { ok: false, reason: "invalid JSON" }],
  no_copy_available: ()  => [409, { conflict: "no_copy_available", remaining: 0 }],
  member_limit:      (b) => [409, { errorMessage: "member limit exceeded", limit: b.limit }],
  path_not_found:    (b) => [404, { status: 404, path: b.path }],
};

// Problem format: every condition maps to a problem type in the catalog.
const PROBLEM = {
  book_not_found:    (b) => problem("resource_not_found", `ISBN ${b.id} is not in the catalog.`, { resource: "book" }),
  loan_not_found:    (b) => problem("resource_not_found", `Loan record ${b.id} does not exist.`, { resource: "loan" }),
  member_not_found:  (b) => problem("resource_not_found", `Member ${b.id} does not exist.`, { resource: "member" }),
  malformed_body:    ()  => problem("malformed_body", "Body is not valid JSON.", { resource: "body" }),
  no_copy_available: (b) => problem("no_copy_available", `Copies of ${b.title} are all on loan.`, { isbn: b.id, remaining: 0 }),
  member_limit:      (b) => problem("member_limit", `Member holds ${b.open} books, limit ${b.limit}.`, { limit: b.limit, open: b.open }),
  path_not_found:    (b) => problem("resource_not_found", `Path ${b.path} is not defined.`, { resource: "path" }),
};

const sendError = (res, code, context = {}) => {
  if (STYLE === "problem") {
    const { status, body } = PROBLEM[code](context);
    res.writeHead(status, { "content-type": "application/problem+json; charset=utf-8" });
    return res.end(JSON.stringify(body));
  }
  const [status, body] = SCATTERED[code](context);
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(body));
};

const BOOKS = new Map([
  ["978-0201896831", { title: "The Art of Computer Programming", copies: 2 }],
  ["978-0262033848", { title: "Introduction to Algorithms", copies: 1 }],
]);
const MEMBERS = new Set(["U-1001", "U-1002"]);
const MEMBER_LIMIT = 2;
const loans = [{ id: "O-1", member: "U-1001", isbn: "978-0262033848" }];

const readBody = (req) =>
  new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); });

const respond = (res, code, obj) => {
  res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(obj));
};

createServer(async (req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  if (req.method === "GET" && path.startsWith("/books/")) {
    const isbn = path.slice("/books/".length);
    const book = BOOKS.get(isbn);
    if (!book) return sendError(res, "book_not_found", { id: isbn });
    return respond(res, 200, { isbn, ...book });
  }

  if (req.method === "GET" && path.startsWith("/loans/")) {
    const id = path.slice("/loans/".length);
    const record = loans.find((o) => o.id === id);
    if (!record) return sendError(res, "loan_not_found", { id });
    return respond(res, 200, record);
  }

  if (req.method === "POST" && path === "/loans") {
    let body;
    try { body = JSON.parse((await readBody(req)) || "{}"); }
    catch { return sendError(res, "malformed_body"); }

    if (!MEMBERS.has(body.member)) return sendError(res, "member_not_found", { id: body.member });
    const book = BOOKS.get(body.isbn);
    if (!book) return sendError(res, "book_not_found", { id: body.isbn });

    const held = loans.filter((o) => o.isbn === body.isbn).length;
    if (held >= book.copies) return sendError(res, "no_copy_available", { id: body.isbn, title: book.title });

    const open = loans.filter((o) => o.member === body.member).length;
    if (open >= MEMBER_LIMIT) return sendError(res, "member_limit", { limit: MEMBER_LIMIT, open });

    const id = `O-${loans.length + 1}`;
    loans.push({ id, member: body.member, isbn: body.isbn });
    return respond(res, 201, { id, member: body.member, isbn: body.isbn });
  }

  if (req.method === "POST" && path === "/returns") {
    const body = JSON.parse((await readBody(req)) || "{}");
    const index = loans.findIndex((o) => o.id === body.loanId);
    if (index < 0) return sendError(res, "loan_not_found", { id: body.loanId });
    loans.splice(index, 1);
    return respond(res, 200, { status: "returned" });
  }

  sendError(res, "path_not_found", { path });
}).listen(8431, "127.0.0.1", () => console.log(`server 127.0.0.1:8431 style=${STYLE}`));
```

#### The Problem Details Format

The shape the `PROBLEM` table produces is not improvised: there is a standardized format
for the error body. **Problem details** is defined by RFC 9457, is carried with the
`application/problem+json` content type, and has five core fields.

- **`type`** — the problem type's permanent identifier, a URI. This is the field the client
  branches on. It is an identifier, not text; it stays the same even if the title changes.
- **`title`** — the problem type's short, human-readable name. Apart from localization it
  stays the same for the same `type`.
- **`status`** — the HTTP status code's copy inside the body.
- **`detail`** — an explanation specific to this **instance**: which ISBN, which member,
  which limit.
- **`instance`** — this single occurrence's identifier; it lets the log record be found.

The distinction between `type` and `detail` is the format's core. `type` names the
**class**, `detail` describes the **instance**. The client decides by looking at the `type`
field ("show the reservation button if there is no copy"), and only shows the `detail` field
to the user or writes it to the log. If this distinction is not maintained, the client is
forced into text comparison, and a spelling fix on the server breaks the client's logic.

Repeating the `status` field in the body looks unnecessary at first glance — it is already
in the status line. It becomes necessary in two situations: when the response is captured
as a body into a log or a queue, the status line is lost; and when an intermediary changes
the status code, the mismatch between the body's value and the status line makes that
change visible.

The standard allows adding **extension members** outside the core. The number of remaining
copies, the limit value, which resource was looked up and not found — these are extension
members, and they depend on the problem type: the same `type` always comes with the same
extension members.

#### Measurement

The script below produces the server's eight error conditions in turn, collects the bodies,
and counts three things: how many distinct content types come back, how many distinct key
sets result, and how many distinct combinations the five core fields appear in.

```js
// check.mjs — measures the format consistency of a server's error responses
// Usage: node check.mjs <base-address>
const BASE = process.argv[2];
const CORE = ["type", "title", "status", "detail", "instance"];

// Each row produces one error condition: [name, method, path, body]
const CASES = [
  ["book not found (GET)", "GET", "/books/978-0000000000", null],
  ["loan not found (GET)", "GET", "/loans/O-99", null],
  ["malformed body", "POST", "/loans", "{this is not json"],
  ["member not found", "POST", "/loans", { member: "U-9999", isbn: "978-0201896831" }],
  ["book not found (POST)", "POST", "/loans", { member: "U-1001", isbn: "978-0000000000" }],
  ["no copy available", "POST", "/loans", { member: "U-1002", isbn: "978-0262033848" }],
  ["loan not found (return)", "POST", "/returns", { loanId: "O-99" }],
  ["path not found", "GET", "/shelves", null],
];

const keysOf = (n, prefix = "") =>
  Object.entries(n).flatMap(([a, d]) =>
    d && typeof d === "object" && !Array.isArray(d) ? keysOf(d, `${prefix}${a}.`) : [`${prefix}${a}`]);

const coreShapes = new Set();
const fullShapes = new Set();
const types = new Set();
console.log("case                     content type             core           extension members");
for (const [name, method, path, body] of CASES) {
  const response = await fetch(BASE + path, {
    method,
    headers: body == null ? {} : { "content-type": "application/json" },
    body: body == null ? undefined : typeof body === "string" ? body : JSON.stringify(body),
  });
  const text = await response.text();
  const type = (response.headers.get("content-type") ?? "").split(";")[0];
  types.add(type);
  let list;
  try { list = keysOf(JSON.parse(text)); } catch { list = ["<not parsed>"]; }
  const core = CORE.filter((a) => list.includes(a));
  const extra = list.filter((a) => !CORE.includes(a)).sort();
  coreShapes.add(core.join(","));
  fullShapes.add([...core, ...extra].join(","));
  const status = core.length === CORE.length ? "full" : `partial(${core.length}/5)`;
  console.log(`${name.padEnd(24)} ${type.padEnd(24)} ${status.padEnd(14)} ${extra.join(",") || "-"}`);
}

console.log(`\nerror conditions tested: ${CASES.length}`);
console.log(`distinct content types:  ${types.size}  (${[...types].join(", ")})`);
console.log(`distinct full key sets:  ${fullShapes.size}`);
console.log(`distinct core shapes:    ${coreShapes.size}`);
```

```bash
#!/usr/bin/env bash
# Starts server.mjs in each format in turn and runs the same check.
for style in scattered problem; do
  node server.mjs "$style" & s=$!
  sleep 0.5
  node check.mjs http://127.0.0.1:8431
  kill "$s"; wait "$s" 2>/dev/null
  echo
done
```

```
server 127.0.0.1:8431 style=scattered
case                     content type             core           extension members
book not found (GET)     application/json         partial(0/5)   error,id,resource
loan not found (GET)     application/json         partial(0/5)   message
malformed body           application/json         partial(0/5)   ok,reason
member not found         application/json         partial(0/5)   error.code,error.description
book not found (POST)    application/json         partial(0/5)   error,id,resource
no copy available        application/json         partial(0/5)   conflict,remaining
loan not found (return)  application/json         partial(0/5)   message
path not found           application/json         partial(1/5)   path

error conditions tested: 8
distinct content types:  1  (application/json)
distinct full key sets:  6
distinct core shapes:    2

server 127.0.0.1:8431 style=problem
case                     content type             core           extension members
book not found (GET)     application/problem+json full           resource
loan not found (GET)     application/problem+json full           resource
malformed body           application/problem+json full           resource
member not found         application/problem+json full           resource
book not found (POST)    application/problem+json full           resource
no copy available        application/problem+json full           isbn,remaining
loan not found (return)  application/problem+json full           resource
path not found           application/problem+json full           resource

error conditions tested: 8
distinct content types:  1  (application/problem+json)
distinct full key sets:  2
distinct core shapes:    1
```

In the scattered format, eight error conditions produce six distinct key sets, and none of
them share a common core. `error`, `message`, `reason`, `errorMessage`, `error.description`
— all of them carry the same thing, the "what happened" information, and each one uses a
different name. The request layer built in the Application Architecture course has to map
the response to an **error contract**, and no mapping can be written against this table.
The client either writes six separate parsers or looks at a single field and ignores the
rest; the second option is common, and it turns into the sentence "an error occurred" on
the user's screen.

In the problem format, the number of distinct core shapes drops to one, and all five fields
are present on every row. The full key set count is two, because the extension members
differ, and they should differ: the `no_copy_available` problem has a `remaining` field,
the `resource_not_found` problem does not. But this difference is tied to the **problem
type**, not the endpoint. When the client reads the `type` field it knows which extension
members will come; in the scattered format it had to know which endpoint it had called.

The content type row shows a second gain. In the scattered format, the error response and
the success response carry the same content type; the intermediaries and logging tools in
between cannot tell whether this is an error without parsing the body.
`application/problem+json` gives that distinction without looking at the body.

#### The Same Problem Returned From Different Endpoints

In the table, the `resource_not_found` problem appears on six rows. For the client to be
able to rely on this, those rows' `type`, `title`, and `status` fields must be identical.
The script below produces the same problem from two different endpoints, then requests a
type that is not written into the catalog.

```bash
#!/usr/bin/env bash
# Produces the same problem type from two different endpoints; tries a type outside the catalog.
node server.mjs problem & server=$!
sleep 0.5

for path in /books/978-0000000000 /loans/O-99; do
  echo "--- $path ---"
  curl -sS -D - -o /tmp/body "http://127.0.0.1:8431$path" | grep -i '^HTTP\|^content-type'
  cat /tmp/body; echo
done

kill "$server"; wait "$server" 2>/dev/null

echo "--- requesting a problem type outside the catalog ---"
node -e 'import("./problem.mjs").then(({ problem }) => {
  try { problem("shelf_is_dusty", "A made-up error."); }
  catch (h) { console.log(h.message); }
});'
```

```
server 127.0.0.1:8431 style=problem
--- /books/978-0000000000 ---
HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8
{"type":"https://example.library/problems/resource-not-found","title":"Resource not found","status":404,"detail":"ISBN 978-0000000000 is not in the catalog.","instance":"oc-0001","resource":"book"}
--- /loans/O-99 ---
HTTP/1.1 404 Not Found
content-type: application/problem+json; charset=utf-8
{"type":"https://example.library/problems/resource-not-found","title":"Resource not found","status":404,"detail":"Loan record O-99 does not exist.","instance":"oc-0002","resource":"loan"}
--- requesting a problem type outside the catalog ---
unknown problem type in catalog: shelf_is_dusty
```

The first three fields are identical; `detail` and `instance` differ. The client's status
code mapping looks at the `type` field and writes a single branch for both endpoints; the
text to show the user comes from the `detail` field; `instance` is written into the support
record.

The last line shows the catalog's second function. The catalog does not only hold the
mapping between `type`, `title`, and `status`; it errors when a type it does not have is
requested. A problem not written into the catalog cannot silently produce a new shape. What
prevents the format from scattering again over time is not that the format is designed
correctly, but that misuse is visible at runtime.

#### What Does Not Belong in the Body

Producing the error body from a single place makes what stays out controllable, just as
much as what goes in. Stack traces, database query text, file paths, and internal
identifiers do not go to the client; they are kept in the server log, matched by the
`instance` field. When the client reports a problem, it gives the occurrence identifier,
and the full context is found from the log.

The criterion rests on who can do what: information the client can **change its behavior**
on goes into the body (no copies left, limit is two, ISBN not found); information the
client can do nothing about goes into the log. This criterion gives the same result for
both security and usability reasons.

#### Summary

- When the error body is written per endpoint, eight error conditions produce six distinct
  key sets and none of them share a common core; the client cannot build a single error
  contract on this table.
- The problem details format's five core fields each serve a separate purpose: `type`
  names the class, `title` is the class's fixed name, `status` repeats the status code in
  the body, `detail` describes the instance, `instance` matches the single occurrence to
  the log.
- The client branches on the `type` field; branching on the `detail` text turns a spelling
  fix on the server into a breaking change.
- Producing the body from a single layer built on the problem type catalog brings the
  distinct core shape count down to one; extension members can still differ, because they
  depend on the problem type, not the endpoint.
- The catalog makes the format's re-scattering visible at runtime by erroring when a type
  it does not have is requested.
- Information the client can change its behavior on is written into the body; information
  it cannot is written into the log record matched by `instance`.

#### Next Step

The `validation` type in the catalog was never used in this lesson. The reason is that a
validation error does not fit into a single sentence: a loan request with four wrong fields
at once cannot be sufficiently described by the title "request body failed validation." The
client needs to show each wrong field next to its own input box, and that requires the
server to report, field by field, which field was rejected and why. The next lesson adds a
field-level error list to problem details, decides the rule by which field names match the
client's fields, and measures the case where the match breaks down.

### Validation Errors

Source: https://academia.sh/en/courses/api-design/validation-errors

Adding a field-level error list to problem details, the request-round difference between stopping at the first error and collecting all of them, and the body path rule that makes field names match the client's input boxes.

The previous lesson bound the error body to a single shape but left the `validation` type
in the problem catalog unused. The reason is that this type is structurally different from
the others. "No copy available" is a single fact; a sentence in the `detail` field
describes it completely. "Request body failed validation" is not a fact but a **set of
facts**: which fields of the body were rejected, by which rules?

The loan service's lending request is no longer a single book; it carries a member,
multiple items, and a return date. This lesson builds field-level error reporting and
settles two decisions by measurement: how many errors get reported at once, and by what
name the fields are referred to.

#### How Many Errors at Once

Validation can stop at the first field that violates a rule, or it can scan the whole body
and collect the errors. The difference between them is how many times the user has to send
a request to fix the form.

The validator below takes the schema as data. The schema keys are **body paths** and are
written in JSON Pointer form: `/member`, `/items/1/isbn`. The `*` marker expands over array
elements, so the `/items/*/isbn` pattern applies to every item in the body.

```js
// validate.mjs — validates the body against a schema, produces a field-level error list
// Field name is JSON Pointer (RFC 6901): "/member", "/items/1/isbn"

export const RULES = {
  required: (d) => (d === undefined || d === null || d === "" ? { code: "required" } : null),
  pattern: (d, p) => (new RegExp(p).test(String(d)) ? null : { code: "format", param: p }),
  enum: (d, p) => (p.includes(d) ? null : { code: "not_in_enum", param: p }),
  array: (d, p) =>
    !Array.isArray(d) ? { code: "not_an_array" }
      : d.length < p.min ? { code: "too_short", param: p }
      : d.length > p.max ? { code: "too_long", param: p }
      : null,
  date: (d) => (/^\d{4}-\d{2}-\d{2}$/.test(String(d)) ? null : { code: "not_a_date" }),
  maxDays: (d, p, today) => {
    const diff = Math.round((Date.parse(d) - Date.parse(today)) / 86400000);
    if (Number.isNaN(diff)) return null;                 // date rule already reported it
    if (diff < 0) return { code: "in_past" };
    return diff > p ? { code: "too_far_ahead", param: p } : null;
  },
};

// The * inside a template is expanded to real array indices: /items/*/isbn -> /items/0/isbn
const read = (body, path) =>
  path.split("/").slice(1).reduce((d, p) => (d == null ? undefined : d[p]), body);

function expandPaths(body, template) {
  const star = template.indexOf("/*");
  if (star < 0) return [template];
  const arr = read(body, template.slice(0, star));
  if (!Array.isArray(arr)) return [];
  return arr.flatMap((_, i) => expandPaths(body, template.slice(0, star) + `/${i}` + template.slice(star + 2)));
}

// validate(schema, body, today) -> [{ path, code, param }]
export function validate(schema, body, today) {
  const errors = [];
  for (const [template, rules] of Object.entries(schema)) {
    for (const path of expandPaths(body, template)) {
      const value = read(body, path);
      for (const [name, param] of rules) {
        if (name !== "required" && (value === undefined || value === null || value === "")) break;
        const result = RULES[name](value, param, today);
        if (result) { errors.push({ path, ...result }); break; }  // one error per field
      }
    }
  }
  return errors;
}
```

To compare the two collection styles, a body with four fields wrong at once is taken, and
the user is assumed to fix only the errors **reported to them**.

```js
// turns.mjs — compares stop-at-first-error validation with collect-all validation
import { validate } from "./validate.mjs";

const TODAY = "2026-03-01";
const SCHEMA = {
  "/member":         [["required"], ["pattern", "^U-\\d{4}$"]],
  "/items":          [["required"], ["array", { min: 1, max: 3 }]],
  "/items/*/isbn":   [["required"], ["pattern", "^97[89]-\\d{10}$"]],
  "/items/*/branch": [["required"], ["enum", ["central", "shore", "hill"]]],
  "/returnDate":     [["required"], ["date"], ["maxDays", 30]],
};

// The body the user first submits: four fields wrong at once.
const FIRST = {
  member: "1001",
  items: [
    { isbn: "978-0262033848", branch: "central" },
    { isbn: "0262033848", branch: "sea" },
  ],
  returnDate: "2026-06-01",
};

// The user's fix order: whichever error is reported gets fixed.
const FIXES = {
  "/member": (b) => (b.member = "U-1001"),
  "/items/1/isbn": (b) => (b.items[1].isbn = "978-0201896831"),
  "/items/1/branch": (b) => (b.items[1].branch = "shore"),
  "/returnDate": (b) => (b.returnDate = "2026-03-20"),
};

function roundCount(collectAll) {
  const body = structuredClone(FIRST);
  let round = 0;
  while (round < 10) {
    round++;
    const errors = validate(SCHEMA, body, TODAY);
    if (errors.length === 0) return round;
    const reported = collectAll ? errors : [errors[0]];
    console.log(`  round ${round}: ${reported.map((h) => `${h.path}=${h.code}`).join("  ")}`);
    for (const h of reported) FIXES[h.path](body);
  }
  return round;
}

console.log("validation that stops at the first error:");
const a = roundCount(false);
console.log(`  request rounds until success: ${a}\n`);

console.log("validation that collects all errors:");
const b = roundCount(true);
console.log(`  request rounds until success: ${b}`);
```

```
validation that stops at the first error:
  round 1: /member=format
  round 2: /items/1/isbn=format
  round 3: /items/1/branch=not_in_enum
  round 4: /returnDate=too_far_ahead
  request rounds until success: 5

validation that collects all errors:
  round 1: /member=format  /items/1/isbn=format  /items/1/branch=not_in_enum  /returnDate=too_far_ahead
  request rounds until success: 2
```

Five rounds against two. The number itself depends on how many fields in the body are
wrong — $n$ wrong fields mean $n+1$ rounds in stop-at-first-error validation — but the
behavioral difference is independent of the count: stopping at the first error **hides**
the existence of the next error from the user. The user thinks the job is done after every
fix and gets rejected again.

One detail is preserved: the validator stops at the first error **per field**. The
`/member` field must be both required and pattern-matching; when it is left blank, there is
no benefit in reporting both the "required" and "format" errors, since the second is a
consequence of the first. The rule is: scanning continues across fields, but the first
error within a field is enough.

#### The Three Fields of an Error Record

Every error record carries three things. **`path`** is the field's address in the body.
**`code`** is the error's stable identifier — the same distinction as the validation
schemas built in the Application Architecture course in the Frontend Development
curriculum: the code is for branching, the message is for display. **`param`** is the
number the code requires: which pattern, which options, how many days.

The code standing in for the message makes it possible to produce the text shown to the
user **on the client side**. The server sends `too_far_ahead` and `30`; the client turns
this into a sentence in its own language and its own tone. If the server sent text,
translation, pluralization rules, and tone decisions would move onto the server; none of
that is the server's job.

This triple is added to problem details as an **extension member**. The five core fields
stay in place; the `errors` array joins them.

```js
// server.mjs — service that validates a loan request and returns 422 problem details
import { createServer } from "node:http";
import { validate } from "./validate.mjs";

const TODAY = "2026-03-01";                 // fixed so the example is reproducible
const SCHEMA = {
  "/member":         [["required"], ["pattern", "^U-\\d{4}$"]],
  "/items":          [["required"], ["array", { min: 1, max: 3 }]],
  "/items/*/isbn":   [["required"], ["pattern", "^97[89]-\\d{10}$"]],
  "/items/*/branch": [["required"], ["enum", ["central", "shore", "hill"]]],
  "/returnDate":     [["required"], ["date"], ["maxDays", 30]],
};

// The one entry from the previous lesson's problem catalog that this lesson needs.
let counter = 0;
const validationProblem = (errors) => ({
  type: "https://example.library/problems/validation",
  title: "Request body failed validation",
  status: 422,
  detail: `${errors.length} fields were not accepted.`,
  instance: `oc-${String(++counter).padStart(4, "0")}`,
  errors,
});

const readBody = (req) =>
  new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); });

// Core fields stacked, each error on its own line: keep the body readable.
const format = (s) => [
  "{",
  ...["type", "title", "status", "detail", "instance"].map((a) => `  "${a}": ${JSON.stringify(s[a])},`),
  '  "errors": [',
  ...s.errors.map((h, i) => `    ${JSON.stringify(h)}${i < s.errors.length - 1 ? "," : ""}`),
  "  ]",
  "}",
].join("\n");

createServer(async (req, res) => {
  res.sendDate = false;
  const body = JSON.parse((await readBody(req)) || "{}");
  const errors = validate(SCHEMA, body, TODAY);

  if (errors.length) {
    res.writeHead(422, { "content-type": "application/problem+json; charset=utf-8" });
    return res.end(format(validationProblem(errors)));
  }
  res.writeHead(201, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify({ id: "O-2", member: body.member, items: body.items.length }));
}).listen(8433, "127.0.0.1", () => console.log("validation server 127.0.0.1:8433"));
```

```bash
#!/usr/bin/env bash
# Sends a loan request with four wrong fields; shows the 422 body.
node server.mjs & server=$!
sleep 0.5

curl -sS -w '\n[%{http_code}] %{content_type}\n' -X POST -H 'content-type: application/json' \
  -d '{"member":"1001","items":[{"isbn":"978-0262033848","branch":"central"},{"isbn":"0262033848","branch":"sea"}],"returnDate":"2026-06-01"}' \
  http://127.0.0.1:8433/loans

echo "--- fixed body ---"
curl -sS -w '\n[%{http_code}] %{content_type}\n' -X POST -H 'content-type: application/json' \
  -d '{"member":"U-1001","items":[{"isbn":"978-0262033848","branch":"central"},{"isbn":"978-0201896831","branch":"shore"}],"returnDate":"2026-03-20"}' \
  http://127.0.0.1:8433/loans

kill "$server"; wait "$server" 2>/dev/null
```

```
validation server 127.0.0.1:8433
{
  "type": "https://example.library/problems/validation",
  "title": "Request body failed validation",
  "status": 422,
  "detail": "4 fields were not accepted.",
  "instance": "oc-0001",
  "errors": [
    {"path":"/member","code":"format","param":"^U-\\d{4}$"},
    {"path":"/items/1/isbn","code":"format","param":"^97[89]-\\d{10}$"},
    {"path":"/items/1/branch","code":"not_in_enum","param":["central","shore","hill"]},
    {"path":"/returnDate","code":"too_far_ahead","param":30}
  ]
}
[422] application/problem+json; charset=utf-8
--- fixed body ---
{"id":"O-2","member":"U-1001","items":2}
[201] application/json; charset=utf-8
```

The status code being **422**, not 400, is a deliberate choice. 400 reports that the
request could not be **understood** by the server: malformed JSON, a missing header, an
unrecognized content type. The body here has been understood completely — parsed, its
fields read, run through the rules — and its **meaning** was rejected. The distinction is
useful in the log too: a rise in the 400 rate shows that the client built the request
wrong, a rise in the 422 rate shows that the user entered wrong data; the two have
different causes and different fixes.

#### The Field Name's Counterpart on the Client

For the error list to be useful, the client has to know **next to which input box** to show
each error. The error presentation in the Accessible Component Patterns course assumes this
match: each error is bound to its own field's accessible description, and the error summary
moves focus to that field. If the match cannot be established, all that is left is a single
generic message.

The measurement below reports the same four errors under three separate server contracts
and counts how many of them the client can match to their box.

```js
// match.mjs — measures the client-side match rate of three field naming conventions
// The client's input boxes are named by the contract's rule:
// name = JSON Pointer with the leading slash dropped and "/" replaced by "."
const BOXES = ["member", "items.0.isbn", "items.0.branch", "items.1.isbn", "items.1.branch", "returnDate"];
const toBoxName = (path) => path.replace(/^\//, "").replaceAll("/", ".");

// The same four errors, reported under three separate server contracts.
const CONTRACTS = {
  "no field name": {
    errors: [{ message: "The information you submitted has four errors." }],
    toBox: () => null,
  },
  "server's internal names": {
    errors: [
      { field: "member_id", code: "format" },
      { field: "items[1].isbn", code: "format" },
      { field: "items[1].branch", code: "not_in_enum" },
      { field: "due_date", code: "too_far_ahead" },
    ],
    toBox: (h) => h.field,
  },
  "body path (JSON Pointer)": {
    errors: [
      { path: "/member", code: "format" },
      { path: "/items/1/isbn", code: "format" },
      { path: "/items/1/branch", code: "not_in_enum" },
      { path: "/returnDate", code: "too_far_ahead" },
    ],
    toBox: (h) => toBoxName(h.path),
  },
};

console.log("contract                    reported  matched  can be shown next to its box");
for (const [name, { errors, toBox }] of Object.entries(CONTRACTS)) {
  const matched = errors.filter((h) => BOXES.includes(toBox(h)));
  console.log(
    `${name.padEnd(27)} ${String(errors.length).padStart(9)}  ${String(matched.length).padStart(7)}  ` +
    `${matched.length ? matched.map((h) => toBox(h)).join(", ") : "none"}`
  );
}
```

```
contract                    reported  matched  can be shown next to its box
no field name                       1        0  none
server's internal names             4        0  none
body path (JSON Pointer)            4        4  member, items.1.isbn, items.1.branch, returnDate
```

The second row is worse than the first, because it is deceptive: the server appears to
report four separate errors, and there are field names too, but those names come from the
server's own internal data model. `member_id`, `items[1].branch` — there is no such field
in the client's body. The client either keeps a hand-written translation table between the
server's internal names and its own field names, or shows the errors unmatched. If a
translation table is kept, every internal rename on the server breaks the client; in other
words, something outside the contract starts behaving like part of the contract.

The rule is: **the error path is the address of the field in the body the client sent.**
Not the server's internal model names, but the structure of the request body. This rule
guarantees two properties. It is total — every field in the body has a path, array elements
included. And it is derivable in one direction: because the client builds the body itself,
it can write the rule that converts a path into a box name, without needing to keep a table
in the reverse direction.

The only remaining decision is which format the body path is written in. JSON Pointer is a
defined standard, expresses array indices naturally, and has well-defined escaping rules. A
dot separator could be chosen instead; what matters is not the format itself but that it is
**documented and defined for every field**.

#### Summary

- When validation stops at the first error, $n$ wrong fields require $n+1$ request rounds;
  when errors are reported in bulk, two rounds are enough and the existence of the next
  error is not hidden from the user.
- Scanning continues across fields, but the first error within a field is enough:
  successive rule violations on the same field are consequences of each other.
- An error record carries three fields — path, code, parameter; the text shown to the user
  is produced on the client from the code and the parameter, not from the server.
- A request that could not be understood is reported with 400, a request that was
  understood but whose meaning was rejected is reported with 422; the distinction also
  separates the cause behind a rise in the log.
- The error path is the address in the body the client sent, not the server's internal
  model name; when internal names are reported, the match rate drops to zero out of four.
- The field naming convention must be total and derivable in one direction; what matters is
  not which format is chosen but that it is documented and defined for every field.

#### Next Step

The validation schema was taken as fixed in this lesson: `/items` accepts at most three
elements, the return date can be at most thirty days ahead. What happens when the library
decides to change these limits? Raising the item limit to five breaks no client; lowering
it to three breaks every client that sends a request with four items. Renaming the same
body field, however, breaks every client at once. Change to the contract is inevitable; the
next lesson takes up the mechanism that makes this change manageable, and by publishing two
versions of the same resource on a single server, compares the cost to client code of
path-based, header-based, and content-based versioning.

### Versioning Strategies

Source: https://academia.sh/en/courses/api-design/versioning-strategies

Publishing two versions of the same resource on a single server, the measured equal cost to client code of path-based, header-based, and content-based versioning, and the real difference that comes from whether intermediaries can tell versions apart.

The validation schema was taken as fixed in the previous lesson: at most three items, the
return date at most thirty days ahead. These numbers can change; more importantly, the
body's **structure** can change. The loan record's `member` field turns from a plain
identifier string into an object when a name is also needed, the single `isbn` field turns
into an array when loans with multiple items are required, and the `returnDate` field is
renamed `dueDate`.

What these changes have in common is that they break clients that are already working.
Versioning does not prevent breakage; it keeps the **old contract in effect**. This lesson
runs three versioning styles side by side on the same server and measures two things: how
many lines of client code change, and whether intermediaries can tell the versions apart.

#### Two Versions of the Same Resource

The server below serves a single loan record in two versions. The versions are derived from
the same data; only the shape of the exported body differs. All three styles run in the
same process.

```js
// server.mjs — publishes the same loan record in two versions, three separate versioning styles
import { createServer } from "node:http";

const RECORD = { id: "O-1", member: "U-1001", name: "Alice Carter", isbn: "978-0262033848", dueDate: "2026-03-20" };

// v1: flat shape, single book.  v2: member object, item array, field renamed.
const VERSIONS = {
  1: (k) => ({ id: k.id, member: k.member, isbn: k.isbn, returnDate: k.dueDate }),
  2: (k) => ({ id: k.id, member: { id: k.member, name: k.name }, items: [{ isbn: k.isbn }], dueDate: k.dueDate }),
};

const MEDIA_TYPE = "application/vnd.library.loan+json";

// Extracts the version parameter from the Accept header: ...+json;version=2
const acceptVersion = (accept) => {
  const m = /version=(\d+)/.exec(accept ?? "");
  return m ? Number(m[1]) : null;
};

const write = (res, version, extraHeaders) => {
  res.writeHead(200, { "content-type": `${MEDIA_TYPE}; version=${version}`, ...extraHeaders });
  res.end(JSON.stringify(VERSIONS[version](RECORD)));
};

createServer((req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  // 1) Path-based: version is part of the address. Two versions are two separate resource addresses.
  const m = /^\/v(\d+)\/loans\/(.+)$/.exec(path);
  if (m && VERSIONS[m[1]]) return write(res, Number(m[1]), {});

  if (path === "/loans/O-1") {
    // 2) Content-based: version is a parameter of the media type. Requires Vary: Accept.
    const accepted = acceptVersion(req.headers.accept);
    if (accepted && VERSIONS[accepted]) return write(res, accepted, { vary: "Accept" });

    // 3) Header-based: version is in a separate header. Vary: that header.
    const header = Number(req.headers["api-version"] ?? 1);
    if (VERSIONS[header]) return write(res, header, { vary: "Api-Version" });
  }

  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found", status: 404, detail: `${path} does not exist.`, instance: "oc-0001" }));
}).listen(8434, "127.0.0.1", () => console.log("server 127.0.0.1:8434"));
```

```bash
#!/usr/bin/env bash
# Requests the same record's two versions with three separate versioning styles.
node server.mjs & server=$!
sleep 0.5

echo "--- path-based ---"
curl -sS -w '  [%{content_type}]\n' http://127.0.0.1:8434/v1/loans/O-1
curl -sS -w '  [%{content_type}]\n' http://127.0.0.1:8434/v2/loans/O-1

echo "--- header-based ---"
curl -sS -H 'Api-Version: 1' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1
curl -sS -H 'Api-Version: 2' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1

echo "--- content-based ---"
curl -sS -H 'Accept: application/vnd.library.loan+json;version=1' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1
curl -sS -H 'Accept: application/vnd.library.loan+json;version=2' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1

kill "$server"; wait "$server" 2>/dev/null
```

```
server 127.0.0.1:8434
--- path-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
--- header-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
--- content-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
```

All three styles produce the same two bodies. The difference is where the version is
written: to the address in the path-based style, to a separate request-specific header in
the header-based style, to a parameter of the media type in the `Accept` header in the
content-based style.

The content-based style uses HTTP's own content negotiation mechanism. The `Content-Type`
the server returns also carries the version, so the response reports its own version. In
the header-based style, this information is in a separate header, and it is not a concept
HTTP knows about; for it to work, the client and the server must agree on the same header
name.

#### The Cost to Client Code

The first justification for choosing a style is usually presented as "the client changes
less code." This is a measurable claim. The program below generates a client's source with
three call sites for each of the three styles, and counts the number of changed lines in
two scenarios: all call sites migrating to v2, and only one migrating.

```js
// client-cost.mjs — changed line count in client code for three versioning styles
// Three call sites, three scenarios: [1,1,1] all v1 | [2,2,2] full migration | [2,1,1] partial migration
// Constant names stay stable: the majority version keeps the main constant, the minority gets an added constant.
const MEDIA_TYPE = "application/vnd.library.loan+json";
const CALL_SITES = [["getLoan  ", "(id)", "/loans/${id}"], ["listLoans", "()  ", "/loans"], ["getMember", "(id)", "/members/${id}"]];
const majority = (s) => (s.filter((v) => v === 1).length >= 2 ? 1 : 2);
const call = (k, address, extra) => `export const ${k[0]} = ${k[1]} => request(\`${address}\`${extra});`;

const STYLES = {
  "path-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "BASE" : `BASE_V${v}`);
    return [
      `const BASE = "http://127.0.0.1:8434/v${c}";`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const BASE_V${v} = "http://127.0.0.1:8434/v${v}";`),
      ...CALL_SITES.map((k, i) => call(k, "${" + name(s[i]) + "}" + k[2], "")),
    ];
  },
  "header-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "VERSION" : `VERSION_V${v}`);
    return [
      'const BASE = "http://127.0.0.1:8434";',
      `const VERSION = { "api-version": "${c}" };`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const VERSION_V${v} = { "api-version": "${v}" };`),
      ...CALL_SITES.map((k, i) => call(k, "${BASE}" + k[2], `, ${name(s[i])}`)),
    ];
  },
  "content-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "VERSION" : `VERSION_V${v}`);
    return [
      'const BASE = "http://127.0.0.1:8434";',
      `const VERSION = { accept: "${MEDIA_TYPE};version=${c}" };`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const VERSION_V${v} = { accept: "${MEDIA_TYPE};version=${v}" };`),
      ...CALL_SITES.map((k, i) => call(k, "${BASE}" + k[2], `, ${name(s[i])}`)),
    ];
  },
};

const changed = (before, after) => after.filter((x) => !before.includes(x)).length;

console.log("style           lines  full migration  partial migration");
for (const [name, f] of Object.entries(STYLES)) {
  const v1 = f([1, 1, 1]);
  console.log(`${name.padEnd(15)} ${String(v1.length).padStart(5)} ` +
    `${String(changed(v1, f([2, 2, 2]))).padStart(15)} ${String(changed(v1, f([2, 1, 1]))).padStart(18)}`);
}

for (const name of ["path-based", "header-based"]) {
  console.log(`\n— ${name} client during partial migration —`);
  for (const s of STYLES[name]([2, 1, 1])) console.log(s);
}
```

```
style           lines  full migration  partial migration
path-based          4               1                  2
header-based        5               1                  2
content-based       5               1                  2

— path-based client during partial migration —
const BASE = "http://127.0.0.1:8434/v1";
const BASE_V2 = "http://127.0.0.1:8434/v2";
export const getLoan   = (id) => request(`${BASE_V2}/loans/${id}`);
export const listLoans = ()   => request(`${BASE}/loans`);
export const getMember = (id) => request(`${BASE}/members/${id}`);

— header-based client during partial migration —
const BASE = "http://127.0.0.1:8434";
const VERSION = { "api-version": "1" };
const VERSION_V2 = { "api-version": "2" };
export const getLoan   = (id) => request(`${BASE}/loans/${id}`, VERSION_V2);
export const listLoans = ()   => request(`${BASE}/loans`, VERSION);
export const getMember = (id) => request(`${BASE}/members/${id}`, VERSION);
```

All three styles give the same numbers: one line in a full migration, two in a partial
migration. This was not the expected result; it is a common belief that path-based
versioning is more expensive. The belief's source is the assumption that the address is
scattered across call sites. When the address is collected into a single constant, the
version is collected into that constant too, and the cost equalizes with the header-based
style.

What the measurement actually shows is this: **the cost is however many places the version
information sits in the client, not the style.** If it can sit in a single place in all
three styles, all three are the same price. In that case, the choice has to rest on a
justification outside client code.

#### What Intermediaries See

That justification lies in the intermediaries the request passes through on the path
between client and server. The program below is a small cache and runs in two modes: a
mode that produces the key from the address alone, and a mode that also folds the request
headers declared in the response's `Vary` header into the key.

```js
// cache.mjs — a small cache that sits in front of the server
// Usage: node cache.mjs address   (key is the request address only)
//        node cache.mjs vary      (key is address + the headers named in the response's Vary)
import { createServer } from "node:http";

const MODE = process.argv[2] ?? "address";
const UPSTREAM = "http://127.0.0.1:8434";
const entries = new Map();     // key -> { body, type }
const varyInfo = new Map();    // address -> header names declared in the response's Vary

createServer(async (req, res) => {
  res.sendDate = false;
  const requestHeaders = { accept: req.headers.accept ?? "", "api-version": req.headers["api-version"] ?? "" };
  const keyFor = (names) => req.url + names.map((a) => `|${a}=${requestHeaders[a]}`).join("");

  const names = MODE === "vary" ? (varyInfo.get(req.url) ?? []) : [];
  const found = entries.get(keyFor(names));
  if (found) {
    res.writeHead(200, { "content-type": found.type, "x-cache": "hit" });
    return res.end(found.body);
  }

  const response = await fetch(UPSTREAM + req.url, { headers: requestHeaders });
  const body = await response.text();
  const type = response.headers.get("content-type");
  const vary = (response.headers.get("vary") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);

  varyInfo.set(req.url, vary);
  entries.set(keyFor(MODE === "vary" ? vary : []), { body, type });

  res.writeHead(200, { "content-type": type, "x-cache": "miss" });
  res.end(body);
}).listen(8435, "127.0.0.1", () => console.log(`cache 127.0.0.1:8435 mode=${MODE}`));
```

```bash
#!/usr/bin/env bash
# Behind the cache: first the client requesting v2, then the client requesting v1.
node server.mjs & s=$!
sleep 0.4

ask() {   # ask <label> <curl-args...>
  printf '%-34s ' "$1"; shift
  curl -sS -D /tmp/b -o /tmp/g "$@"
  printf '%-9s version=%s  %s\n' \
    "$(grep -i '^x-cache' /tmp/b | tr -d '\r' | cut -d' ' -f2)" \
    "$(grep -io 'version=[0-9]' /tmp/b | head -1 | cut -d= -f2)" \
    "$(head -c 46 /tmp/g)"
}

for mode in address vary; do
  node cache.mjs "$mode" & o=$!
  sleep 0.4
  echo "== cache mode: $mode =="
  echo "-- header-based (single address) --"
  ask "client requesting v2" -H 'Api-Version: 2' http://127.0.0.1:8435/loans/O-1
  ask "client requesting v1" -H 'Api-Version: 1' http://127.0.0.1:8435/loans/O-1
  echo "-- path-based (two addresses) --"
  ask "client requesting v2" http://127.0.0.1:8435/v2/loans/O-1
  ask "client requesting v1" http://127.0.0.1:8435/v1/loans/O-1
  kill "$o"; wait "$o" 2>/dev/null
done

kill "$s"; wait "$s" 2>/dev/null
```

```
server 127.0.0.1:8434
cache 127.0.0.1:8435 mode=address
== cache mode: address ==
-- header-based (single address) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               hit       version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
-- path-based (two addresses) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
cache 127.0.0.1:8435 mode=vary
== cache mode: vary ==
-- header-based (single address) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
-- path-based (two addresses) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
```

The critical line is the second line under `address` mode. The client requesting
`Api-Version: 1` got a cache hit and was returned the **v2 body**: the `member` field is an
object instead of a plain identifier. Because the cache only looks at the address, it
counted the two requests as the same request and gave the second one the first request's
response. This is a silent inconsistency — the client does not get the structure it
expects, and **no error response is produced**; the status code is 200. In the path-based
style, the same scenario gives two separate misses; two addresses are two records, and
there is no way for them to mix.

In `vary` mode, the header-based style also works correctly. So the problem is not in the
style, but in whether the intermediary correctly processes the `Vary` header. But this is
what matters when deciding: header-based and content-based styles depend, for correctness,
on **every** intermediary along the path correctly processing the `Vary` header; the
path-based style expects nothing from any intermediary. The same justification holds for
logs, dashboards, and debugging: in the path-based style, which version was called how much
can be read from the address log; in the others, it cannot be read unless the header was
recorded.

On this dimension, the content-based style behaves the same as the header-based style, but
it has one advantage: the `Accept` header is a header HTTP recognizes, and it is ordinary
for caches to encounter `Vary: Accept`. A response that varies by a header name the service
made up itself is a less expected situation from the intermediaries' point of view.

#### What Gets Versioned

None of the three styles answers the question of **what** the version attaches to. There
are two options. If the whole API is versioned, a single version number covers all
resources; a breaking change in one resource raises the version for all of them, and even
clients whose resource has not changed have to move to the new version. If versioning is
per resource, each resource moves at its own pace, but the number of versions the client
has to track becomes as many as the number of resources, and the links between responses
have to separately state which version they go to.

The choice looks at how tightly coupled the resources are to each other: if the loan record
is connected to the member and the book through embedded fields, versioning the three
separately leads to inconsistent combinations. The choice between a single package and a
package per component in the Design Systems course is the same trade-off's counterpart in
another domain.

#### Summary

- Versioning does not prevent a breaking change; it keeps the old contract in effect,
  meaning two contracts live side by side for a while.
- Path-based, header-based, and content-based styles produce the same two bodies; the
  difference is whether the version is written to the address, to a separate header, or to
  a parameter of the media type.
- The cost to client code is the same in all three styles — one line in a full migration,
  two in a partial migration; what matters is not the style but how many places the version
  information sits in the client.
- The real difference is in the intermediaries: an address-keyed cache returns the v2 body
  to a client requesting v1 under header-based and content-based styles, and produces no
  error.
- Header-based and content-based styles depend, for correctness, on every intermediary
  along the path processing the `Vary` header; the path-based style expects nothing from
  any intermediary.
- Whether the version attaches to the API or to the resource is a separate decision, made
  according to how coupled the resources are to each other.

#### Next Step

This lesson published two versions side by side but did not ask **why** the version number
went up to two: the `member` field turning into an object, the `isbn` field turning into an
array, and the `returnDate` field being renamed were accepted as breaking without
discussion. But is adding a new option to the `branch` field breaking? Making a field that
could be left blank required? Adding a new field to the response? When the answer to these
questions is left to intuition, every team gives a different answer and the version number
becomes a matter of debate. The next lesson writes a script that compares two schemas and
detects breaking changes, and derives the version number from the diff instead of from
argument.

### Backward Compatibility

Source: https://academia.sh/en/courses/api-design/backward-compatibility

The same change classified oppositely in the request and response directions, a differ that compares two schema versions and detects breaking changes, and deriving the version number from the diff instead of from argument.

The previous lesson published two versions side by side but did not ask why the version
number went up to two. The `member` field turning into an object was counted as breaking;
adding a new option to the `branch` field was not discussed. When these questions are left
to intuition, every team gives a different answer; the version number rests on opinion and
gets skipped over with the sentence "this is a small change."

This lesson defines breakingness, writes a differ, and derives the version number from the
diff. The definition's starting point is a simple observation: **the same change can be
compatible in the request direction while being breaking in the response direction.**

#### Breakingness Determined by Direction

A contract has two directions. The client **sends** a body, the server **produces** a body.
Because the client sends, the server defines an accepted set; because the server produces,
it also defines a produced set.

If what the server **accepts narrows**, old clients are left out. If what is accepted
widens, old clients are unaffected; what they send is still inside the set. So in the
request direction, narrowing is breaking and widening is compatible.

If what the server **produces widens**, old clients encounter something they do not
recognize. If what it produces narrows, clients are unaffected; the fields they read are
still in place. So in the response direction, widening is breaking and narrowing is
compatible.

The two directions' rules are exactly each other's opposite. This inverse relationship is
why a single change, like removing the `/isbn` field, is compatible in the request and
breaking in the response: an old client keeps sending the field removed from the request,
and the server ignores it; but for the field removed from the response, the old client
keeps looking for it and does not find it.

The two schemas below give the loan resource's state across a version range. Each field
states three things: its type, whether it is required, and, if any, the values it can take.

```js
// schemas.mjs — two schema versions of the loan resource
// Field: { type, required, values }.  No values means the value set is unbounded.
export const V1 = {
  version: "1.4.2",
  request: {
    "/member":   { type: "string", required: true },
    "/isbn":     { type: "string", required: true },
    "/branch":   { type: "string", required: false, values: ["central", "shore"] },
    "/dayCount": { type: "number", required: false },
  },
  response: {
    "/id":         { type: "string", required: true },
    "/member":     { type: "string", required: true },
    "/isbn":       { type: "string", required: true },
    "/returnDate": { type: "string", required: true },
    "/status":     { type: "string", required: true, values: ["open", "closed"] },
  },
};

export const V2 = {
  request: {
    "/member":   { type: "string",  required: true },
    "/items":    { type: "array",   required: true },                                    // new required field
    "/branch":   { type: "string",  required: true, values: ["central", "shore", "hill"] },
    "/dayCount": { type: "integer", required: false },                                   // type narrowed
    "/note":     { type: "string",  required: false },                                   // new optional field
  },
  response: {
    "/id":         { type: "string", required: true },
    "/member":     { type: "object", required: true },                                    // type changed
    "/items":      { type: "array",  required: true },                                    // new field
    "/returnDate": { type: "string", required: false },                                   // now optional
    "/status":     { type: "string", required: true, values: ["open", "closed", "overdue"] },
  },
};
```

#### The Differ

The differ walks the two schemas, finds every difference, and applies the direction rule.
Type changes require a small subtype relationship: every integer is a number, but the
reverse is not true. Types with no such relationship between them are breaking in both
directions.

```js
// differ.mjs — compares two schema versions, detects breaking changes, derives the version
// Usage: node differ.mjs [tolerant]   ("tolerant": clients tolerate an unknown value)

import { V1, V2 } from "./schemas.mjs";

const TOLERANT = process.argv[2] === "tolerant";

// Type lattice: integer is a subtype of number. A subtype accepts a portion of what the supertype accepts.
const SUBTYPES = { integer: ["number"], date: ["string"] };
const isSubtype = (a, b) => a === b || (SUBTYPES[a] ?? []).includes(b);

// Two directions, two rules. Request: breaking if what the server accepts narrows.
// Response: breaking if what the server produces widens.
function compare(oldSchema, newSchema, direction) {
  const breaking = direction === "request"
    ? { narrowing: true, widening: false, requiredAdded: true, requiredRemoved: false, removal: false }
    : { narrowing: false, widening: true, requiredAdded: false, requiredRemoved: true, removal: true };
  const findings = [];
  const add = (kind, name, description) => findings.push({ kind, direction, name, description });

  for (const [name, o] of Object.entries(oldSchema)) {
    const n = newSchema[name];
    if (!n) { add(breaking.removal ? "breaking" : "compatible", name, "field removed"); continue; }
    if (!o.required && n.required) add(breaking.requiredAdded ? "breaking" : "compatible", name, "was optional, became required");
    if (o.required && !n.required) add(breaking.requiredRemoved ? "breaking" : "compatible", name, "was required, became optional");
    if (o.type !== n.type) {
      const narrowing = isSubtype(n.type, o.type), widening = isSubtype(o.type, n.type);
      const kind = narrowing ? (breaking.narrowing ? "breaking" : "compatible")
        : widening ? (breaking.widening ? "breaking" : "compatible")
        : "breaking";                                       // unrelated types are breaking in both directions
      add(kind, name, `type ${o.type} → ${n.type}` + (narrowing ? " (narrowing)" : widening ? " (widening)" : " (unrelated)"));
    }
    const oldValues = o.values, newValues = n.values;
    if (oldValues && newValues) {
      const removed = oldValues.filter((d) => !newValues.includes(d)), added = newValues.filter((d) => !oldValues.includes(d));
      if (removed.length) add(breaking.narrowing ? "breaking" : "compatible", name, `value set narrowed: ${removed.join(",")} removed`);
      if (added.length) {
        const isBreaking = breaking.widening && !(direction === "response" && TOLERANT);
        add(isBreaking ? "breaking" : "compatible", name, `value set widened: ${added.join(",")} added`);
      }
    }
  }
  for (const [name, n] of Object.entries(newSchema)) {
    if (oldSchema[name]) continue;
    const isBreaking = n.required && breaking.requiredAdded;
    add(isBreaking ? "breaking" : "addition", name, n.required ? "required field added" : "optional field added");
  }
  return findings;
}

const findings = [...compare(V1.request, V2.request, "request"), ...compare(V1.response, V2.response, "response")];
const ORDER = { breaking: 0, addition: 1, compatible: 2 };
findings.sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || a.direction.localeCompare(b.direction));

console.log(`tolerance declaration: ${TOLERANT ? "present" : "absent"}\n`);
console.log("kind       direction  name          change");
for (const f of findings) console.log(`${f.kind.padEnd(10)} ${f.direction.padEnd(10)} ${f.name.padEnd(13)} ${f.description}`);

const counts = { breaking: 0, addition: 0, compatible: 0 };
for (const f of findings) counts[f.kind]++;
const [major, minor, patch] = V1.version.split(".").map(Number);
const next = counts.breaking ? `${major + 1}.0.0` : counts.addition ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;

console.log(`\nbreaking: ${counts.breaking}   addition: ${counts.addition}   compatible: ${counts.compatible}`);
console.log(`current version: ${V1.version}   derived version: ${next}`);
console.log(`if breaking changes were excluded: ${major}.${minor + 1}.0`);
```

```
tolerance declaration: absent

kind       direction  name          change
breaking   request    /branch       was optional, became required
breaking   request    /dayCount     type number → integer (narrowing)
breaking   request    /items        required field added
breaking   response   /member       type string → object (unrelated)
breaking   response   /isbn         field removed
breaking   response   /returnDate   was required, became optional
breaking   response   /status       value set widened: overdue added
addition   request    /note         optional field added
addition   response   /items        required field added
compatible request    /isbn         field removed
compatible request    /branch       value set widened: hill added

breaking: 7   addition: 2   compatible: 2
current version: 1.4.2   derived version: 2.0.0
if breaking changes were excluded: 1.5.0
```

The last two lines show the inverse relationship directly. Removing the `/isbn` field is
compatible in the request, breaking in the response. Adding `hill` to the `/branch` field's
value set is compatible in the request; adding `overdue` to the `/status` field's value set
is breaking in the response. The same operation lands in the opposite class in the opposite
direction.

The `/items` field in the `addition` rows is also a consequence of this rule: adding a
required field to the response is compatible, because the old client was not reading that
field anyway. Adding a required field to the request, however, is breaking, and the table
shows exactly that.

The version number is no longer debated but **derived**. Seven breaking changes raise the
major version. The last line shows a separate option: if the breaking changes were excluded
from the release and only the additions were shipped, the version would be 1.5.0. These two
lines turn the debate "what should we do about the version" into "what are we putting in
the release"; the second is an answerable question.

#### Tolerance Declaration

Widening the value set in the response is the most contested item on the list. Does adding
the `overdue` status break old clients? The answer depends on what the contract says. If
the contract says "if an unknown status value arrives, the client must treat it as
unrecognized," the widening is not breaking; if it does not say that, it is breaking.

```bash
node differ.mjs tolerant | tail -4
```

```
breaking: 6   addition: 2   compatible: 3
current version: 1.4.2   derived version: 2.0.0
if breaking changes were excluded: 1.5.0
```

The breaking count drops from seven to six. The lesson here is that tolerance is a
**contract clause**. It is not a decision made on the server side; it requires clients to
actually be written that way, and that can only be expected if it was declared in the
contract beforehand. A tolerance clause added to the contract afterward is itself a
breaking change.

#### What the Diff Does Not Say

The differ tells you **what could break**, not **what will break**. To see the distinction,
the same schemas are tested with a real old client.

```js
// simulate.mjs — tests the classification by actually running an old client
// Old client sends a v1 body and reads a v1 response; the server is on the v2 schema.
import { V1, V2 } from "./schemas.mjs";

const OLD_REQUEST = { "/member": "U-1001", "/isbn": "978-0262033848", "/branch": "shore", "/dayCount": 14.5 };
const NEW_RESPONSE = { "/id": "O-1", "/member": { id: "U-1001" }, "/items": [{}], "/status": "overdue" };

const typeOf = (d) =>
  Array.isArray(d) ? "array" : typeof d === "object" ? "object"
    : typeof d === "number" ? (Number.isInteger(d) ? "integer" : "number") : "string";

// 1) The old client's body is run through the new schema.
const requestErrors = [];
for (const [name, field] of Object.entries(V2.request)) {
  const d = OLD_REQUEST[name];
  if (d === undefined) { if (field.required) requestErrors.push(`${name}: required field not sent`); continue; }
  if (field.type !== typeOf(d) && !(field.type === "number" && typeOf(d) === "integer")) requestErrors.push(`${name}: sent ${typeOf(d)}, expected ${field.type}`);
  if (field.values && !field.values.includes(d)) requestErrors.push(`${name}: ${d} is not accepted`);
}

// 2) The new response is read with the schema the old client expects.
const responseErrors = [];
for (const [name, field] of Object.entries(V1.response)) {
  const d = NEW_RESPONSE[name];
  if (d === undefined) { if (field.required) responseErrors.push(`${name}: expected field missing from response`); continue; }
  if (field.type !== typeOf(d)) responseErrors.push(`${name}: got ${typeOf(d)}, expected ${field.type}`);
  if (field.values && !field.values.includes(d)) responseErrors.push(`${name}: unrecognized value ${d}`);
}

console.log("— the old client's body on the new server —");
for (const h of requestErrors) console.log(`  ${h}`);
console.log(`  fields rejected: ${requestErrors.length}`);

console.log("\n— the new server's response on the old client —");
for (const h of responseErrors) console.log(`  ${h}`);
console.log(`  fields not readable: ${responseErrors.length}`);
```

```
— the old client's body on the new server —
  /items: required field not sent
  /dayCount: sent number, expected integer
  fields rejected: 2

— the new server's response on the old client —
  /member: got object, expected string
  /isbn: expected field missing from response
  /returnDate: expected field missing from response
  /status: unrecognized value overdue
  fields not readable: 4
```

The differ had found three breaking changes in the request direction; only two of them
materialized on this client. The `/branch` field makes the difference: it went from
optional to required, but this client was already sending it. The change is breaking by
the contract — it raises the version number — but its cost for this client is zero.

Contract and cost are separate measures. The version number looks at the contract and
rises even if not a single client is affected; the release plan looks at cost. The
migration cost calculation in the Design Systems course established the same distinction:
breakingness is the contract's measure, the number of affected call sites is reality's
measure.

The four items in the response direction, however, materialized exactly. This shows
another asymmetry between the directions: breakingness in the request direction depends on
what the client **sends**, breakingness in the response direction depends on what it
**reads**. Clients are generally not required to send every field in the schema, but the
fields they read are fixed; this is why breaking changes in the response direction turn
into real cost at a higher rate.

#### Summary

- The contract's two directions are subject to opposite rules: it is breaking in the
  request direction if what the server accepts narrows, and breaking in the response
  direction if what it produces widens.
- The same change falls into opposite classes in the two directions; removing a field from
  the request is compatible, removing it from the response is breaking.
- Adding a required field to the response is compatible, adding a required field to the
  request is breaking; type narrowing is breaking in the request, type widening is
  breaking in the response.
- The version number is derived from the diff result; when breaking changes are excluded
  from the release, the derived number changes too, moving the debate from the version to
  the release scope.
- Tolerance for an unknown value is a contract clause and must be declared beforehand;
  adding it afterward is itself a breaking change.
- The differ tells you what could break; which client actually breaks is a separate
  measurement, and breakingness in the response direction turns into real cost at a higher
  rate.

#### Next Step

The differ detected seven breaking changes and raised the version to 2.0.0, but left one
question unanswered: how long will the old version live? Raising the version number does
not migrate old clients on its own; for them to migrate, they first have to learn about
the situation, then find time for the transition. The next lesson turns this duration into
a policy: it adds headers that announce deprecation in the response itself, models usage
telemetry to calculate the share of consumers still on the old version at the end of the
transition period, and shows how the sunset date is chosen based on that share.

### Deprecation Policy

Source: https://academia.sh/en/courses/api-design/deprecation-policy

Announcing deprecation in the response itself, collecting per-consumer version telemetry, and calculating the consumer count and traffic share remaining on the old version at the end of the transition period.

The differ detected seven breaking changes and raised the version to 2.0.0. Raising the
version number does not migrate old clients on its own; v1 still works, and every client
that calls it keeps working. Maintaining v1 forever is not an option for the library
either: two versions means every bug fix has to be done twice and every new field has to
be produced in two shapes.

The path between the two is **deprecation**: the version is first declared deprecated,
keeps running for a while longer, and is then closed. This lesson establishes three
things — where the announcement is written, how to measure who is still on the old
version, and which number the sunset date is chosen by looking at.

#### Writing the Announcement Into the Response

If the announcement is written into the documentation, only those who read the
documentation see it. The party the announcement needs to reach is the **code that calls
the old version**; the person who wrote that code cannot be expected to read the
documentation that exact week. That is why the announcement is put into the response
itself.

Two headers define this. `Deprecation` reports the moment the resource was deprecated;
`Sunset` reports the moment access will end. The two are separate information: the first
says "do not use this anymore," the second says "this will stop working on this date."
The `Link` headers placed alongside them point to the replacement resource and the
migration guide; an announcement that only says "this is going away" speeds up no
migration.

```js
// server.mjs — serves the v1 resource with a deprecation announcement
// Usage: node server.mjs <today>   example: node server.mjs 2026-04-10
import { createServer } from "node:http";

const TODAY = new Date(process.argv[2] ?? "2026-04-10");
const ANNOUNCED = new Date("2026-03-01T00:00:00Z");   // the date deprecation was announced
const SUNSET = new Date("2026-09-01T00:00:00Z");      // the date access will end

const RECORD = { id: "O-1", member: "U-1001", isbn: "978-0262033848", returnDate: "2026-03-20" };

// Telemetry: which consumer called which version how many times.
const counts = new Map();   // "consumer|version" -> request count

createServer((req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  if (path === "/telemetry") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify([...counts].map(([a, s]) => ({ consumer: a.split("|")[0], version: a.split("|")[1], requests: s }))));
  }

  const version = /^\/v(\d+)\//.exec(path)?.[1];
  const consumer = req.headers["api-consumer"] ?? "unknown";
  if (version) counts.set(`${consumer}|v${version}`, (counts.get(`${consumer}|v${version}`) ?? 0) + 1);

  if (path === "/v2/loans/O-1") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify({ id: RECORD.id, member: { id: RECORD.member }, items: [{ isbn: RECORD.isbn }], dueDate: RECORD.returnDate }));
  }

  if (path === "/v1/loans/O-1") {
    // If the sunset date has passed, the resource no longer exists: 410, a permanent end.
    if (TODAY >= SUNSET) {
      res.writeHead(410, { "content-type": "application/problem+json; charset=utf-8", link: '</v2/loans/O-1>; rel="successor-version"' });
      return res.end(JSON.stringify({
        type: "https://example.library/problems/version-sunset", title: "Version sunset", status: 410,
        detail: `v1 was sunset on ${SUNSET.toUTCString()}; use /v2/loans.`, instance: "oc-0001",
      }));
    }
    // Announcement: Deprecation gives the moment of deprecation, Sunset the moment access ends.
    res.writeHead(200, {
      "content-type": "application/json; charset=utf-8",
      deprecation: `@${Math.floor(ANNOUNCED.getTime() / 1000)}`,
      sunset: SUNSET.toUTCString(),
      link: '</v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"',
    });
    return res.end(JSON.stringify(RECORD));
  }

  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found", status: 404, detail: `${path} does not exist.`, instance: "oc-0002" }));
}).listen(8436, "127.0.0.1", () => console.log(`server 127.0.0.1:8436 today=${TODAY.toISOString().slice(0, 10)}`));
```

```bash
#!/usr/bin/env bash
# Same request before and after the sunset date; then a telemetry dump.
for today in 2026-04-10 2026-09-02; do
  node server.mjs "$today" & s=$!
  sleep 0.5
  echo "--- $today ---"
  curl -sS -D - -o /tmp/g -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v1/loans/O-1 \
    | grep -iE '^HTTP|^deprecation|^sunset|^link' | tr -d '\r'
  cat /tmp/g; echo
  if [ "$today" = "2026-04-10" ]; then
    curl -sS -o /dev/null -H 'Api-Consumer: shelf-terminal' http://127.0.0.1:8436/v1/loans/O-1
    curl -sS -o /dev/null -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v2/loans/O-1
    echo "telemetry: $(curl -sS http://127.0.0.1:8436/telemetry)"
  fi
  kill "$s"; wait "$s" 2>/dev/null
done
```

```
server 127.0.0.1:8436 today=2026-04-10
--- 2026-04-10 ---
HTTP/1.1 200 OK
deprecation: @1772323200
sunset: Tue, 01 Sep 2026 00:00:00 GMT
link: </v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}
telemetry: [{"consumer":"mobile-app","version":"v1","requests":1},{"consumer":"shelf-terminal","version":"v1","requests":1},{"consumer":"mobile-app","version":"v2","requests":1}]
server 127.0.0.1:8436 today=2026-09-02
--- 2026-09-02 ---
HTTP/1.1 410 Gone
link: </v2/loans/O-1>; rel="successor-version"
{"type":"https://example.library/problems/version-sunset","title":"Version sunset","status":410,"detail":"v1 was sunset on Tue, 01 Sep 2026 00:00:00 GMT; use /v2/loans.","instance":"oc-0001"}
```

Three details matter. The announcement headers arrive together with a **successful**
response; the request has been fulfilled, only a warning accompanies it. The code returned
after the sunset is not 404 but **410**: 404 says "there is nothing here," 410 says "there
was something here and it was permanently removed"; the second prevents the client from
thinking it built the wrong address. And the `Link` header stays in the 410 response too,
because the moment of sunset is exactly when migration information is needed most.

The telemetry line is the third part. The server counts every request by consumer identity
and version; without this counter, the sunset date is chosen by guesswork. The consumer
identity arrives here through a header; once authentication is in place, this information
is read from the token and does not depend on the client reporting it.

#### What Remains at the End of the Transition Period

Telemetry gives how many consumers migrate at what speed. This speed varies by segment:
actively developed large consumers migrate fast, small consumers untouched for years
migrate slowly. The program below calculates what remains at the end of the transition
period for three announcement styles.

```js
// transition.mjs — computes the consumer count and traffic share remaining on the old version at the end of the transition period
// Model: each week, a fixed fraction of a segment's remaining consumers migrates.

const SEGMENTS = [
  { name: "large",  count: 4,   traffic: 0.60 },
  { name: "medium", count: 20,  traffic: 0.30 },
  { name: "small",  count: 176, traffic: 0.10 },
];

// Weekly migration rate: varies by how the announcement was made.
const ANNOUNCEMENTS = {
  "docs only":                 { large: 0.05, medium: 0.03, small: 0.01 },
  "response header":           { large: 0.15, medium: 0.10, small: 0.04 },
  "header + direct warning":   { large: 0.35, medium: 0.10, small: 0.04 },
};

const WEEKS = 26;   // between March 1 and September 1

const remaining = (rate, week) => (1 - rate) ** week;

console.log(`transition period: ${WEEKS} weeks   total consumers: ${SEGMENTS.reduce((t, k) => t + k.count, 0)}\n`);
console.log("announcement style          remaining consumers  remaining traffic  large  medium  small");
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  const fractions = SEGMENTS.map((k) => remaining(rates[k.name], WEEKS));
  const consumers = SEGMENTS.reduce((t, k, i) => t + k.count * fractions[i], 0);
  const traffic = SEGMENTS.reduce((t, k, i) => t + k.traffic * fractions[i], 0);
  console.log(
    `${name.padEnd(28)} ${Math.round(consumers).toString().padStart(19)}  ${(traffic * 100).toFixed(1).padStart(17)}%  ` +
    fractions.map((o, i) => Math.round(SEGMENTS[i].count * o).toString().padStart(5)).join(" ")
  );
}

// If the sunset criterion is traffic share: how much does the window need to stretch?
const THRESHOLD = 0.005;
console.log(`\nweeks needed until remaining traffic drops below ${THRESHOLD * 100}%:`);
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  let h = 0;
  while (SEGMENTS.reduce((t, k) => t + k.traffic * remaining(rates[k.name], h), 0) > THRESHOLD && h < 500) h++;
  console.log(`  ${name.padEnd(28)} ${String(h).padStart(3)} weeks (${(h / 4.35).toFixed(1)} months)`);
}

// Per segment: which segment the remaining traffic comes from
console.log("\ndistribution of remaining traffic across segments under 'response header' announcement:");
const o = ANNOUNCEMENTS["response header"];
const remainingTraffic = SEGMENTS.map((k) => k.traffic * remaining(o[k.name], WEEKS));
const total = remainingTraffic.reduce((a, b) => a + b, 0);
SEGMENTS.forEach((k, i) =>
  console.log(`  ${k.name.padEnd(7)} ${(remainingTraffic[i] * 100).toFixed(2)}%  (${((remainingTraffic[i] / total) * 100).toFixed(0)}% of remaining traffic)`));
```

```
transition period: 26 weeks   total consumers: 200

announcement style          remaining consumers  remaining traffic  large  medium  small
docs only                                    146               37.1%      1     9   136
response header                               62                6.3%      0     1    61
header + direct warning                       62                5.4%      0     1    61

weeks needed until remaining traffic drops below 0.5%:
  docs only                    299 weeks (68.7 months)
  response header               75 weeks (17.2 months)
  header + direct warning       74 weeks (17.0 months)

distribution of remaining traffic across segments under 'response header' announcement:
  large   0.88%  (14% of remaining traffic)
  medium  1.94%  (31% of remaining traffic)
  small   3.46%  (55% of remaining traffic)
```

The table gives three results.

First, the effect of writing the announcement into the response is large: when announced
only in the documentation, thirty-seven percent of traffic is still on the old version at
the end of six months, while when announced through the response header, this share drops
to 6.3 percent. The difference is not a policy change but a change in where the
announcement reaches.

Second, reaching out to large consumers directly brings total traffic down from 6.3
percent to only 5.4 percent. Direct warning noticeably speeds up large consumers'
migration, but their share has already evaporated within six months; fifty-five percent of
remaining traffic comes from small consumers, and the cost of reaching them one by one is
high. **The way to reduce remaining traffic does not run through talking to the largest
consumers.**

Third and most important, the two numbers separate from each other. Under the
response-header announcement, **62 consumers** are still on the old version at the end of
the transition period, but they produce only 6.3 percent of traffic. The answer to "how
many consumers are left" postpones the sunset; the answer to "how much traffic is left"
makes the sunset possible. The two are two measures of the same reality, and the decision
changes depending on which one is looked at.

#### Choosing the Sunset Date

The last table shows the consequence of setting the criterion as a threshold: bringing
remaining traffic below 0.5 percent takes 75 weeks — seventeen months — under the
response-header announcement. A six-month window cannot hit this threshold.

This gives three options, and all three are legitimate. The window is extended to
seventeen months, and the two versions live together that long. The threshold is relaxed;
6.3 percent is deemed acceptable, and the remaining consumers get a 410 at sunset. Or the
sunset is staged: access is cut off at specific hours first, then closed entirely — so
consumers who have not migrated notice the sunset in production, but are not caught
unprepared at the moment of full closure.

What determines the choice is **who** the remaining consumers are. If the remaining 6.3
percent is the library's own shelf terminals, the window is kept short, because the party
that will carry out the migration is also the library. If it is outside district
libraries' software, the window is extended. This is exactly why telemetry is collected
per consumer: the total share is not enough to make this decision.

It is also useful for the window to be measured by **release**, not by calendar: the
phrase "for one major version" keeps its meaning even if release speed changes. But if the
sunset moment is to be reported in the response, it has to be converted into a date; the
`Sunset` header carries a date, not a release number. The two are used together: the
policy is written in releases, the announcement is converted to a date.

#### Summary

- The place the announcement needs to reach is not the documentation but the code that
  calls the old version; that is why the `Deprecation` and `Sunset` headers are sent
  together with the successful response.
- `Deprecation` reports the moment of deprecation, `Sunset` the moment access will end;
  the `Link` headers alongside them point to the replacement resource and the migration
  guide.
- After the sunset, 410 is returned instead of 404, and the migration link stays in the
  410 response too.
- Writing the announcement into the response brings traffic remaining on the old version
  at the end of six months down from 37 percent to 6.3 percent.
- More than half of remaining traffic comes from small consumers; reaching out to the
  largest consumers directly reduces total remaining traffic by only 0.9 percentage
  points.
- The remaining consumer count and the remaining traffic share are separate measures, and
  the sunset decision changes depending on which one is looked at; telemetry is collected
  per consumer precisely to be able to make this distinction.

#### Next Step

Everything built up to this point — error format, field-level validation, versions,
breakingness rules, the deprecation window — is part of the contract, and all of it has
been described in prose so far. Prose has two problems as a contract: it cannot be handed
to a machine, and it cannot be compared against reality. The differ was working on a
schema; where does that schema come from, and how is it known that it matches what the
server actually produces? The next lesson turns the contract into a machine-readable
definition, produces a validator from that definition, tests real requests, and catches
the case where the schema and the real response diverge.

### Machine-Readable Documentation

Source: https://academia.sh/en/courses/api-design/machine-readable-documentation

Writing the contract as a schema, producing both a validator and human-readable documentation from the same definition, and catching at runtime the case where the server's actual response diverges from the definition.

Everything built up to this point — error format, field-level validation, versions,
breakingness rules, the deprecation window — is part of the contract. Where the contract is
written down, however, has remained open. Written in prose, two things cannot be done: it
cannot be handed to a machine, and it cannot be compared against reality.

The differ was already working on a schema. This lesson turns that schema into the
contract's **single source**: a validator and documentation are produced from the same
definition, and then the response the server actually produces is tested against that same
definition.

#### Writing the Contract as a Schema

**JSON Schema** is used for body schemas; type, requiredness, allowed values, and pattern
are expressed with this vocabulary. The path/method/response layout wrapping the schemas
does the job interface definition formats (OpenAPI) do: it states which method a given path
supports and which body comes back for each status code.

```js
// definition.mjs — the loan service's machine-readable definition
// Body schemas are written with JSON Schema vocabulary; the wrapper around them
// reflects the path/method/response layout of interface definition formats (OpenAPI).

const LOAN_REQUEST = {
  type: "object",
  required: ["member", "items"],
  additionalProperties: false,
  properties: {
    member: { type: "string", pattern: "^U-\\d{4}$" },
    items: {
      type: "array",
      items: { type: "object", required: ["isbn"], additionalProperties: false,
               properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$" } } },
    },
    branch: { type: "string", enum: ["central", "shore", "hill"] },
  },
};

const LOAN_RESPONSE = {
  type: "object",
  required: ["id", "member", "items", "returnDate", "status"],
  additionalProperties: false,
  properties: {
    id: { type: "string", pattern: "^O-\\d+$" },
    member: { type: "string" },
    items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false,
                properties: { isbn: { type: "string" } } } },
    returnDate: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
    status: { type: "string", enum: ["open", "closed"] },
  },
};

const PROBLEM = {
  type: "object",
  required: ["type", "title", "status", "detail", "instance"],
  properties: {
    type: { type: "string" }, title: { type: "string" }, status: { type: "integer" },
    detail: { type: "string" }, instance: { type: "string" },
  },
};

export const DEFINITION = {
  "POST /loans": { request: LOAN_REQUEST, response: { 201: LOAN_RESPONSE, 422: PROBLEM } },
  "GET /loans/{id}": { request: null, response: { 200: LOAN_RESPONSE, 404: PROBLEM } },
};
```

Notice that the definition also covers the error responses. If the contract only defined
the success body, the client still would not know what to expect in an error condition;
the problem details format built in the first lesson is written here as a schema too, and
thereby becomes checkable.

#### Producing a Validator From the Definition

For a schema to be machine-readable means a validator can be **produced** from it. The
program below interprets the subset of JSON Schema in use and returns a validation
function for every schema.

```js
// validator.mjs — produces a validator from a JSON Schema subset
// Supported keywords: type, required, properties, items, enum, pattern, additionalProperties

const typeOf = (d) =>
  d === null ? "null" : Array.isArray(d) ? "array"
    : typeof d === "number" ? (Number.isInteger(d) ? "integer" : "number") : typeof d;

function check(schema, value, path, errors) {
  const t = typeOf(value);
  if (schema.type && t !== schema.type && !(schema.type === "number" && t === "integer")) {
    errors.push(`${path || "/"}: expected ${schema.type}, got ${t}`);
    return;
  }
  if (schema.enum && !schema.enum.includes(value)) errors.push(`${path}: ${JSON.stringify(value)} is not in the allowed values`);
  if (schema.pattern && !new RegExp(schema.pattern).test(String(value))) errors.push(`${path}: ${JSON.stringify(value)} does not match the pattern`);

  if (schema.type === "object") {
    for (const name of schema.required ?? []) if (!(name in value)) errors.push(`${path}/${name}: required field missing`);
    if (schema.additionalProperties === false) {
      for (const name of Object.keys(value)) {
        if (!(name in (schema.properties ?? {}))) errors.push(`${path}/${name}: field not in definition`);
      }
    }
    for (const [name, sub] of Object.entries(schema.properties ?? {})) {
      if (name in value) check(sub, value[name], `${path}/${name}`, errors);
    }
  }
  if (schema.type === "array" && schema.items) value.forEach((o, i) => check(schema.items, o, `${path}/${i}`, errors));
}

// Produces a validator function from a schema: (value) -> error list
export const makeValidator = (schema) => (value) => {
  const errors = [];
  check(schema, value, "", errors);
  return errors;
};
```

Notice that the error messages carry the body path: the field naming convention built in
the Validation Errors lesson is preserved here automatically, because the path is produced
while the schema is being walked. When the schema is the single source, field names come
from a single source too.

Human-readable documentation is also produced from the same definition. Having the
documentation and the validator come from the same file makes documentation drift
structurally impossible.

```js
// docs.mjs — produces human-readable documentation from the same definition that produces the validator
import { DEFINITION } from "./definition.mjs";

const attribute = (s) => [
  s.type,
  s.enum ? `values: ${s.enum.join("|")}` : null,
  s.pattern ? `pattern: ${s.pattern}` : null,
].filter(Boolean).join(", ");

function write(schema, indent = "  ") {
  if (schema.type === "object") {
    for (const [name, sub] of Object.entries(schema.properties ?? {})) {
      const required = (schema.required ?? []).includes(name) ? "required" : "optional";
      console.log(`${indent}${name.padEnd(12)} ${required.padEnd(12)} ${attribute(sub)}`);
      if (sub.type === "object" || sub.type === "array") write(sub.type === "array" ? sub.items : sub, indent + "  ");
    }
    if (schema.additionalProperties === false) console.log(`${indent}(fields not in the definition are not accepted)`);
  }
}

const FILTER = process.argv[2];   // optional: only this key is written

for (const [key, t] of Object.entries(DEFINITION)) {
  if (FILTER && key !== FILTER) continue;
  console.log(`\n## ${key}`);
  if (t.request) { console.log(" request body:"); write(t.request); }
  for (const [code, schema] of Object.entries(t.response)) { console.log(` response ${code}:`); write(schema); }
}
```

```bash
node docs.mjs "POST /loans"
```

```
## POST /loans
 request body:
  member       required     string, pattern: ^U-\d{4}$
  items        required     array
    isbn         required     string, pattern: ^97[89]-\d{10}$
    (fields not in the definition are not accepted)
  branch       optional     string, values: central|shore|hill
  (fields not in the definition are not accepted)
 response 201:
  id           required     string, pattern: ^O-\d+$
  member       required     string
  items        required     array
    isbn         required     string
    (fields not in the definition are not accepted)
  returnDate   required     string, pattern: ^\d{4}-\d{2}-\d{2}$
  status       required     string, values: open|closed
  (fields not in the definition are not accepted)
 response 422:
  type         required     string
  title        required     string
  status       required     integer
  detail       required     string
  instance     required     string
```

#### Checking the Response Against the Definition

Validating the request is a common practice. The real drift occurs in the response the
server **produces**: a field gets added, a value set widens, a field stops coming back in
some conditions. None of these is caught by validating the request.

The server below has a single point that writes the response, and at that point it also
tests the response against the definition. Deviations are recorded into a list and read
from the `/check` path. A deviation has been planted in it on purpose: for the `O-1`
record, a field not in the definition and a status value not in the definition are
produced.

```js
// server.mjs — validates the request with the validator produced from the definition, and checks the response against the definition too
import { createServer } from "node:http";
import { DEFINITION } from "./definition.mjs";
import { makeValidator } from "./validator.mjs";

// A validator is produced once for each schema in the definition.
const REQUEST_VALIDATORS = Object.fromEntries(
  Object.entries(DEFINITION).filter(([, t]) => t.request).map(([a, t]) => [a, makeValidator(t.request)]));
const RESPONSE_VALIDATORS = Object.fromEntries(
  Object.entries(DEFINITION).flatMap(([a, t]) => Object.entries(t.response).map(([k, s]) => [`${a} ${k}`, makeValidator(s)])));

const deviations = [];   // places where the response and the definition diverge

const loans = new Map([["O-1", { id: "O-1", member: "U-1001", items: [{ isbn: "978-0262033848" }], returnDate: "2026-03-20", status: "open" }]]);
let counter = 1;

const readBody = (req) =>
  new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); });

// The single point that writes the response: the check against the definition happens here.
const respond = (res, key, code, body, type = "application/json") => {
  const validate = RESPONSE_VALIDATORS[`${key} ${code}`];
  const errors = validate ? validate(body) : ["this response code is not in the definition"];
  if (errors.length) deviations.push({ key, code, errors });
  res.writeHead(code, { "content-type": `${type}; charset=utf-8` });
  res.end(JSON.stringify(body));
};

const problem = (type, title, code, detail) =>
  ({ type: `https://example.library/problems/${type}`, title, status: code, detail, instance: `oc-${counter++}` });

createServer(async (req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  if (path === "/check") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify(deviations, null, 1));
  }

  if (req.method === "POST" && path === "/loans") {
    const key = "POST /loans";
    const body = JSON.parse((await readBody(req)) || "{}");
    const errors = REQUEST_VALIDATORS[key](body);
    if (errors.length) {
      const g = problem("validation", "Request body failed validation", 422, errors.join("; "));
      return respond(res, key, 422, g, "application/problem+json");
    }
    const record = { id: `O-${loans.size + 1}`, member: body.member, items: body.items, returnDate: "2026-04-15", status: "open" };
    loans.set(record.id, record);
    return respond(res, key, 201, record);
  }

  if (req.method === "GET" && path.startsWith("/loans/")) {
    const key = "GET /loans/{id}";
    const record = loans.get(path.slice("/loans/".length));
    if (!record) return respond(res, key, 404, problem("resource-not-found", "Resource not found", 404, `${path} does not exist.`), "application/problem+json");
    // DEVIATION: overdue records produce a field and a value that are not in the definition.
    const output = record.id === "O-1"
      ? { ...record, status: "overdue", overdueDays: 12 }
      : record;
    return respond(res, key, 200, output);
  }

  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify(problem("resource-not-found", "Resource not found", 404, `${path} does not exist.`)));
}).listen(8437, "127.0.0.1", () => console.log("server 127.0.0.1:8437"));
```

```bash
#!/usr/bin/env bash
# A valid request, an invalid request, and a response that diverges from the definition; then the check dump.
node server.mjs & s=$!
sleep 0.5

echo "--- valid request ---"
curl -sS -w '  [%{http_code}]\n' -X POST -H 'content-type: application/json' \
  -d '{"member":"U-1002","items":[{"isbn":"978-0201896831"}],"branch":"shore"}' http://127.0.0.1:8437/loans

echo "--- invalid request ---"
curl -sS -w '  [%{http_code}]\n' -X POST -H 'content-type: application/json' \
  -d '{"member":"1002","items":[{"isbn":"0201896831","quantity":2}],"branch":"sea","note":"urgent"}' http://127.0.0.1:8437/loans

echo "--- response that diverges from the definition ---"
curl -sS -w '  [%{http_code}]\n' http://127.0.0.1:8437/loans/O-1

echo "--- check ---"
curl -sS http://127.0.0.1:8437/check

kill "$s"; wait "$s" 2>/dev/null
```

```
server 127.0.0.1:8437
--- valid request ---
{"id":"O-2","member":"U-1002","items":[{"isbn":"978-0201896831"}],"returnDate":"2026-04-15","status":"open"}  [201]
--- invalid request ---
{"type":"https://example.library/problems/validation","title":"Request body failed validation","status":422,"detail":"/note: field not in definition; /member: \"1002\" does not match the pattern; /items/0/quantity: field not in definition; /items/0/isbn: \"0201896831\" does not match the pattern; /branch: \"sea\" is not in the allowed values","instance":"oc-1"}  [422]
--- response that diverges from the definition ---
{"id":"O-1","member":"U-1001","items":[{"isbn":"978-0262033848"}],"returnDate":"2026-03-20","status":"overdue","overdueDays":12}  [200]
--- check ---
[
 {
  "key": "GET /loans/{id}",
  "code": 200,
  "errors": [
   "/overdueDays: field not in definition",
   "/status: \"overdue\" is not in the allowed values"
  ]
 }
]
```

The invalid request was rejected with five separate violations, and no rule was written by
hand; all of it came from the definition. What matters most is the third line: the server
produced a response containing a field and a status value not in the definition, the
client received that response with **status code 200 and the full body**, but the
deviation was recorded.

This is the runtime counterpart of the previous lesson's breakingness classification.
Adding the `overdue` value is a widening of the value set in the response direction, and
it is breaking without a tolerance declaration. The differ finds this when two schemas are
compared; the check here finds the case where the code changed without the schema ever
being updated. The two close different leaks: one classifies a deliberate change, the
other makes an unintentional change visible.

#### The Check Not Being Blocking

When the response departs from the definition, two behaviors can be chosen. If the check
is **blocking**, the server rejects its own response and produces a 500; if it is
**recording**, it sends the response and writes the deviation to the log. The server above
does the second.

The choice looks at whom the deviation harms. Adding a field not in the definition is
harmless for most clients; blocking that response makes a working function unusable. In
development and test environments, by contrast, blocking mode is appropriate: the
deviation breaks before it reaches the production environment. The same check is run with
two different behaviors depending on the environment.

Recording mode has one condition: the deviation list has to be read. A deviation written
to a log nobody looks at is the same thing as a deviation never detected.

#### Summary

- When the contract is written in prose, it cannot be handed to a machine and cannot be
  compared against reality; written as a schema, both become possible.
- Body schemas are written with JSON Schema, and the path/method/response layout is
  written with a wrapper that does the job interface definition formats do; it covers the
  error responses too.
- When both the validator and human-readable documentation are produced from the same
  definition, documentation drift becomes structurally impossible; field names come from a
  single source too.
- Validating only the request leaves a gap: the drift mostly occurs in the response the
  server produces, and it is only caught if the response is also tested against the
  definition.
- The response check finds the case where the code changed without the schema ever being
  updated; the differ, meanwhile, classifies two schemas that were deliberately changed.
- The check runs in recording mode in production, and in blocking mode in development and
  test; recording mode only works if the deviation list is actually read.

#### Next Step

The definition now checks what the server actually produces, but leaves one question
unanswered: which fields does the consumer actually **use**? The definition says every
element of the `items` array has an `isbn`; does the shelf terminal read that field, or is
`id` and `status` all it needs? Without knowing this, it cannot be predicted which change
will break whom, and every change gets planned for the worst case. The next lesson writes
consumer expectations to a file, runs them against the provider, and shows which
consumer's test fails when the provider makes a breaking change.

### Contract Tests

Source: https://academia.sh/en/courses/api-design/contract-testing

Writing consumer expectations to a file, running them against the provider, showing which consumer's test fails on a breaking change, and calculating the impact scope before the change is made.

The machine-readable definition checks what the server produces, but does not know what
the consumer **uses**. The definition says the loan record has five fields; does the shelf
terminal read all of them, or only two? Without knowing this, every change gets planned
for the worst case, and no field can be removed with confidence.

Contract tests close this gap. Every consumer writes down what it expects from the
provider; the provider runs these expectations as part of its own test suite. Because
expectations are derived from the consumer's **actual usage**, they carry different
information than the definition: the definition says what is given, the expectation says
what is used.

#### Consumer Expectation

The expectation file contains the consumer's name, the requests it makes, and the fields
it reads from those requests. The loan service has two consumers: the barcode terminal on
the shelves and members' phone app.

```json
{
  "consumer": "shelf-terminal",
  "interactions": [
    {
      "name": "the record's id and status are read",
      "request": { "method": "GET", "path": "/loans/O-1" },
      "expected": { "code": 200, "fields": { "/id": "string", "/status": "string" } }
    }
  ]
}
```

```json
{
  "consumer": "mobile-app",
  "interactions": [
    {
      "name": "the loan record is shown on screen",
      "request": { "method": "GET", "path": "/loans/O-1" },
      "expected": { "code": 200, "fields": { "/member": "string", "/items/0/isbn": "string", "/returnDate": "string" } }
    },
    {
      "name": "an invalid member id is rejected with a field error",
      "request": { "method": "POST", "path": "/loans", "body": { "member": "1001", "items": [{ "isbn": "978-0262033848" }] } },
      "expected": { "code": 422, "fields": { "/type": "string", "/errors/0/path": "string", "/errors/0/code": "string" } }
    }
  ]
}
```

What the expectation **does not** contain matters as much as what it does. The shelf
terminal never wrote down the `member` and `items` fields, because it does not read them.
The mobile app did not write down the `id` field. The expectation file does not say "the
response contains these fields," it says "this consumer depends on these fields." The
difference determines who a field the provider removes will break.

The mobile app's second expectation covers the error path too. The error contract is also
part of the contract: the presence of the path and code fields in the 422 response's
`errors` array is the condition for that app being able to show errors next to their input
boxes.

#### Running Expectations Against the Provider

The provider can run in two versions: v1, and v2, where the breaking change was made. The
breaking change is the `returnDate` field being renamed `dueDate` and the `member` field
turning into an object.

```js
// provider.mjs — the loan service provider
// Usage: node provider.mjs <port> [v2]
//   v2: returnDate field becomes dueDate, member returns as an object (breaking change)
import { createServer } from "node:http";

const PORT = Number(process.argv[2] ?? 8438);
const V2 = process.argv[3] === "v2";

const RECORD = { id: "O-1", member: "U-1001", items: [{ isbn: "978-0262033848" }], returnDate: "2026-03-20", status: "open" };

const present = (k) => V2
  ? { id: k.id, member: { id: k.member }, items: k.items, dueDate: k.returnDate, status: k.status }
  : { ...k };

const readBody = (req) =>
  new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); });

createServer(async (req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];
  const json = (code, g, type = "application/json") => {
    res.writeHead(code, { "content-type": `${type}; charset=utf-8` });
    res.end(JSON.stringify(g));
  };

  if (req.method === "GET" && path === "/loans/O-1") return json(200, present(RECORD));

  if (req.method === "POST" && path === "/loans") {
    const g = JSON.parse((await readBody(req)) || "{}");
    if (!/^U-\d{4}$/.test(g.member ?? "")) {
      return json(422, { type: "https://example.library/problems/validation", title: "Request body failed validation",
        status: 422, detail: "Member id format is invalid.", instance: "oc-0001",
        errors: [{ path: "/member", code: "format" }] }, "application/problem+json");
    }
    return json(201, present({ ...RECORD, id: "O-2", member: g.member, items: g.items ?? [] }));
  }

  json(404, { type: "https://example.library/problems/resource-not-found", title: "Resource not found",
    status: 404, detail: `${path} does not exist.`, instance: "oc-0002" }, "application/problem+json");
}).listen(PORT, "127.0.0.1", () => console.log(`provider 127.0.0.1:${PORT} ${V2 ? "v2" : "v1"}`));
```

The test file reads the expectations from the directory, starts the provider as a separate
process, and actually sends every interaction.

```js
// contract.test.mjs — runs consumer expectations against the provider
// Usage: node --test contract.test.mjs           (provider v1)
//        PROVIDER_VERSION=v2 node --test contract.test.mjs
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";

const PORT = 8438;
const BASE = `http://127.0.0.1:${PORT}`;
let child;

before(async () => {
  child = spawn("node", ["provider.mjs", String(PORT), process.env.PROVIDER_VERSION ?? "v1"], { stdio: "ignore" });
  for (let i = 0; i < 40; i++) {                       // wait until the provider comes up
    try { await fetch(`${BASE}/loans/O-1`); return; } catch { await new Promise((c) => setTimeout(c, 50)); }
  }
  throw new Error("provider failed to start");
});
after(() => child.kill());

const read = (body, path) => path.split("/").slice(1).reduce((d, p) => (d == null ? undefined : d[p]), body);
const typeOf = (d) => (Array.isArray(d) ? "array" : d === null ? "null" : typeof d);

for (const file of readdirSync("expectations").sort()) {
  const { consumer, interactions } = JSON.parse(readFileSync(`expectations/${file}`, "utf8"));
  for (const e of interactions) {
    test(`${consumer}: ${e.name}`, async () => {
      const response = await fetch(BASE + e.request.path, {
        method: e.request.method,
        headers: e.request.body ? { "content-type": "application/json" } : {},
        body: e.request.body ? JSON.stringify(e.request.body) : undefined,
      });
      assert.equal(response.status, e.expected.code, "status code");
      const body = await response.json();
      for (const [path, expectedType] of Object.entries(e.expected.fields)) {
        const value = read(body, path);
        assert.notEqual(value, undefined, `${path} field is not in the response`);
        assert.equal(typeOf(value), expectedType, `${path} field's type`);
      }
    });
  }
}
```

```bash
node --test contract.test.mjs 2>&1 | head -8
```

```
✔ mobile-app: the loan record is shown on screen (61.390833ms)
✔ mobile-app: an invalid member id is rejected with a field error (3.7515ms)
✔ shelf-terminal: the record's id and status are read (2.022584ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
```

Timings vary by machine. Now the provider makes the breaking change:

```bash
PROVIDER_VERSION=v2 node --test contract.test.mjs 2>&1 | head -20
```

```
✖ mobile-app: the loan record is shown on screen (61.953125ms)
✔ mobile-app: an invalid member id is rejected with a field error (4.043333ms)
✔ shelf-terminal: the record's id and status are read (0.81875ms)
ℹ tests 3
ℹ suites 0
ℹ pass 2
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 123.58675

✖ failing tests:

test at contract.test.mjs:28:5
✖ mobile-app: the loan record is shown on screen (61.953125ms)
  AssertionError [ERR_ASSERTION]: /member field's type

  'object' !== 'string'
```

One of the three tests failed. The failing test names exactly which **consumer's** which
**interaction** broke in which **field**. The shelf terminal's test passed, because it
does not read the changed fields.

This is information validation against the definition cannot give. The definition check
says "the response departed from the definition" and stops; the contract test says "the
mobile app's loan screen broke, the shelf terminal was unaffected." The first reports a
rule violation, the second reports a consequence.

#### Calculating the Impact Before the Change

For a test to fail, the change has to have already been made. Expectation files can also
be read **before** the change: which field is read by which consumer is already written
down.

```js
// impact.mjs — from the expectation files, derives which change will break which consumer
import { readdirSync, readFileSync } from "node:fs";

// Consumer -> the field paths it depends on
const dependencies = new Map();
for (const file of readdirSync("expectations").sort()) {
  const { consumer, interactions } = JSON.parse(readFileSync(`expectations/${file}`, "utf8"));
  const paths = interactions.flatMap((e) => Object.keys(e.expected.fields).map((y) => `${e.request.path} ${y}`));
  dependencies.set(consumer, new Set(paths));
}

// All fields the definition declares in the GET /loans/O-1 response
const DEFINED = ["/id", "/member", "/items/0/isbn", "/returnDate", "/status"].map((y) => `/loans/O-1 ${y}`);

console.log("field                          dependent consumers");
for (const path of DEFINED) {
  const linked = [...dependencies].filter(([, k]) => k.has(path)).map(([t]) => t);
  console.log(`${path.replace("/loans/O-1 ", "").padEnd(30)} ${linked.length ? linked.join(", ") : "— (nobody reads this)"}`);
}

// Proposed change: returnDate is dropped, member becomes an object.
const CHANGE = ["/loans/O-1 /returnDate", "/loans/O-1 /member"];
const affected = [...dependencies].filter(([, k]) => CHANGE.some((d) => k.has(d))).map(([t]) => t);

console.log(`\nproposed change: ${CHANGE.map((d) => d.split(" ")[1]).join(", ")}`);
console.log(`affected consumers: ${affected.length}/${dependencies.size}  (${affected.join(", ")})`);
console.log(`fields never read: ${DEFINED.filter((y) => ![...dependencies.values()].some((k) => k.has(y))).length}`);
```

```
field                          dependent consumers
/id                            shelf-terminal
/member                        mobile-app
/items/0/isbn                  mobile-app
/returnDate                    mobile-app
/status                        shelf-terminal

proposed change: /returnDate, /member
affected consumers: 1/2  (mobile-app)
fields never read: 0
```

This table does the same job at the field level that the telemetry in the Deprecation
Policy lesson did. Telemetry counted which consumer called which **version**; expectation
files say which consumer reads which **field**. Together, the two turn a change's cost
from a guess into a calculation.

The last line is also a warning. "Fields never read: 0" looks reassuring, but it is only
true for consumers who have **written an expectation file**. The conclusion that no one
reads a field only holds once every consumer has written its expectations. This is
contract testing's most fragile spot: a missing expectation looks like a nonexistent
dependency.

This is why contract testing is an arrangement both sides participate in. The consumer
writes its expectation and hands it to the provider's repository; the provider runs these
files in its own test suite. A consumer that does not write an expectation is invisible in
the provider's decisions, and when it breaks, all it can do is report the fact. Setting up
this arrangement for the library's own shelf terminal is easy; collecting expectations for
outside district libraries' applications is the hardest part of the arrangement.

#### Summary

- The machine-readable definition says what is given, the consumer expectation says what
  is used; only the second tells you whom removing a field will break.
- Fields absent from an expectation file mean the consumer does not depend on them; the
  error response's structure is part of the expectation too.
- When expectations are run against the provider, a breaking change names exactly which
  consumer's which interaction broke in which field.
- The definition check reports a rule violation, the contract test reports a consequence;
  the two answer different questions.
- Expectation files can also be read before the change, and the number of consumers a
  proposed change affects can be calculated.
- The conclusion "a field nobody reads" only holds for consumers that have written their
  expectations; a missing expectation looks like a nonexistent dependency.

#### Next Step

Expectation files have been used one-directionally so far: the consumer wrote, the
provider tested. The same files also work in the reverse direction. When a new consumer
starts being developed, the provider may not have written that endpoint yet; but the
consumer waiting is unnecessary, because the contract is already known. The next lesson
writes a server that produces sample responses from the schema, develops a client against
it, and shows that the same client code works unchanged once it moves to the real server.

### Mock Servers

Source: https://academia.sh/en/courses/api-design/mock-servers

Writing a server that produces sample responses from a schema, developing the client against it, measuring that the same code works once it moves to the real server, and counting the cases the mock server hides.

Expectation files have been used one-directionally so far: the consumer wrote, the
provider tested. The same contract also does its job in the reverse direction. When a new
consumer starts being developed, the provider may not have written that endpoint yet; but
the consumer waiting is unnecessary, because the contract is already known.

A **mock server** turns the contract into something executable: it produces sample
responses from the schema in the definition and stands in for the real server. This lesson
writes such a server, develops a client against it, then connects the same code to the
real server and measures two things — whether the response shape is the same, and which
of the client's branches never ran.

#### Adding Examples to the Schema

Producing an example is not possible for every field. Type information is enough to
produce a string, but not enough to produce a string that matches the `^U-\d{4}$` pattern.
That is why fields with a pattern have to carry an example; JSON Schema defines the
`examples` keyword for this.

```js
// definition.mjs — the loan service's machine-readable definition (with example values)
// Every field that has a pattern carries an example; the mock server uses them.
const LOAN_REQUEST = {
  type: "object", required: ["member", "items"], additionalProperties: false,
  properties: {
    member: { type: "string", pattern: "^U-\\d{4}$", examples: ["U-1001"] },
    items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false,
      properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$", examples: ["978-0262033848"] } } } },
    branch: { type: "string", enum: ["central", "shore", "hill"] },
  },
};

const LOAN_RESPONSE = {
  type: "object", required: ["id", "member", "items", "returnDate", "status"], additionalProperties: false,
  properties: {
    id: { type: "string", pattern: "^O-\\d+$", examples: ["O-1"] },
    member: { type: "string", pattern: "^U-\\d{4}$", examples: ["U-1001"] },
    items: { type: "array", items: { type: "object", required: ["isbn"], additionalProperties: false,
      properties: { isbn: { type: "string", pattern: "^97[89]-\\d{10}$", examples: ["978-0262033848"] } } } },
    returnDate: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$", examples: ["2026-04-15"] },
    status: { type: "string", enum: ["open", "closed"] },
  },
};

const PROBLEM = {
  type: "object", required: ["type", "title", "status", "detail", "instance"],
  properties: {
    type: { type: "string", examples: ["https://example.library/problems/example"] },
    title: { type: "string", examples: ["Example problem"] },
    status: { type: "integer", examples: [0] },
    detail: { type: "string", examples: ["Example detail."] },
    instance: { type: "string", examples: ["oc-0001"] },
    errors: { type: "array", items: { type: "object", required: ["path", "code"],
      properties: { path: { type: "string", examples: ["/member"] }, code: { type: "string", examples: ["format"] } } } },
  },
};

export const DEFINITION = {
  "POST /loans": { request: LOAN_REQUEST, response: { 201: LOAN_RESPONSE, 422: PROBLEM } },
  "GET /loans/{id}": { request: null, response: { 200: LOAN_RESPONSE, 404: PROBLEM } },
};
```

```js
// example.mjs — produces an example value from a schema
// Rule: enum uses the first value, examples uses the first example, a pattern requires an example.
export function makeExample(schema, path = "") {
  if (schema.enum) return schema.enum[0];
  if (schema.examples?.length) return schema.examples[0];
  if (schema.pattern) throw new Error(`${path || "/"}: no examples written for a field with a pattern`);
  switch (schema.type) {
    case "string":  return "text";
    case "integer": case "number": return 0;
    case "boolean": return false;
    case "array":   return schema.items ? [makeExample(schema.items, `${path}/0`)] : [];
    case "object": {
      const obj = {};
      for (const [name, sub] of Object.entries(schema.properties ?? {})) obj[name] = makeExample(sub, `${path}/${name}`);
      return obj;
    }
    default: return null;
  }
}
```

When there is a pattern but no example, the generator errors. This is a new condition
placed on the contract: **the schema must be complete enough that an example can be
produced from it.**

```js
// missing-example.mjs — a patterned field without an example does not let the mock server start
import { makeExample } from "./example.mjs";

const COMPLETE = { type: "object", required: ["barcode"],
  properties: { barcode: { type: "string", pattern: "^BK-\\d{6}$", examples: ["BK-004312"] } } };
const INCOMPLETE = { type: "object", required: ["barcode"],
  properties: { barcode: { type: "string", pattern: "^BK-\\d{6}$" } } };

console.log("schema with example ->", JSON.stringify(makeExample(COMPLETE)));
try { makeExample(INCOMPLETE); } catch (h) { console.log("schema without example ->", h.message); }
```

```
schema with example -> {"barcode":"BK-004312"}
schema without example -> /barcode: no examples written for a field with a pattern
```

#### The Mock Server

The mock server recognizes the paths in the definition, produces the examples **once**,
and returns them. Because examples are produced up front, a missing-example problem shows
up while the server is starting, not on the first request.

```js
// mock.mjs — server that produces sample responses from the definition
// Usage: node mock.mjs <port> [problem]   "problem": also produces error paths
import { createServer } from "node:http";
import { DEFINITION } from "./definition.mjs";
import { makeExample } from "./example.mjs";

const PORT = Number(process.argv[2] ?? 8439);
const PROBLEM_MODE = process.argv[3] === "problem";

// Examples are produced once; a field with a pattern but no example errors here.
const EXAMPLES = Object.fromEntries(
  Object.entries(DEFINITION).flatMap(([a, t]) =>
    Object.entries(t.response).map(([k, s]) => [`${a} ${k}`, makeExample(s)])));

const readBody = (req) =>
  new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); });

createServer(async (req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];
  const key = req.method === "POST" && path === "/loans" ? "POST /loans"
    : req.method === "GET" && /^\/loans\/[^/]+$/.test(path) ? "GET /loans/{id}" : null;

  if (!key) { res.writeHead(404).end(); return; }
  await readBody(req);

  // Which status code to produce: success, or an error code in problem mode.
  const codes = Object.keys(DEFINITION[key].response).map(Number);
  const success = codes.find((k) => k < 400);
  const code = PROBLEM_MODE && req.headers["example-problem"] ? Number(req.headers["example-problem"]) : success;

  const body = structuredClone(EXAMPLES[`${key} ${code}`]);
  if (body && code >= 400) body.status = code;          // the real code is written into the example body
  res.writeHead(code, { "content-type": code >= 400 ? "application/problem+json; charset=utf-8" : "application/json; charset=utf-8" });
  res.end(JSON.stringify(body));
}).listen(PORT, "127.0.0.1", () => console.log(`mock server 127.0.0.1:${PORT}${PROBLEM_MODE ? " (problem mode)" : ""}`));
```

The client receives the server address from outside. The switch between the mock and the
real server happens by changing this single dependency; nothing else changes.

```js
// client.mjs — client that shows a loan record; the server address is supplied externally
export async function showLoan(base, id, extraHeaders = {}) {
  const response = await fetch(`${base}/loans/${id}`, { headers: extraHeaders });
  const body = await response.json();
  const fields = Object.keys(body).join(",");
  if (response.status === 200)
    return { case: "success", fields, line: `${body.id} · ${body.member} · ${body.items.length} items · ${body.returnDate} · ${body.status}` };
  if (response.status === 404) return { case: "not-found", fields, line: body.detail };
  if (response.status === 422) return { case: "validation", fields, line: body.errors.map((h) => `${h.path}=${h.code}`).join(", ") };
  return { case: "unknown", fields, line: `unexpected code ${response.status}` };
}
```

```js
// real.mjs — real server that conforms to the definition
import { createServer } from "node:http";

const RECORDS = new Map([["O-1", { id: "O-1", member: "U-1001", items: [{ isbn: "978-0201896831" }], returnDate: "2026-05-02", status: "closed" }]]);

createServer((req, res) => {
  res.sendDate = false;
  const id = req.url.split("?")[0].replace("/loans/", "");
  const record = RECORDS.get(id);
  if (record) {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify(record));
  }
  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found",
    status: 404, detail: `Loan record ${id} does not exist.`, instance: "oc-0007" }));
}).listen(8440, "127.0.0.1", () => console.log("real server 127.0.0.1:8440"));
```

#### Same Code, Three Servers

The runner below runs the client against a given address, writes the returned body's field
set, and counts how many distinct branches of the client ran.

```js
// run.mjs — runs the same client code against a given address, reports which cases
// ran and the shape of the response.
// Usage: node run.mjs <base-address> [problem]
import { showLoan } from "./client.mjs";

const BASE = process.argv[2];
const PROBLEM = process.argv[3] === "problem";

const CASES = PROBLEM
  ? [["existing record", "O-1", {}], ["missing record", "O-9", { "example-problem": "404" }]]
  : [["existing record", "O-1", {}], ["missing record", "O-9", {}]];

const cases = new Set();
for (const [name, id, headers] of CASES) {
  const s = await showLoan(BASE, id, headers);
  cases.add(s.case);
  console.log(`${name.padEnd(16)} case=${s.case.padEnd(12)} ${s.line}`);
  console.log(`${"".padEnd(16)} fields: ${s.fields}`);
}
console.log(`distinct cases run: ${cases.size}/4  (${[...cases].join(", ")})`);
```

```bash
#!/usr/bin/env bash
# Same client code: first against the mock server, then against the real server.
node mock.mjs 8439 & mock=$!
node real.mjs & real=$!
sleep 0.6

echo "== mock server (success examples only) =="
node run.mjs http://127.0.0.1:8439
kill "$mock"; wait "$mock" 2>/dev/null

node mock.mjs 8439 problem & mock=$!
sleep 0.5
echo "== mock server (problem mode) =="
node run.mjs http://127.0.0.1:8439 problem
kill "$mock"; wait "$mock" 2>/dev/null

echo "== real server =="
node run.mjs http://127.0.0.1:8440
kill "$real"; wait "$real" 2>/dev/null
```

```
real server 127.0.0.1:8440
mock server 127.0.0.1:8439
== mock server (success examples only) ==
existing record  case=success      O-1 · U-1001 · 1 items · 2026-04-15 · open
                 fields: id,member,items,returnDate,status
missing record   case=success      O-1 · U-1001 · 1 items · 2026-04-15 · open
                 fields: id,member,items,returnDate,status
distinct cases run: 1/4  (success)
mock server 127.0.0.1:8439 (problem mode)
== mock server (problem mode) ==
existing record  case=success      O-1 · U-1001 · 1 items · 2026-04-15 · open
                 fields: id,member,items,returnDate,status
missing record   case=not-found    Example detail.
                 fields: type,title,status,detail,instance,errors
distinct cases run: 2/4  (success, not-found)
== real server ==
existing record  case=success      O-1 · U-1001 · 1 items · 2026-05-02 · closed
                 fields: id,member,items,returnDate,status
missing record   case=not-found    Loan record O-9 does not exist.
                 fields: type,title,status,detail,instance
distinct cases run: 2/4  (success, not-found)
```

The third section confirms the actual claim: client code developed against the mock
server worked **unchanged** once connected to the real server. The success body's field
set is identical across both servers; the values differ, the shape does not. The only
thing that changed to move the client to the real server was the address.

The first section shows the mock server's trap. A mock server that only produces success
examples returns 200 even when you ask for a record that does not exist. Only one of the
client's four branches runs, and the developer considers the job done without ever seeing
the error paths. This is the most common cause of breakage in the move to the real server:
what is missing is not the body shape but an error branch that was never written.

The second section gives the solution. When the mock server can learn which status code
to produce from the request, all of the client's paths can be run during development. The
count of branches run went from one to two, reaching the same number as the real server.
Being able to request every status code defined in the contract from the mock server
turns it from a demo tool into a development tool.

#### The Mock Server's Generosity

The field sets of the 404 bodies in the second and third sections differ: the mock server
also produced the `errors` field, the real server did not. Both conform to the contract,
because `errors` is not among the required fields.

The risk here is one-directional. Because the mock server also produces **optional
fields**, the client can assume they will always be present. That assumption collapses in
the move to the real server. There is no risk in the reverse direction: the required
fields the mock server produces are also present in the real server.

This is why whether the example generator produces optional fields should be a
configurable option. A client run in both modes has shown that it can stay standing with
only the required fields present — which is what the contract actually says.

#### Summary

- The mock server turns the contract into something executable and lets the consumer
  develop without waiting for the provider.
- An example cannot be produced for fields that have a pattern; the schema must be
  complete enough that an example can be produced from it, and the gap shows up while the
  server is starting.
- The same client code runs unchanged on the mock and the real server; the success body's
  field set is the same on both, only the values differ.
- A mock server that only produces a success example runs one of the client's four
  branches and leaves the error paths never written.
- Being able to choose the status code requested from the mock server brings the count of
  branches run up to the same level as the real server.
- Because the mock server also produces optional fields, it can convince the client they
  will always be present; the generator should also have a mode that works with only the
  required fields.

#### Next Step

Throughout this topic, the contract was always built the same way: the server decides
which resource to give with which fields, and the client makes do with what it is given.
Partial response and field selection loosened this rigidity, but the server still drew the
boundary. The query-based approach reverses this relationship: a type system is defined on
the server, the **client** decides which fields come back, and the response takes the
shape of the query. The next topic builds out this approach in detail; its first lesson
writes the schema and type system, defines object, scalar, interface, and union types, and
demonstrates them on a small runtime that actually resolves types.

## GraphQL in Detail

Building the type system, the three operation kinds, per-field resolvers, query reuse through fragments and variables, the measured solution to the N+1 problem through batch loading, the cursor-based connection model, field-level authorization and partial errors, query cost limits, and the caching consequences of the HTTP transport.

### Schema and Type System

Source: https://academia.sh/en/courses/api-design/schema-and-type-system

Defining object, scalar, enum, interface, and union types, parsing the query document, and the schema catching errors by validating the query before it runs.

Throughout the previous topic, the contract was built the same way every time: the server
decided which resource it would give with which fields, and the client settled for what it
was given. Partial response and field selection loosened this rigidity, but the server
still drew the boundary.

The query-based approach reverses this relationship. The server defines a **type system**;
the client decides which fields will come back, and the response takes the shape of the
query. In exchange, the server asks for one guarantee: everything the client can ask for
must already be defined by types.

Across this topic, a small GraphQL engine will be written for the loan service. This
lesson builds the engine's first two parts — the type system and the parser — and
demonstrates its third: the schema validating the query before it runs.

#### Five Kinds of Types

A schema is a record made up of named types. GraphQL defines six named kinds; the sixth is
the **input object** type, which declares argument objects, and this course does not use it —
its arguments stay scalar. The remaining five kinds are enough.

- **Scalar** is an indivisible value: an identifier, a string, a number, a date. It cannot
  take a subselection.
- **Enum** is a fixed set of values. Like scalar, it is a leaf.
- **Object** is a composite type with named fields. A subselection is required.
- **Interface** declares the fields that more than one object type shares. Only the common
  fields can be selected through an interface; reading a type-specific field requires
  dropping down to the concrete type.
- **Union** lets types with no common field return together. No field can be selected
  directly through a union.

The difference between interface and union is what the query is allowed to do. Both the
loan record and the member record carry an identifier and a creation date; this
commonality is an interface. A search result, on the other hand, returns either a book or
a member, and the two share no common field; this is a union.

```js
// schema.mjs — the loan service's type system
// Kind values: scalar, enum, object, interface, union. A field's type is written as a
// string: "Loan", "ID!", "[Item!]!" — ! means required, [] means list.

export const SCHEMA = {
  query: "Query",
  mutation: "Mutation",
  types: {
    ID:      { kind: "scalar" },
    String:  { kind: "scalar" },
    Int:     { kind: "scalar" },
    Date:    { kind: "scalar" },
    Status:  { kind: "enum", values: ["OPEN", "CLOSED", "OVERDUE"] },

    // Interface: declares the fields shared by more than one type.
    Record:  { kind: "interface", fields: { id: "ID!", createdAt: "Date!" } },

    Book:    { kind: "object", fields: { isbn: "ID!", title: "String!", author: "String!", copies: "Int!" } },
    Member:  { kind: "object", interfaces: ["Record"],
               fields: { id: "ID!", createdAt: "Date!", name: "String!",
                         loans: { type: "[Loan!]!", args: { status: "Status" } } } },
    Item:    { kind: "object", fields: { book: "Book!", branch: "String!" } },
    Loan:    { kind: "object", interfaces: ["Record"],
               fields: { id: "ID!", createdAt: "Date!", member: "Member!", items: "[Item!]!",
                         status: "Status!", returnDate: "Date" } },

    // Union: types with no common field returning together.
    SearchResult: { kind: "union", members: ["Book", "Member"] },

    Query:   { kind: "object", fields: {
                 loan: { type: "Loan", args: { id: "ID!" } },
                 loans: { type: "[Loan!]!", args: { status: "Status" } },
                 member: { type: "Member", args: { code: "ID!" } },
                 search: { type: "[SearchResult!]!", args: { term: "String!" } },
                 record: { type: "Record", args: { id: "ID!" } } } },
    Mutation: { kind: "object", fields: {
                 issueLoan: { type: "Loan", args: { member: "ID!", isbn: "ID!" } },
                 returnLoan:   { type: "Loan", args: { id: "ID!" } } } },
  },
};

// "[Item!]!" -> { name: "Item", list: true, required: true }
export function parseType(text) {
  let s = text, required = false, list = false;
  if (s.endsWith("!")) { required = true; s = s.slice(0, -1); }
  if (s.startsWith("[")) { list = true; s = s.slice(1, -1); if (s.endsWith("!")) s = s.slice(0, -1); }
  return { name: s, list, required };
}

export const fieldDef = (type, name) => {
  const t = type.fields?.[name];
  return t === undefined ? null : typeof t === "string" ? { type: t, args: {} } : { args: {}, ...t };
};

// The concrete types that can fall under an interface or a union
export const possibleTypes = (schema, name) => {
  const t = schema.types[name];
  if (t.kind === "union") return t.members;
  if (t.kind === "interface") return Object.entries(schema.types).filter(([, x]) => x.interfaces?.includes(name)).map(([a]) => a);
  return [name];
};
```

`Query` and `Mutation` have nothing special about them; they are ordinary object types.
What makes them starting points is that their names are written in the schema's `query`
and `mutation` fields. The type system is a graph, and these two names mark the nodes
where the graph is entered.

#### Parsing the Query

A query is text; it has to be turned into a tree before it can be validated. The parser
below recognizes operation types, aliases, arguments, variables, and query fragments,
called fragments for short in this course.

```js
// parser.mjs — turns a GraphQL query document into a tree
// Supported: operation types, alias, argument, variable, fragment and inline fragment.

const TOKEN = /\s+|,|#[^\n]*|(\.\.\.|[{}():$=[\]!]|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[_A-Za-z][_0-9A-Za-z]*)/g;

const tokenize = (text) => [...text.matchAll(TOKEN)].map((e) => e[1]).filter((s) => s !== undefined);

export function parse(text) {
  const s = tokenize(text);
  let i = 0;
  const peek = () => s[i];
  const take = (expected) => {
    if (expected !== undefined && s[i] !== expected) throw new Error(`expected "${expected}", got "${s[i] ?? "end of document"}"`);
    return s[i++];
  };

  const parseValue = () => {
    if (peek() === "$") { take("$"); return { kind: "variable", name: take() }; }
    if (peek() === "[") { take("["); const d = []; while (peek() !== "]") d.push(parseValue()); take("]"); return { kind: "literal", value: d }; }
    const t = take();
    if (t.startsWith('"')) return { kind: "literal", value: JSON.parse(t) };
    if (/^-?\d/.test(t)) return { kind: "literal", value: Number(t) };
    if (t === "true" || t === "false") return { kind: "literal", value: t === "true" };
    return { kind: "enum", value: t };                          // enum value
  };

  const typeName = () => {                                      // Loan! [Item!]! and the like
    let name;
    if (peek() === "[") { take("["); name = `[${typeName()}]`; take("]"); } else name = take();
    if (peek() === "!") { take("!"); name += "!"; }
    return name;
  };

  const selectionSet = () => {
    take("{");
    const selection = [];
    while (peek() !== "}") {
      if (peek() === "...") {
        take("...");
        if (peek() === "on") { take("on"); const type = take(); selection.push({ kind: "inline", type, selection: selectionSet() }); }
        else selection.push({ kind: "spread", name: take() });
        continue;
      }
      let name = take(), alias = null;
      if (peek() === ":") { take(":"); alias = name; name = take(); }
      const args = {};
      if (peek() === "(") { take("("); while (peek() !== ")") { const a = take(); take(":"); args[a] = parseValue(); } take(")"); }
      const sub = peek() === "{" ? selectionSet() : null;
      selection.push({ kind: "field", name, alias: alias ?? name, args, selection: sub });
    }
    take("}");
    return selection;
  };

  const operations = [], fragments = {};
  while (i < s.length) {
    if (peek() === "fragment") {
      take("fragment"); const name = take(); take("on"); const type = take();
      fragments[name] = { name, type, selection: selectionSet() };
      continue;
    }
    let type = "query", name = null;
    if (["query", "mutation", "subscription"].includes(peek())) { type = take(); if (peek() !== "(" && peek() !== "{") name = take(); }
    const variables = [];
    if (peek() === "(") {
      take("(");
      while (peek() !== ")") {
        take("$"); const vname = take(); take(":"); const vtype = typeName();
        let defaultValue = undefined;
        if (peek() === "=") { take("="); defaultValue = parseValue().value; }
        variables.push({ name: vname, type: vtype, defaultValue });
      }
      take(")");
    }
    operations.push({ type, name, variables, selection: selectionSet() });
  }
  return { operations, fragments };
}
```

The one thing the parser's tree does not know is **meaning**. If a field called
`overdueFine` is selected, the parser places it into the tree without any trouble; it is
the type system that will say no such field exists.

#### The Schema Validating the Query

The validator walks the selection set together with the schema's types, and at every step
looks for a match.

```js
// validate.mjs — checks the query against the schema; finds errors before running it
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

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

export function validate(schema, document) {
  const errors = [];
  for (const op of document.operations) {
    const root = op.type === "mutation" ? schema.mutation : schema.query;
    const declared = new Set(op.variables.map((d) => d.name));
    validateSelection(schema, document, root, op.selection, root, declared, errors);
  }
  return errors;
}

function validateSelection(schema, document, typeName, selection, path, declared, errors) {
  const type = schema.types[typeName];
  for (const s of selection) {
    if (s.kind === "spread") {
      const fragment = document.fragments[s.name];
      if (!fragment) { errors.push(`${path}: fragment "${s.name}" is not defined`); continue; }
      if (!possibleTypes(schema, typeName).includes(fragment.type) && fragment.type !== typeName)
        errors.push(`${path}: fragment "${s.name}" is for ${fragment.type}, the type here is ${typeName}`);
      else validateSelection(schema, document, fragment.type, fragment.selection, `${path}...${s.name}`, declared, errors);
      continue;
    }
    if (s.kind === "inline") {
      if (!possibleTypes(schema, typeName).includes(s.type))
        errors.push(`${path}: ${s.type} cannot fall under ${typeName} (possible types: ${possibleTypes(schema, typeName).join(", ")})`);
      else validateSelection(schema, document, s.type, s.selection, `${path}...${s.type}`, declared, errors);
      continue;
    }

    // A union type cannot select a field directly; a concrete type must be reached first.
    if (type.kind === "union") { errors.push(`${path}: ${typeName} is a union, field "${s.name}" cannot be selected directly`); continue; }

    const def = fieldDef(type, s.name);
    if (!def) { errors.push(`${path}: type ${typeName} has no field "${s.name}"`); continue; }

    for (const a of Object.keys(s.args)) {
      if (!(a in def.args)) errors.push(`${path}.${s.name}: argument "${a}" is not defined`);
    }
    for (const d of Object.values(s.args)) {
      if (d.kind === "variable" && !declared.has(d.name)) errors.push(`${path}.${s.name}: variable $${d.name} is not declared`);
    }
    for (const [a, t] of Object.entries(def.args)) {
      if (t.endsWith("!") && !(a in s.args)) errors.push(`${path}.${s.name}: required argument "${a}" is missing`);
    }

    const fieldType = schema.types[parseType(def.type).name];
    const isLeaf = LEAF.has(fieldType.kind);
    if (isLeaf && s.selection) errors.push(`${path}.${s.name}: ${parseType(def.type).name} is a leaf, it cannot take a subselection`);
    else if (!isLeaf && !s.selection) errors.push(`${path}.${s.name}: ${parseType(def.type).name} is composite, a subselection is required`);
    else if (!isLeaf) validateSelection(schema, document, parseType(def.type).name, s.selection, `${path}.${s.name}`, declared, errors);
  }
}
```

```js
// run.mjs — validates one valid and six invalid queries against the schema
import { parse } from "./parser.mjs";
import { validate } from "./validate.mjs";
import { SCHEMA } from "./schema.mjs";

const QUERIES = {
  "valid query": `
    query LoanScreen($member: ID!) {
      member(code: $member) { name loans(status: OPEN) { ...Summary } }
      search(term: "algorithm") { ... on Book { title } ... on Member { name } }
      record(id: "O-1") { id createdAt }
    }
    fragment Summary on Loan { id status items { book { title } } }`,

  "nonexistent field": `{ loan(id: "O-1") { id overdueFine } }`,
  "subselection on a leaf": `{ loan(id: "O-1") { status { name } } }`,
  "no subselection on a composite": `{ loan(id: "O-1") { id member } }`,
  "direct field from a union": `{ search(term: "x") { title } }`,
  "wrong fragment type": `{ loan(id: "O-1") { ...BookSummary } } fragment BookSummary on Book { title }`,
  "missing argument and undeclared variable": `{ loan { id } member(code: $k) { name } }`,
};

for (const [name, text] of Object.entries(QUERIES)) {
  const errors = validate(SCHEMA, parse(text));
  console.log(`\n${name}  (${errors.length} error${errors.length === 1 ? "" : "s"})`);
  for (const e of errors) console.log(`  ${e}`);
}
```

```
valid query  (0 errors)

nonexistent field  (1 error)
  Query.loan: type Loan has no field "overdueFine"

subselection on a leaf  (1 error)
  Query.loan.status: Status is a leaf, it cannot take a subselection

no subselection on a composite  (1 error)
  Query.loan.member: Member is composite, a subselection is required

direct field from a union  (1 error)
  Query.search: SearchResult is a union, field "title" cannot be selected directly

wrong fragment type  (1 error)
  Query.loan: fragment "BookSummary" is for Book, the type here is Loan

missing argument and undeclared variable  (2 errors)
  Query.loan: required argument "id" is missing
  Query.member: variable $k is not declared
```

One of the seven queries passed, six were rejected, and **none of them ran**. Every
rejection was made using only information from the schema; the data source was never
consulted, and not a single resolver was called.

This is a structurally different point from resource-based design. There, what an
endpoint accepts is validated at run time, and the consumer learns which fields exist
from documentation. Here, the same information lives in the schema and can be validated
before the query is even sent; the machine-readable definition that had to be written
separately in the Machine-Readable Documentation lesson is the type system itself.

#### What the Type System Gives and What It Asks For

The interface and union rows are where the type system does the most work. The
`record(id:)` field returns an interface; the query can select only the `id` and
`createdAt` fields from it, because those are the fields the interface declares. A query
that wants to read the `status` field specific to a loan record has to drop down to the
concrete type by writing `... on Loan`. In a union, there is no common field at all; no
branch can read anything without declaring its own type.

What this strictness buys is that the response's shape can be **derived from the query**.
The client knows the shape of the body that will come back just by looking at the
selection set it sent; the server has no need to publish a separate response schema.

The price it asks for is that the schema be complete. Nothing undefined in the schema can
be asked for; there is no escape hatch such as returning the raw data as is for now.
Every field, every argument, and every type relationship of the loan service has to be
written down.

#### Summary

- A schema is built from five kinds of types: scalar and enum are leaves, object is
  composite, interface declares common fields, and union returns types with no common
  field together.
- Only common fields are read through an interface; no field can be read directly through
  a union — in both cases, reading a type-specific field requires dropping down to the
  concrete type.
- `Query` and `Mutation` are ordinary object types; what makes them starting points is
  that their names are written into the schema as entry nodes.
- The parser turns the query into a tree but does not know its meaning; it is the type
  system that says whether a field exists.
- Validation against the schema happens without the query running and without a single
  resolver being called; a nonexistent field, a subselection on a leaf, a missing
  selection on a composite, a wrong fragment type, and a missing argument are all caught
  at this stage.
- What the type system buys is that the response's shape can be derived from the query;
  what it costs is that nothing undefined in the schema can be asked for.

#### Next Step

The validated query has not run yet; the schema says what can be asked, not where the
response will come from. And only one operation type has been discussed so far. Issuing a
loan is not a read but a write, and it asks for a different guarantee about ordering on
the server; waiting for a book's status to change is neither a read nor a write — it
produces more than one response over time. The next lesson defines the three operation
types, writes the engine's first version, and shows where the three diverge in their
execution rules.

### Query, Mutation, and Subscription

Source: https://academia.sh/en/courses/api-design/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.

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.

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

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

```js
// 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!" } } },
    },
  },
};
```

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

### Resolvers

Source: https://academia.sh/en/courses/api-design/resolvers

The resolver's four inputs, what the default resolver does, execution proceeding level by level, and measuring the resolver-call count and data-source round-trip count a query produces.

The engine called a resolver for every field, but how resolvers are written was not
discussed. A separate resolver had been written for the loan record's `member` field, and
none for its `id` field; the difference between the two is the backbone of GraphQL
execution.

This lesson defines the resolver signature, shows what the default resolver does, and
counts how many resolver calls a query produces. That last number will give rise to the
next lesson's topic.

#### The Resolver's Four Inputs

A resolver is the function that produces a single field's value, and it takes four
inputs.

- **Source** is the value the parent level returned. The source of the `Loan.member`
  resolver is the loan record one level up. Root fields have no source.
- **Args** are the field's arguments; variables have already been resolved by this point,
  and the resolver never sees anything like `$id`.
- **Context** is data shared across the whole request: credentials, a database
  connection, a per-request cache. It is the only horizontal channel carried between
  fields.
- **Info** says where the call is being made: which field, which parent type, which path
  in the response.

How the engine produces these four is shown below. Two things are new: the `info` object
and the `tracer` hook that every call is reported to.

```js
// executor.mjs — runs a validated document
// Every resolver gets four inputs:
//   source   the value the parent level returned
//   args     the field's arguments (variables resolved)
//   context  data shared across the request
//   info     { field, parentType, path } — identifies the call in place
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

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

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]));

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, path, 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, `${path}.${f.alias}`); };
  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, path) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const custom = o.resolvers[typeName]?.[field.name];
  const resolver = custom ?? ((k) => k?.[field.name]);       // default resolver: reads the field of the same name
  const info = { field: field.name, parentType: typeName, path, isDefault: !custom };
  o.tracer?.(info);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info);
  return complete(o, def.type, value, field.selection, path);
}

async function complete(o, typeText, value, selection, path) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) return Promise.all(value.map((d, i) => complete(o, t.name, d, selection, `${path}[${i}]`)));
  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, path);
}

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

#### The Default Resolver

The one decisive line in `executeField` is this: if no resolver has been written, the
engine uses the `(k) => k?.[field.name]` function. This is the **default resolver**, and
it reads the field of the same name from the source.

No resolver has been written for the loan record's `id`, `status`, and `returnDate`
fields, because the object coming from the data source already carries these fields
under the same names. One has been written for the `member` field, because the record
only holds a `memberId`, and turning it into a member object requires a lookup.

The rule is this: **a resolver is written only where the data's shape and the schema's
shape diverge.** This dramatically reduces the resolver count when the data source
resembles the schema, and it pushes the schema toward becoming a copy of the data source
— a tendency worth watching, because the schema is the interface the consumer sees, not
the store's.

The data source below counts every access separately, so that resolver calls and
data-source round trips can be told apart.

```js
// data.mjs — in-memory data; every access is counted
export const counter = { book: 0, member: 0, loan: 0 };

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 }],
  ["978-0131103627", { isbn: "978-0131103627", title: "The C Programming Language", author: "Kernighan", copies: 3 }],
]);
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" }],
]);
const LOANS = new Map([
  ["O-1", { id: "O-1", createdAt: "2026-01-04", memberId: "U-1001", status: "OPEN", returnDate: "2026-03-20",
            items: [{ isbn: "978-0262033848", branch: "central" }, { isbn: "978-0201896831", branch: "shore" }] }],
  ["O-2", { id: "O-2", createdAt: "2026-01-19", memberId: "U-1002", status: "OPEN", returnDate: "2026-04-02",
            items: [{ isbn: "978-0131103627", branch: "hill" }] }],
  ["O-3", { id: "O-3", createdAt: "2026-02-02", memberId: "U-1001", status: "CLOSED", returnDate: "2026-02-28",
            items: [{ isbn: "978-0201896831", branch: "central" }] }],
]);

// Single-record accesses: every call counts as one "data source round trip."
export const getBook = (isbn) => { counter.book++; return BOOKS.get(isbn); };
export const getMember = (id) => { counter.member++; return MEMBERS.get(id); };
export const listLoans = (memberId) => { counter.loan++; return [...LOANS.values()].filter((o) => !memberId || o.memberId === memberId); };
export const getLoan = (id) => { counter.loan++; return LOANS.get(id); };
export const reset = () => { counter.book = 0; counter.member = 0; counter.loan = 0; };

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    loan: (_, a) => getLoan(a.id) ?? null,
    loans: () => listLoans(),
    member: (_, a) => getMember(a.code) ?? null,
  },
  // No resolver for id, createdAt, status, returnDate: the default resolver does the job.
  Loan: { member: (o) => getMember(o.memberId) },
  Item: { book: (k) => getBook(k.isbn) },
  Member: { loans: (u) => listLoans(u.id) },
};
```

#### The Shape of Execution

```js
// run.mjs — traces resolver calls, shows their order, and counts them
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS, counter, reset } from "./data.mjs";

async function trace(title, query, verbose) {
  const calls = [];
  reset();
  const result = await execute(SCHEMA, RESOLVERS, parse(query), { tracer: (b) => calls.push(b) });

  console.log(`\n=== ${title} ===`);
  if (verbose) {
    for (const b of calls) console.log(`  ${(b.parentType + "." + b.field).padEnd(16)}${(b.isDefault ? "default" : "custom").padEnd(9)} ${b.path}`);
  } else {
    // Group by depth: depth, call count, which fields
    const level = new Map();
    for (const b of calls) {
      const d = b.path.split(".").length - 1;
      if (!level.has(d)) level.set(d, []);
      level.get(d).push(`${b.parentType}.${b.field}`);
    }
    console.log("  depth  calls  fields");
    for (const [d, list] of [...level].sort((a, b) => a[0] - b[0])) {
      console.log(`  ${String(d).padStart(5)}  ${String(list.length).padStart(5)}  ${[...new Set(list)].join(", ")}`);
    }
  }
  const custom = calls.filter((b) => !b.isDefault).length;
  console.log(`  total resolver calls: ${calls.length} (custom ${custom}, default ${calls.length - custom})`);
  console.log(`  data source round trips: book=${counter.book} member=${counter.member} loan=${counter.loan}  total=${counter.book + counter.member + counter.loan}`);
  return result;
}

await trace("small query", `{ loans { id member { name } } }`, true);
const result = await trace("large query", `{ loans { id status member { name } items { branch book { title author } } } }`, false);
console.log(`\nfirst record: ${JSON.stringify(result.data.loans[0])}`);
```

```
=== small query ===
  Query.loans     custom    .loans
  Loan.id         default   .loans[0].id
  Loan.member     custom    .loans[0].member
  Loan.id         default   .loans[1].id
  Loan.member     custom    .loans[1].member
  Loan.id         default   .loans[2].id
  Loan.member     custom    .loans[2].member
  Member.name     default   .loans[0].member.name
  Member.name     default   .loans[1].member.name
  Member.name     default   .loans[2].member.name
  total resolver calls: 10 (custom 4, default 6)
  data source round trips: book=0 member=3 loan=1  total=4

=== large query ===
  depth  calls  fields
      1      1  Query.loans
      2     12  Loan.id, Loan.status, Loan.member, Loan.items
      3     11  Member.name, Item.branch, Item.book
      4      8  Book.title, Book.author
  total resolver calls: 32 (custom 8, default 24)
  data source round trips: book=4 member=3 loan=1  total=8

first record: {"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central","book":{"title":"Introduction to Algorithms","author":"Cormen"}},{"branch":"shore","book":{"title":"The Art of Computer Programming","author":"Knuth"}}]}
```

The small query's trace directly shows the shape of execution. First the root field
resolves. Then the `id` and `member` fields of **all three** loan records resolve. Only
after those finish do the `Member.name` calls begin.

Execution proceeds not depth by depth but **level by level**. The reason is the
`Promise.all` call inside `executeSelection`: every sibling field at one level is
started together, and none of them moves to a level below until all of them finish. This
is the property the next lesson's batch loading will rest on: if the calls at the same
level are already made at the same time, they can be merged.

Two constraints force this order. A resolver's source is the value the parent level
returned; a child cannot resolve before its parent does. And there is no ordering
guarantee among sibling fields; because query fields are assumed independent, they cannot
depend on each other's result.

#### Two Separate Numbers

The large query produced 32 resolver calls but reached the data source only 8 times. The
two numbers are separate and measure different things.

The resolver-call count comes from the **shape of the query**: the product of the number
of selected fields and the lengths of the lists. Twenty-four of them are default
resolvers; these read a field from an in-memory object, and their cost is negligible.

The data-source round-trip count, on the other hand, comes from the **written
resolvers**, and this is where the real cost is. The distribution of the eight round
trips is striking: one loan-list call, three member calls, four book calls. Yet the
database holds only two distinct members and three distinct books.

The same member appears in two loan records and so was fetched twice; the same book
appears in two items and so was fetched twice. As the list grows longer, this number
grows with it: a hundred loan records mean a hundred member calls, even if most of the
members are the same person.

#### Summary

- A resolver takes four inputs: the source the parent level returned, the resolved args,
  the context shared across the request, and the info that identifies the call's place.
- The default resolver reads the field of the same name from the source; a resolver is
  written only where the data's shape and the schema's shape diverge.
- Execution proceeds level by level, not depth by depth: every sibling field at one level
  is started together, and none moves to the level below until all finish.
- The order arises from two constraints: a child level needs the value its parent
  returned, and sibling fields cannot depend on each other's result.
- The resolver-call count comes from the shape of the query, the data-source round-trip
  count from the written resolvers; the two are separate measures.
- The same member and the same book get fetched more than once; this number grows
  together with the list length.

#### Next Step

The number in the last paragraph is the name of a problem. Three member calls were made
for three loan records, yet there were only two distinct members; four book calls were
made, yet there were only three distinct books. When the list grows to a hundred records,
there will be a hundred and one round trips: one list query plus one for every record.
The next lesson names this problem, writes a batch-loading layer that rests on the fact
that calls made at the same level are already started together, and measures how far the
round-trip count drops.

### Fragments and Variables

Source: https://academia.sh/en/courses/api-design/fragments-and-variables

A fragment gathering a repeated selection set into one place, the resolver-call drop from merging fields in overlapping fragments, and variables fixing the number of distinct documents.

The engine has supported fragments and variables from the start, but what either one is
good for was never shown. This lesson answers two questions by measuring them: what does
writing a fragment buy, and what changes when a variable is used instead of inlining the
value in the query text?

Both have something in common: neither changes the response. A fragmented query and an
unfragmented one return the same data; a query with a variable and one with the value
inlined return the same data. The gain is somewhere else.

This lesson's parser, schema, data source, and engine are used exactly as they were
written in the previous three lessons; only the engine's selection-set-flattening portion
will change.

#### What a Fragment Does

A **query fragment** is a named selection set bound to a type; this course calls it a
fragment for short. A query that reads the same fields in more than one place writes that
set once and calls it by name.

```js
// run.mjs — compares an unfragmented query against a fragmented one
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS, reset } from "./data.mjs";

// The same selection set repeated in three places
const UNFRAGMENTED = `{
  open:   loan(id: "O-1") { id status member { name } items { branch book { title } } }
  closed: loan(id: "O-3") { id status member { name } items { branch book { title } } }
  other:  loan(id: "O-2") { id status member { name } items { branch book { title } } }
}`;

// The same query, the selection set in one place
const FRAGMENTED = `{
  open:   loan(id: "O-1") { ...LoanSummary }
  closed: loan(id: "O-3") { ...LoanSummary }
  other:  loan(id: "O-2") { ...LoanSummary }
}
fragment LoanSummary on Loan { id status member { name } items { branch book { title } } }`;

// If a field like "status" had to change, how many lines would be touched?
const editSites = (text, field) => text.split("\n").filter((s) => s.includes(field)).length;

async function runAndCount(text, variables = {}) {
  const calls = [];
  reset();
  const s = await execute(SCHEMA, RESOLVERS, parse(text), { variables, tracer: (b) => calls.push(b) });
  return { data: JSON.stringify(s.data), calls: calls.length };
}

const a = await runAndCount(UNFRAGMENTED);
const b = await runAndCount(FRAGMENTED);

console.log("document       lines  lines with 'status'  resolver calls");
console.log(`unfragmented   ${String(UNFRAGMENTED.split("\n").length).padStart(5)}  ${String(editSites(UNFRAGMENTED, "status")).padStart(20)}  ${String(a.calls).padStart(14)}`);
console.log(`fragmented     ${String(FRAGMENTED.split("\n").length).padStart(5)}  ${String(editSites(FRAGMENTED, "status")).padStart(20)}  ${String(b.calls).padStart(14)}`);
console.log(`\nare the two documents' results identical: ${a.data === b.data}`);
console.log(`result: ${a.data.slice(0, 96)}...`);
```

```
document       lines  lines with 'status'  resolver calls
unfragmented       5                     3              30
fragmented         6                     1              30

are the two documents' results identical: true
result: {"open":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central...
```

The fragmented document is one line longer, but the selection set sits in a single
place. When a field has to be added or removed, the unfragmented document requires
touching three lines, the fragmented one, one. The difference is three to one, and it
grows together with the number of root fields.

The results being identical and the resolver-call count not changing shows that the
fragment **disappears at run time**. The engine opens the fragments while it flattens the
selection set; past that point, there is no difference left between the fragmented and
unfragmented document. A fragment is not an execution concept, it is a source-text
concept.

What this buys is that a fragment can be matched to a **component** on the client side.
In the component tree from the Component-Based Interface Development course, each
component can declare its own data requirement as a fragment, and the parent component
builds a single query by combining these fragments; when a child component adds a field,
the parent query grows on its own.

#### Merging Overlapping Fields

While fragments are being combined, the same field can end up selected more than once.
What happens if both fragments ask for `id`?

The engine's selection-set-flattening portion is moved into a separate module, and it
answers this question: fields that fall on the same response key are reduced to a single
call, and their subselections merge.

```js
// flatten.mjs — flattens the selection set; merges fields that fall on the same response key
import { possibleTypes } from "./schema.mjs";

// Opens fragments, drops branches that do not match the concrete type, lists the rest in order.
function open(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)) open(schema, document, typeName, fragment.selection, acc);
  }
  return acc;
}

// Fields that fall on the same response key are reduced to a single call; their subselections merge.
export function flatten(schema, document, typeName, selection, merge = true) {
  const opened = open(schema, document, typeName, selection);
  if (!merge) return opened;
  const map = new Map();
  for (const a of opened) {
    const existing = map.get(a.alias);
    if (!existing) { map.set(a.alias, { ...a, selection: a.selection ? [...a.selection] : null }); continue; }
    if (a.selection) existing.selection = [...(existing.selection ?? []), ...a.selection];
  }
  return [...map.values()];
}
```

The engine is rebuilt on top of this module. The resolver signature, the tracer, and
level-ordered execution are as in the Resolvers lesson; two things change: the flattening
work has been moved out, and merging can be switched off. The measurement will rest on
this switch.

```js
// executor.mjs — the engine, with its flattening step coming from a separate module
// Resolver signature, the tracer, and level-ordered execution are as in the Resolvers
// lesson; the selection-set flattening now comes from flatten.mjs and merging can be
// switched off.
import { parseType, fieldDef } from "./schema.mjs";
import { flatten } from "./flatten.mjs";

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

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]));

async function executeSelection(o, typeName, selection, source, path, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection, o.merge);
  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, `${path}.${f.alias}`); };
  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, path) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const custom = o.resolvers[typeName]?.[field.name];
  const resolver = custom ?? ((k) => k?.[field.name]);       // default resolver: reads the field of the same name
  const info = { field: field.name, parentType: typeName, path, isDefault: !custom };
  o.tracer?.(info);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info);
  return complete(o, def.type, value, field.selection, path);
}

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

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

```js
// overlap.mjs — measures overlapping fragments resolving the same field twice
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS } from "./data.mjs";

const QUERY = `{
  loan(id: "O-1") { id ...Who ...What }
}
fragment Who on Loan { id status member { name } }
fragment What on Loan { id status items { branch } }`;

for (const merge of [false, true]) {
  const calls = [];
  const s = await execute(SCHEMA, RESOLVERS, parse(QUERY), { tracer: (b) => calls.push(b), merge });
  const counts = {};
  for (const b of calls) counts[`${b.parentType}.${b.field}`] = (counts[`${b.parentType}.${b.field}`] ?? 0) + 1;
  console.log(`merge ${merge ? "on " : "off"}  calls=${String(calls.length).padStart(2)}  ` +
    Object.entries(counts).map(([a, n]) => `${a}×${n}`).join(" "));
  console.log(`  result: ${JSON.stringify(s.data)}`);
}
```

```
merge off  calls=11  Query.loan×1 Loan.id×3 Loan.status×2 Loan.member×1 Loan.items×1 Member.name×1 Item.branch×2
  result: {"loan":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central"},{"branch":"shore"}]}}
merge on   calls= 8  Query.loan×1 Loan.id×1 Loan.status×1 Loan.member×1 Loan.items×1 Member.name×1 Item.branch×2
  result: {"loan":{"id":"O-1","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"branch":"central"},{"branch":"shore"}]}}
```

The two results are identical, and the call count dropped from eleven to eight. Without
merging, the `id` field would resolve three times and `status` twice; they would still
appear only once in the response, because they write to the same key. So execution
without merging does not produce a wrong result, it does **unnecessary work**.

This is the rule that makes it safe to write a fragment per component. Five components
can each ask for the `id` field in their own fragment; thanks to merging, the field
resolves once. Without the rule, the cost of writing a fragment would be multiplied by
the number of times it is used.

Merging has one condition: the fields falling on the same key must be able to overlap. A
field asked for twice under the same name with two different arguments cannot be merged;
in that case an alias is required, and two separate keys result.

#### Variables

A variable separates the query text from the value. The result of this separation is
that the **same document** goes to the server every time the same screen opens.

```js
// variables.mjs — compares a query with the value inlined in the text against one using a variable
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { RESOLVERS } from "./data.mjs";

const SMALL = ["O-1", "O-2", "O-3", "O-1", "O-2"];
// Large load: 200 requests, spread across 50 distinct ids
const LARGE = Array.from({ length: 200 }, (_, i) => `O-${(i % 50) + 1}`);

const INLINE       = { text: (id) => `{ loan(id: "${id}") { id status member { name } } }`, variables: () => ({}) };
const WITH_VARIABLE = { text: () => `query($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: (id) => ({ id }) };

async function run(title, ids, format, shouldRun) {
  const cache = new Map();      // the server keys the parsed document by its text
  let misses = 0, bytes = 0;
  for (const id of ids) {
    const text = format.text(id);
    if (!cache.has(text)) { misses++; cache.set(text, parse(text)); }
    bytes += Buffer.byteLength(text) + Buffer.byteLength(JSON.stringify(format.variables(id)));
    if (shouldRun) await execute(SCHEMA, RESOLVERS, cache.get(text), { variables: format.variables(id) });
  }
  console.log(`${title.padEnd(32)} requests=${String(ids.length).padStart(3)}  distinct documents=${String(cache.size).padStart(3)}  ` +
    `parses=${String(misses).padStart(3)}  bytes sent=${bytes}`);
}

await run("5 requests, value inline in text", SMALL, INLINE, true);
await run("5 requests, with variable", SMALL, WITH_VARIABLE, true);
await run("200 requests, value inline in text", LARGE, INLINE, false);
await run("200 requests, with variable", LARGE, WITH_VARIABLE, false);
```

```
5 requests, value inline in text requests=  5  distinct documents=  3  parses=  3  bytes sent=255
5 requests, with variable        requests=  5  distinct documents=  1  parses=  1  bytes sent=375
200 requests, value inline in text requests=200  distinct documents= 50  parses= 50  bytes sent=10364
200 requests, with variable      requests=200  distinct documents=  1  parses=  1  bytes sent=15164
```

The bytes column works against the variable-based format, and it stays that way:
declaring a variable lengthens the query text, and variables are sent separately. The
gain shows up not in the byte count but in the **distinct-document count**.

At five requests the difference is three to one, at two hundred requests it is fifty to
one. In the format that inlines the value, the distinct-document count grows together
with the distinct-value count; in the variable-based format it is one, and it stays one.
This number is useful in three places: the server can store the parsing and validation
result per document, logs can group how often each query runs, and documents can be
persisted in advance so the client only sends an identifier.

There is also a correctness reason. Inlining a value means **injecting** it into the
query language; a value containing a quote, embedded without escaping, can change the
query's structure. When a variable is used, the value is never parsed as query text — it
travels in a separate JSON body and its type is validated against the schema. The exact
problem prepared statements solve in SQL is solved here in the same way.

#### Summary

- A fragment is a named selection set bound to a type; it gathers repeated fields into
  one place, cutting the edit-site count from three to one.
- A fragment disappears at run time: a fragmented and an unfragmented document produce
  the same result and the same resolver-call count; a fragment is a source-text concept.
- Fields falling on the same response key are merged; execution without merging does not
  produce a wrong result, but it resolves the same field more than once.
- Merging makes it safe to write a fragment per component; without it, the cost of
  writing a fragment would be multiplied by the number of times it is used.
- Using a variable does not reduce the bytes sent, it fixes the distinct-document count:
  one document instead of fifty across two hundred requests.
- A fixed document count makes it possible to store the parsing result, group logs, and
  persist documents in advance; and because the value never enters the query language,
  the structure cannot break.

#### Next Step

The Resolvers lesson left a number unaddressed: three member calls were made for three
loan records, yet there were two distinct members. Fragments and variables did not touch
this number; merging only removes collisions at the same response key, it does not
remove different records asking for the same member. When the list grows to a hundred
records, there will be a hundred member calls. The next lesson names this problem, writes
a batch-loading layer that rests on the fact that calls at the same level are started
together, and measures how far the round-trip count drops.

### N+1 Problem and Batch Loading

Source: https://academia.sh/en/courses/api-design/n-plus-1-problem-and-batch-loading

The data-source round-trip count growing together with list length, a loader layer that merges calls from the same tick into a single round trip, and a measurement dropping from 101 round trips to 3.

The Resolvers lesson left a number unaddressed: three member calls were made for three
loan records, yet there were two distinct members. Fragments and Variables did not touch
this number; field merging only removes collisions at the same response key, it does not
remove different records asking for the same member.

This lesson shows the problem at scale and solves it. The data source holds fifty loan
records, eight members, and twelve books.

```js
// data.mjs — loan data generated for scale; every data source round trip is counted
export const roundTrips = { book: 0, member: 0, loan: 0 };
export const recordsFetched = { book: 0, member: 0 };
export const reset = () => { roundTrips.book = roundTrips.member = roundTrips.loan = 0; recordsFetched.book = recordsFetched.member = 0; };

const AUTHORS = ["Cormen", "Knuth", "Kernighan", "Dijkstra", "Hoare", "Lamport"];
export const BOOKS = new Map(Array.from({ length: 12 }, (_, i) => {
  const isbn = `978-000000${String(i).padStart(2, "0")}`;
  return [isbn, { isbn, title: `Book ${i}`, author: AUTHORS[i % AUTHORS.length], copies: 1 + (i % 3) }];
}));
export const MEMBERS = new Map(Array.from({ length: 8 }, (_, i) => {
  const id = `U-${1001 + i}`;
  return [id, { id, createdAt: "2025-01-01", name: `Member ${i}` }];
}));
export const LOANS = new Map(Array.from({ length: 50 }, (_, i) => {
  const id = `O-${i + 1}`;
  return [id, { id, createdAt: "2026-01-01", memberId: `U-${1001 + (i % 8)}`,
    status: i % 3 === 0 ? "CLOSED" : "OPEN", returnDate: "2026-04-01",
    items: [{ isbn: `978-000000${String(i % 12).padStart(2, "0")}`, branch: "central" }] }];
}));

// Single-key accesses: every call is one round trip.
export const getBook = (isbn) => { roundTrips.book++; recordsFetched.book++; return BOOKS.get(isbn); };
export const getMember = (id) => { roundTrips.member++; recordsFetched.member++; return MEMBERS.get(id); };
export const listLoans = () => { roundTrips.loan++; return [...LOANS.values()]; };

// Batch accesses: more than one key in a single round trip.
export const getBooks = (isbns) => { roundTrips.book++; recordsFetched.book += isbns.length; return isbns.map((i) => BOOKS.get(i)); };
export const getMembers = (ids) => { roundTrips.member++; recordsFetched.member += ids.length; return ids.map((i) => MEMBERS.get(i)); };
```

The parser, schema, flattener, and engine are used exactly as they were written in the
previous lessons; what is new is the data source and the loader layer.

#### The Name of the Problem

A query that asks for fifty loan records together with their members reaches the data
source fifty-one times: once to fetch the list, fifty times to fetch each record's
member. When the book field is added, fifty more round trips. This is called the **N+1
problem**: one list query plus as many single-record queries as the list is long.

The problem's source is the resolver model. The `Loan.member` resolver sees a single
loan record and fetches that record's member; it does not know it has forty-nine
siblings. Every resolver seeing only its own source is the resolver model's strength —
and it is also the cause of this problem.

The solution is not to change the resolvers, but to put a layer between them. A
**loader** does not fetch the keys handed to it right away; it collects the keys
requested within the same tick and reduces them to a single batch call. Its second job
is deduplication: if the same key is requested twice, it is fetched once.

```js
// loader.mjs — reduces keys requested in the same tick to a single batch call
// Its second job is deduplication: the same key requested twice is fetched once.
export function createLoader(batchFetch) {
  let pending = new Map();          // key -> [resolve, ...]
  let scheduled = false;

  const flush = async () => {
    const batch = pending; pending = new Map(); scheduled = false;
    const keys = [...batch.keys()];
    const values = await batchFetch(keys);
    keys.forEach((k, i) => batch.get(k).forEach((resolve) => resolve(values[i])));
  };

  return (key) => new Promise((resolve) => {
    if (!pending.has(key)) pending.set(key, []);
    pending.get(key).push(resolve);
    if (!scheduled) { scheduled = true; queueMicrotask(flush); }
  });
}
```

The batching window is set by the `queueMicrotask` call: every key that arrives before
the currently running tick finishes falls into the same batch. The level-ordered
execution measured in the Resolvers lesson pays off here — the fifty `Loan.member` calls
at the same level were already started together, so all of them enter the same window.

#### Measurement

```js
// run.mjs — runs the same query with single-key and batch resolvers, compares round trips
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA } from "./schema.mjs";
import { createLoader } from "./loader.mjs";
import { listLoans, getBook, getMember, getBooks, getMembers, roundTrips, recordsFetched, reset } from "./data.mjs";

const QUERY = `{ loans { id status member { name } items { branch book { title author } } } }`;

const SINGLE = {
  Query: { loans: () => listLoans() },
  Loan: { member: (o) => getMember(o.memberId) },
  Item: { book: (k) => getBook(k.isbn) },
};

// Batch resolvers pull their loaders from the per-request context.
const BATCH = {
  Query: { loans: () => listLoans() },
  Loan: { member: (o, _, c) => c.loadMember(o.memberId) },
  Item: { book: (k, _, c) => c.loadBook(k.isbn) },
};

async function measure(title, resolvers, makeContext) {
  reset();
  const calls = [];
  const s = await execute(SCHEMA, resolvers, parse(QUERY), { context: makeContext(), tracer: (b) => calls.push(b) });
  const total = roundTrips.book + roundTrips.member + roundTrips.loan;
  console.log(`${title.padEnd(9)} resolver calls=${String(calls.length).padStart(3)}  ` +
    `data source round trips: loan=${roundTrips.loan} member=${String(roundTrips.member).padStart(2)} book=${String(roundTrips.book).padStart(2)} total=${String(total).padStart(3)}  ` +
    `records fetched: member=${recordsFetched.member} book=${recordsFetched.book}`);
  return s.data.loans.length;
}

const n = await measure("single", SINGLE, () => ({}));
await measure("batch", BATCH, () => ({
  loadMember: createLoader(getMembers),      // a new loader per request
  loadBook: createLoader(getBooks),
}));
console.log(`\nloan records in the response: ${n}   distinct members: 8   distinct books: 12`);
```

```
single    resolver calls=451  data source round trips: loan=1 member=50 book=50 total=101  records fetched: member=50 book=50
batch     resolver calls=451  data source round trips: loan=1 member= 1 book= 1 total=  3  records fetched: member=8 book=12

loan records in the response: 50   distinct members: 8   distinct books: 12
```

Three numbers need reading.

**The round-trip count dropped from 101 to 3.** The query did not change, the resolver
count did not change, the response did not change; the only thing that changed is how
the resolvers reach the data source.

**The resolver-call count held steady at 451.** The loader does not reduce resolvers;
every loan record's `member` field is still resolved separately. What it reduces is the
**round trips those resolvers make to the data source**. This distinction matters: the
shape of execution comes from the query and cannot be changed, the shape of data access
comes from the resolver and can be.

**The records-fetched count dropped from 50 to 8.** This is deduplication's share.
Because fifty loan records are spread across eight members, the same member was
requested six times on average; the loader fetched each one once.

#### The Batching Window Depends on Execution Order

The loader's gain depends on keys falling into the same window. The window is as wide
as one tick, and it closes if there is a wait in between.

```js
// window.mjs — a loader's batching window is as wide as a single tick
import { createLoader } from "./loader.mjs";

const watch = (label) => (keys) => {
  console.log(`  ${label}: ${keys.length} key(s) in one round trip -> ${keys.join(", ")}`);
  return keys.map((a) => ({ id: a }));
};

console.log("keys requested in the same tick:");
const l1 = createLoader(watch("together"));
await Promise.all(["U-1001", "U-1002", "U-1003", "U-1001"].map(l1));

console.log("keys requested one after another, each awaited:");
const l2 = createLoader(watch("in turn "));
for (const a of ["U-1001", "U-1002", "U-1003"]) await l2(a);
```

```
keys requested in the same tick:
  together: 3 key(s) in one round trip -> U-1001, U-1002, U-1003
keys requested one after another, each awaited:
  in turn : 1 key(s) in one round trip -> U-1001
  in turn : 1 key(s) in one round trip -> U-1002
  in turn : 1 key(s) in one round trip -> U-1003
```

Four keys were requested, three were fetched: `U-1001` was requested twice and fetched
once. When the same keys were requested one after another, each awaited, three separate
round trips resulted; batching did not work at all.

Two rules follow from this. Using an unnecessary `await` inside a resolver breaks
batching: if a resolver awaits something else first and only then calls the loader, it
cannot enter the same window as its siblings. And because mutation root fields run in
order, there is no batching at the root level; batching still works at the levels below.

#### The Loader's Lifetime

In the measurement script, the loaders are rebuilt on every run. This is not a detail,
it is a requirement: **a loader is created per request.**

The reason is that deduplication is also a cache. Across a single request's lifetime,
the loader returns the same value for the same key; this keeps a single response
internally consistent. A loader shared between requests, on the other hand, turns into a
cache that holds stale data indefinitely, and two separate users' requests start sharing
the same records. A loader created per request goes away when the request's lifetime
ends.

This also explains why context exists. Loaders are placed on the context, because
context is the one structure created per request and reachable by every resolver.

#### Summary

- The N+1 problem is one list query plus as many single-record queries as the list is
  long; its source is every resolver seeing only its own source.
- A loader collects the keys requested within the same tick, reduces them to a single
  batch call, and fetches the same key once.
- On a fifty-record query, the data-source round-trip count drops from 101 to 3 and the
  records-fetched count from 50 to 8; the resolver-call count holds steady at 451.
- The shape of execution comes from the query and cannot be changed; the shape of data
  access comes from the resolver and can be.
- The batching window is as wide as one tick; calls awaited one after another form
  separate round trips, and unnecessary waits inside a resolver break batching.
- A loader is created per request; a shared loader turns into an indefinite cache and
  mixes together separate requests' data.

#### Next Step

All fifty loan records came back in a single response. The library's real loan count is
not fifty but hundreds of thousands, and no client wants all of them. Pagination was
handled in three ways in the Resource and Contract Design topic — offset-based,
cursor-based, and key-based — but the design there was built on address and query
parameters. In the query-based approach, pagination is a property not of an endpoint but
of a **field**, and it recurs in every nested list. The next lesson builds the
cursor-based connection model, decides where page information gets written, and shows
how pagination behaves in nested lists.

### Pagination Patterns

Source: https://academia.sh/en/courses/api-design/pagination-patterns

Building the cursor-based connection model, separating edge from node, the measured behavior difference between offset and cursor pagination while a list changes, and pagination multiplying across nested lists.

The previous lesson's query returned all fifty loan records in a single response. The
library's real loan count is in the hundreds of thousands, and no client wants all of
them.

Pagination was handled in three ways in the Resource and Contract Design topic, but the
design there was built on address and query parameters: what got paginated was an
endpoint. In the query-based approach, what gets paginated is a **field**, and it recurs
in every nested list. This lesson builds the standardized form of field-level pagination.

The parser, flattener, and engine are used exactly as they were written in the previous
lessons; this lesson adds connection types to the schema.

#### The Connection Model

A paginated field returns not a list but a **connection**. A connection has three parts.

- **Edges** hold a record for every item on the page, and each carries two things: the
  item itself (**node**) and a **cursor** marking that item's position in the list.
- **Page info** reports the navigation state: whether there is a page ahead or behind,
  and the page's start and end cursors.
- Fields specific to the connection, such as **total count**, are optional.

Separating edge from node looks like an unnecessary layer at first glance. The reason is
this: a cursor is not a property of the item, it is a property of **the item's position
in this particular list**. The same loan record gets different cursors in two separate
lists; if the cursor were placed inside the node, this would be impossible. That same
place can also carry other relationship-specific data — the moment the record was added
to the list, its ranking score — and none of these are fields of the loan record itself.

```js
// pagination.mjs — cursor-based pagination; the cursor carries the sort key
// Records are ordered by the (createdAt, id) pair; the cursor is that pair, encoded.
export const encodeCursor = (k) => Buffer.from(`${k.createdAt}|${k.id}`).toString("base64url");
export const decodeCursor = (i) => { const [createdAt, id] = Buffer.from(i, "base64url").toString().split("|"); return { createdAt, id }; };

const compare = (a, b) => (a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt.localeCompare(b.createdAt));

// Cursor-based: the first N records greater than the "after" cursor
export function cursorPage(records, { first = 10, after = null }) {
  const sorted = [...records].sort(compare);
  const start = after ? sorted.findIndex((k) => compare(k, decodeCursor(after)) > 0) : 0;
  const slice = start < 0 ? [] : sorted.slice(start, start + first);
  return {
    edges: slice.map((k) => ({ cursor: encodeCursor(k), node: k })),
    pageInfo: {
      hasNextPage: start >= 0 && start + first < sorted.length,
      hasPreviousPage: start > 0,
      startCursor: slice.length ? encodeCursor(slice[0]) : null,
      endCursor: slice.length ? encodeCursor(slice.at(-1)) : null,
    },
    totalCount: sorted.length,
  };
}

// Offset-based: start at the Nth record in sort order
export function offsetPage(records, { first = 10, offset = 0 }) {
  const sorted = [...records].sort(compare);
  return { records: sorted.slice(offset, offset + first), totalCount: sorted.length };
}
```

The cursor being encoded is not obfuscation, it is a **contract decision**. The client
should not decode the cursor, only send it back; the encoding makes visible that its
content is not part of the contract. When the sort key changes, the cursor's structure
changes with it, and this does not affect clients that never tried to parse the cursor.

#### While the List Changes

The difference between offset and cursor is invisible while the list is static. It shows
up when a record is inserted or deleted between pages.

```js
// drift.mjs — offset and cursor behavior when a record is inserted/deleted between pages
import { cursorPage, offsetPage } from "./pagination.mjs";

const RECORDS = Array.from({ length: 9 }, (_, i) =>
  ({ id: `O-${String(i + 1).padStart(2, "0")}`, createdAt: `2026-01-${String(i + 1).padStart(2, "0")}` }));
const PAGE_SIZE = 3;

const CHANGES = {
  "insert at start": (v) => v.push({ id: "O-00", createdAt: "2026-01-00" }),
  "delete from start": (v) => v.splice(v.findIndex((k) => k.id === "O-01"), 1),
};

const FORMATS = {
  offset: { start: { offset: 0 }, get: (v, d) => offsetPage(v, { first: PAGE_SIZE, offset: d.offset }),
            ids: (s) => s.records.map((k) => k.id), next: (s, d) => ({ offset: d.offset + PAGE_SIZE }) },
  cursor: { start: { after: null }, get: (v, d) => cursorPage(v, { first: PAGE_SIZE, after: d.after }),
            ids: (s) => s.edges.map((k) => k.node.id), next: (s) => ({ after: s.pageInfo.endCursor }) },
};

for (const [changeName, change] of Object.entries(CHANGES)) {
  console.log(`after reading the first page: ${changeName} (page size ${PAGE_SIZE}, starting with 9 records)`);
  for (const [formatName, f] of Object.entries(FORMATS)) {
    const data = [...RECORDS];
    const seen = [];
    let state = f.start;
    for (let page = 0; page < 4; page++) {
      const s = f.get(data, state);
      const ids = f.ids(s);
      if (!ids.length) break;
      seen.push(...ids);
      if (page === 0) change(data);
      state = f.next(s, state);
    }
    const unique = new Set(seen);
    const skipped = RECORDS.map((k) => k.id).filter((id) => !unique.has(id) && data.some((k) => k.id === id));
    console.log(`  ${formatName}: ${seen.join(" ")}`);
    console.log(`  ${formatName}: seen=${seen.length} unique=${unique.size} repeated=${seen.length - unique.size} skipped=${skipped.join(",") || "none"}`);
  }
  console.log();
}
```

```
after reading the first page: insert at start (page size 3, starting with 9 records)
  offset: O-01 O-02 O-03 O-03 O-04 O-05 O-06 O-07 O-08 O-09
  offset: seen=10 unique=9 repeated=1 skipped=none
  cursor: O-01 O-02 O-03 O-04 O-05 O-06 O-07 O-08 O-09
  cursor: seen=9 unique=9 repeated=0 skipped=none

after reading the first page: delete from start (page size 3, starting with 9 records)
  offset: O-01 O-02 O-03 O-05 O-06 O-07 O-08 O-09
  offset: seen=8 unique=8 repeated=0 skipped=O-04
  cursor: O-01 O-02 O-03 O-04 O-05 O-06 O-07 O-08 O-09
  cursor: seen=9 unique=9 repeated=0 skipped=none
```

Offset gets it wrong in two separate ways. When a record is inserted into the list,
everything shifts by one position, and the second page shows the first page's last
record again; `O-03` was seen twice. When a record is deleted, the shift runs the other
way and a record is never seen at all; `O-04` was skipped.

Cursor navigated correctly in both cases. The reason is that the question being asked is
different: offset asks "give me starting from the sixth record from the top," and it
points to the wrong place once the top moves; cursor asks "give me what comes after this
record," and it gets the right answer as long as that record stays put.

A skipped record costs more than a repeated one. A repeat is visible to the user and can
be corrected; a skip is silent. If an export job is paginated with offset, a file that
ends up missing a record produces no error at all.

#### Running the Connection

A connection can only be asked for if it is defined in the schema. Edge, page info, and
the connection itself are ordinary object types; there is nothing privileged about them.
The type record from the Schema and Type System lesson stays as it was, with an extension
placed on top that adds these three types and two fields that both return a connection.

```js
// schema-connection.mjs — the schema from lesson 01 with connection types added
import { SCHEMA } from "./schema.mjs";

const CONNECTION_FIELD = { type: "LoanConnection!", args: { first: "Int", after: "String" } };

export const SCHEMA_C = {
  ...SCHEMA,
  types: {
    ...SCHEMA.types,
    Boolean:        { kind: "scalar" },
    PageInfo:       { kind: "object", fields: { hasNextPage: "Boolean!", hasPreviousPage: "Boolean!",
                                                 startCursor: "String", endCursor: "String" } },
    LoanEdge:       { kind: "object", fields: { cursor: "String!", node: "Loan!" } },
    LoanConnection: { kind: "object", fields: { edges: "[LoanEdge!]!",
                                                 pageInfo: "PageInfo!", totalCount: "Int!" } },

    Member: { ...SCHEMA.types.Member,
              fields: { ...SCHEMA.types.Member.fields, loanConnection: CONNECTION_FIELD } },
    Query:  { ...SCHEMA.types.Query,
              fields: { ...SCHEMA.types.Query.fields, loanConnection: CONNECTION_FIELD } },
  },
};
```

The data source holds nine loan records and three members. The connection resolvers do
not paginate on their own; they hand the records to the cursor page function inside
`pagination.mjs` and return the resulting structure as is. No resolver has been written
for the edge, page info, or total count fields, because the returned structure already
carries these names.

```js
// data.mjs — nine loan records, three members, and connection resolvers
import { cursorPage } from "./pagination.mjs";

export const MEMBERS = new Map(Array.from({ length: 3 }, (_, i) =>
  [`U-${1001 + i}`, { id: `U-${1001 + i}`, createdAt: "2025-01-01", name: `Member ${i}` }]));

export const LOANS = Array.from({ length: 9 }, (_, i) => ({
  id: `O-${String(i + 1).padStart(2, "0")}`, createdAt: `2026-01-${String(i + 1).padStart(2, "0")}`,
  memberId: `U-${1001 + (i % 3)}`, status: i % 3 === 0 ? "CLOSED" : "OPEN", returnDate: "2026-04-01",
  items: [{ isbn: "978-0262033848", branch: "central" }],
}));

const paginate = (records, a) => cursorPage(records, { first: a.first ?? 10, after: a.after ?? null });

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    member: (_, a) => MEMBERS.get(a.code) ?? null,
    loanConnection: (_, a) => paginate(LOANS, a),
  },
  Member: { loanConnection: (u, a) => paginate(LOANS.filter((o) => o.memberId === u.id), a) },
  Loan: { member: (o) => MEMBERS.get(o.memberId) },
};
```

```js
// connection.mjs — runs the connection model; measures nested pagination
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA_C as SCHEMA } from "./schema-connection.mjs";
import { RESOLVERS } from "./data.mjs";

const PAGE = `query($first: Int!, $after: String) {
  loanConnection(first: $first, after: $after) {
    totalCount
    pageInfo { hasNextPage hasPreviousPage endCursor }
    edges { cursor node { id status } }
  }
}`;

// Walk every page by following the cursor
let after = null, page = 0, totalEdges = 0;
while (true) {
  const s = await execute(SCHEMA, RESOLVERS, parse(PAGE), { variables: { first: 4, after } });
  const b = s.data.loanConnection;
  totalEdges += b.edges.length;
  console.log(`page ${++page}: ${b.edges.map((k) => k.node.id).join(" ")}   next=${b.pageInfo.hasNextPage}  previous=${b.pageInfo.hasPreviousPage}`);
  if (!b.pageInfo.hasNextPage) { console.log(`total records=${b.totalCount}  edges walked=${totalEdges}`); break; }
  after = b.pageInfo.endCursor;
}

console.log(`\nfirst page's first edge: ${JSON.stringify((await execute(SCHEMA, RESOLVERS, parse(PAGE), { variables: { first: 1, after: null } })).data.loanConnection.edges[0])}`);

// Nested pagination: each member's own loan connection is paginated separately
const NESTED = `{
  m1: member(code: "U-1001") { name loanConnection(first: 2) { totalCount edges { node { id } } } }
  m2: member(code: "U-1002") { name loanConnection(first: 2) { totalCount edges { node { id } } } }
}`;
const nested = (await execute(SCHEMA, RESOLVERS, parse(NESTED))).data;
for (const [key, m] of Object.entries(nested)) {
  console.log(`${key} ${m.name}: ${m.loanConnection.edges.map((k) => k.node.id).join(" ")}  (total ${m.loanConnection.totalCount})`);
}
```

```
page 1: O-01 O-02 O-03 O-04   next=true  previous=false
page 2: O-05 O-06 O-07 O-08   next=true  previous=true
page 3: O-09   next=false  previous=true
total records=9  edges walked=9

first page's first edge: {"cursor":"MjAyNi0wMS0wMXxPLTAx","node":{"id":"O-01","status":"CLOSED"}}
m1 Member 0: O-01 O-04  (total 3)
m2 Member 1: O-02 O-05  (total 3)
```

Nine edges were walked across three pages; no repeats, no skips. The cursor value
appears in its encoded form, and it is clear from this that its content is not meant to
be read.

The last two lines show where query-based pagination diverges from resource-based
pagination. The two members' loan connections were paginated **separately**; each has
its own `totalCount` value, its own edges. In an endpoint design, this would mean two
separate requests; here, it is two branches of a single query.

The consequence is that pagination is no longer a single boundary. If `first: 20`
members are requested from the root field and each member's `first: 20` loans are
requested, the response carries four hundred loan records. Every field respects its own
limit, and yet the total comes out larger than expected even though all of them do.
Pagination alone does not bound the size of the response.

#### Summary

- A paginated field returns a connection, not a list; a connection is made up of edges,
  page info, and fields specific to the connection.
- A cursor is a property of the item's position in that list, not of the item; this is
  why the edge layer exists, and it can also carry other relationship-specific data.
- A cursor is kept encoded; this is not obfuscation, it is a decision that declares its
  content is not part of the contract.
- While a list changes, offset pagination produces repeats on insertion and skips on
  deletion; cursor pagination navigates correctly in both cases.
- A skipped record costs more than a repeated one, because a skip is silent and produces
  no error.
- Pagination applies per field; in nested lists, every field respects its own limit, but
  the total response size can grow regardless of the limits.

#### Next Step

The connection model organized which records the response would carry, but it kept one
assumption: every field asked for can be read, and every field returns a value. In the
library, that is not true. A member's fine information can be seen only by that member
and by clerks; a loan record's note can only be read at the branch where the record was
opened. A field being unauthorized does not require rejecting the whole query — the rest
of the response is still valid. The next lesson builds field-level authorization, shows
what a single field's crash touches in the response, and measures how partial data and
an error come back together in the same response.

### Error Handling and Authorization

Source: https://academia.sh/en/courses/api-design/error-handling-and-authorization

Collecting errors at the field level, an error returning in the same response as partial data, an authorization layer that wraps resolvers, and measuring how a gap propagates upward through required fields.

The connection model organized which records the response would carry, but it kept one
assumption: every field asked for can be read, and every field returns a value.

In the library, that is not true. A member's fine information can be seen only by that
member and by clerks; a loan record's note can only be read at the branch where the
record was opened. Some fields also come from other services, and those services can go
down. This lesson answers two questions together: what happens to the rest of the
response when a field crashes, and where does authorization checking get written?

The parser, flattener, and schema are used exactly as they were written in the previous
lessons; the engine is rewritten in this lesson to collect errors.

#### Errors at the Field Level

In a resource-based endpoint, an error covers the whole response: the status code is
either success or it is not. In the query-based approach, a single response carries
hundreds of fields, and one of them crashing does not invalidate the others. The response
is therefore two-part: `data` carries the fields that resolved, `errors` carries the ones
that could not. Both are present in the same response; this is called **partial
success**.

Two things are added to the engine. Every field call is wrapped in a `try` block, and a
field that crashes is written to the error list together with its path in the response.
The second is subtler: `null` cannot be written into a field marked `!` in the schema, so
when that field crashes, the gap **moves upward**.

```js
// executor.mjs — an engine that collects errors at the field level
// If a field crashes, it is written to the error list and the field becomes null. If the
// field is required with "!", null cannot be written; the gap rises to the nearest
// parent field that can hold null.
import { parseType, fieldDef } from "./schema.mjs";
import { flatten } from "./flatten.mjs";

const LEAF = new Set(["scalar", "enum"]);
class NonNullError extends Error {}                    // attempt to write null into a required field

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]));

async function executeSelection(o, typeName, selection, source, path, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection);
  const result = {};
  for (const f of fields) result[f.alias] = null;
  const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source, `${path}.${f.alias}`); };
  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, path) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const required = parseType(def.type).required;
  const resolver = o.resolvers[typeName]?.[field.name] ?? ((k) => k?.[field.name]);
  const info = { field: field.name, parentType: typeName, path };
  try {
    const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info);
    if ((value === null || value === undefined) && required) throw new NonNullError();
    return await complete(o, def.type, value, field.selection, path);
  } catch (h) {
    // A propagated null is not reported again; the real error is already in the list.
    if (!(h instanceof NonNullError)) o.errors.push({ message: h.text ?? h.message, code: h.code ?? "internal_error", path });
    if (required) throw new NonNullError();
    return null;
  }
}

async function complete(o, typeText, value, selection, path) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) {
    const itemRequired = /^\[[^\]]+!\]/.test(typeText);      // [Loan!] or [Loan]
    return Promise.all(value.map(async (d, i) => {
      try { return await complete(o, t.name, d, selection, `${path}[${i}]`); }
      catch (h) { if (h instanceof NonNullError && !itemRequired) return null; throw h; }
    }));
  }
  const type = o.schema.types[t.name];
  if (LEAF.has(type.kind)) return value;
  const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value);
  return executeSelection(o, concrete, selection, value, path);
}

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, errors: [] };
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  let data = null;
  try { data = await executeSelection(o, root, op.selection, null, "", op.type === "mutation"); }
  catch (h) { if (!(h instanceof NonNullError)) throw h; }      // null at the root: data becomes null
  return o.errors.length ? { data, errors: o.errors } : { data };
}
```

The error record's `path` field does the same job as the field-level validation errors
from the previous topic: it lets the client tie the error to its place in the response.
The difference is that here, the path points to the **response tree**, not the request
body.

#### Where Authorization Gets Written

Authorization checking could be written inside the resolver, but then it repeats in
every resolver, and whoever writes a new resolver forgetting it produces a silent leak.
The check is gathered into a separate layer that wraps the resolvers.

```js
// authorization.mjs — field-level access control; wraps resolvers
export class AuthorizationError extends Error {
  constructor(field) { super(`You do not have permission to see field "${field}".`); this.code = "unauthorized"; this.text = this.message; }
}

// rules: { Type: { field: (source, context) => boolean } }
export function applyAuthorization(resolvers, rules) {
  const wrapped = { ...resolvers };
  for (const [type, fields] of Object.entries(rules)) {
    wrapped[type] = { ...(resolvers[type] ?? {}) };
    for (const [field, allowed] of Object.entries(fields)) {
      const original = resolvers[type]?.[field] ?? ((k) => k?.[field]);
      wrapped[type][field] = (source, args, context, info) => {
        if (!allowed(source, context)) throw new AuthorizationError(`${type}.${field}`);
        return original(source, args, context, info);
      };
    }
  }
  return wrapped;
}
```

A rule has two inputs, and both are necessary. Context carries who is making the
request; source carries the record the decision is about. The rule "a member can see
their own fine" cannot be written without comparing the two. This also explains why
authorization checking stops at the field level: the decision cannot be made without
looking at the record itself, and the record is only on hand while that field's resolver
is being called.

Two fields to be protected are added to the schema. Both are written nullable, and this
is a requirement: an unauthorized field returns `null`, so no field that depends on
authorization can be marked `!`. The reason for this constraint will be measured at the
end of the lesson.

```js
// schema-authorization.mjs — the schema from lesson 01 with two protected fields added
import { SCHEMA } from "./schema.mjs";

export const SCHEMA_A = {
  ...SCHEMA,
  types: {
    ...SCHEMA.types,
    Member: { ...SCHEMA.types.Member, fields: { ...SCHEMA.types.Member.fields, fine: "Int" } },
    Loan:   { ...SCHEMA.types.Loan,   fields: { ...SCHEMA.types.Loan.fields,   note: "String" } },
  },
};
```

```js
// data.mjs — loan data and resolvers; the fine and note fields will be protected
export const MEMBERS = new Map([
  ["U-1001", { id: "U-1001", createdAt: "2024-02-11", name: "Alice Carter", fine: 0 }],
  ["U-1002", { id: "U-1002", createdAt: "2025-06-03", name: "Dana Reyes", fine: 12 }],
]);
export const LOANS = new Map([
  ["O-1", { id: "O-1", createdAt: "2026-01-04", memberId: "U-1001", status: "OPEN", returnDate: "2026-03-20",
            branch: "central", note: "Received with a damaged cover.", items: [{ isbn: "978-0262033848", branch: "central" }] }],
  ["O-2", { id: "O-2", createdAt: "2026-01-19", memberId: "U-1002", status: "OPEN", returnDate: "2026-04-02",
            branch: "shore", note: "Second extension granted.", items: [{ isbn: "978-0201896831", branch: "shore" }] }],
]);

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    loan: (_, a) => LOANS.get(a.id) ?? null,
    loans: () => [...LOANS.values()],
    member: (_, a) => MEMBERS.get(a.code) ?? null,
  },
  Loan: { member: (o) => MEMBERS.get(o.memberId) },
};
```

```js
// run.mjs — partial errors and field-level authorization
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA_A as SCHEMA } from "./schema-authorization.mjs";
import { RESOLVERS } from "./data.mjs";
import { applyAuthorization } from "./authorization.mjs";

// Rules: fine is seen only on your own record, note only at the branch that opened the record.
const RULES = {
  Member: { fine: (member, c) => c.user === member.id || c.role === "clerk" },
  Loan: { note: (loan, c) => c.branch === loan.branch },
};
const PROTECTED = applyAuthorization(RESOLVERS, RULES);

const QUERY = `{
  loans { id status note member { name fine } }
}`;

const CONTEXTS = {
  "member U-1001, at the central branch": { user: "U-1001", role: "member", branch: "central" },
  "clerk, at the shore branch":           { user: "P-7", role: "clerk", branch: "shore" },
};

for (const [name, context] of Object.entries(CONTEXTS)) {
  const s = await execute(SCHEMA, PROTECTED, parse(QUERY), { context });
  const fieldCount = JSON.stringify(s.data).match(/:/g).length;
  console.log(`\n=== ${name} ===`);
  console.log(`data: ${JSON.stringify(s.data)}`);
  console.log(`error count: ${(s.errors ?? []).length}   filled field count: ${fieldCount}`);
  for (const h of s.errors ?? []) console.log(`  ${h.code.padEnd(12)} ${h.path.padEnd(24)} ${h.message}`);
}
```

```
=== member U-1001, at the central branch ===
data: {"loans":[{"id":"O-1","status":"OPEN","note":"Received with a damaged cover.","member":{"name":"Alice Carter","fine":0}},{"id":"O-2","status":"OPEN","note":null,"member":{"name":"Dana Reyes","fine":null}}]}
error count: 2   filled field count: 13
  unauthorized .loans[1].note           You do not have permission to see field "Loan.note".
  unauthorized .loans[1].member.fine    You do not have permission to see field "Member.fine".

=== clerk, at the shore branch ===
data: {"loans":[{"id":"O-1","status":"OPEN","note":null,"member":{"name":"Alice Carter","fine":0}},{"id":"O-2","status":"OPEN","note":"Second extension granted.","member":{"name":"Dana Reyes","fine":12}}]}
error count: 1   filled field count: 13
  unauthorized .loans[0].note           You do not have permission to see field "Loan.note".
```

The same query produced two different responses in two different contexts. In both
cases the loan records came back, their ids and statuses were read; only the protected
fields stayed empty, and their reasons were reported in `errors`.

In the clerk's response, one of the `note` fields is empty: being a clerk is enough to
see a fine but not enough to see a note, because the note rule looks at branch, not
role. Two rules being able to rest on different criteria is what defining authorization
at the field level buys.

What the error message says is also worth noticing: it says the field **exists** but
does not give its value. If a field's existence itself needs to be hidden, authorization
is not enough; that field has to be removed from the schema or rejected during query
validation.

#### Null Propagation

A field crashing does not always stay confined to that field. The `!` marks in the
schema determine how far the gap propagates.

The measurement needs a field that presents the same loan list under two different
markings. The `loans` field is written `[Loan!]!`; a second root field is added to the
schema that returns the same records as `[Loan]`. Both give the same data; only their
markings differ.

```js
// schema-flexible.mjs — a root field presenting the same loan list with a nullable marking
import { SCHEMA_A } from "./schema-authorization.mjs";

export const SCHEMA_F = {
  ...SCHEMA_A,
  types: {
    ...SCHEMA_A.types,
    Query: { ...SCHEMA_A.types.Query,
             fields: { ...SCHEMA_A.types.Query.fields, flexibleLoans: "[Loan]" } },
  },
};
```

```js
// null-propagation.mjs — how a crash propagates under nullable and non-nullable markings
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { SCHEMA_F as SCHEMA } from "./schema-flexible.mjs";
import { LOANS, MEMBERS } from "./data.mjs";

const crashes = (field) => (o) => {
  if (o.id === "O-2") throw Object.assign(new Error("overdue service is not responding"), { code: "upstream" });
  return o[field];
};

const BASE = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: { loans: () => [...LOANS.values()], flexibleLoans: () => [...LOANS.values()] },
  Loan: { member: (o) => MEMBERS.get(o.memberId) },
};

const SCENARIOS = [
  ["returnDate crashes (Date — nullable)", "loans", { ...BASE, Loan: { ...BASE.Loan, returnDate: crashes("returnDate") } }],
  ["status crashes (inside [Loan!]!, Status!)", "loans", { ...BASE, Loan: { ...BASE.Loan, status: crashes("status") } }],
  ["status crashes (inside [Loan], Status!)", "flexibleLoans", { ...BASE, Loan: { ...BASE.Loan, status: crashes("status") } }],
];

for (const [name, root, resolvers] of SCENARIOS) {
  const s = await execute(SCHEMA, resolvers, parse(`{ ${root} { id status returnDate member { name } } }`));
  const text = JSON.stringify(s.data);
  console.log(`\n${name}`);
  console.log(`  data: ${text}`);
  console.log(`  lost: ${text === "null" ? "entire response" : (text.match(/null/g) ?? []).length + " field(s)"}   error: ${(s.errors ?? []).map((h) => `${h.path} (${h.code})`).join(", ") || "none"}`);
}
```

```
returnDate crashes (Date — nullable)
  data: {"loans":[{"id":"O-1","status":"OPEN","returnDate":"2026-03-20","member":{"name":"Alice Carter"}},{"id":"O-2","status":"OPEN","returnDate":null,"member":{"name":"Dana Reyes"}}]}
  lost: 1 field(s)   error: .loans[1].returnDate (upstream)

status crashes (inside [Loan!]!, Status!)
  data: null
  lost: entire response   error: .loans[1].status (upstream)

status crashes (inside [Loan], Status!)
  data: {"flexibleLoans":[{"id":"O-1","status":"OPEN","returnDate":"2026-03-20","member":{"name":"Alice Carter"}},null]}
  lost: 1 field(s)   error: .flexibleLoans[1].status (upstream)
```

The three lines show the same crash under three different schema markings.

When a nullable field crashes, only that field goes empty; the second record stays in
place. When a required field crashes, the gap moves upward and stops at the **nearest
level that can hold null**. Under the `[Loan!]!` marking, neither the item nor the list
can be null; the gap climbs all the way to the root field, and `data` becomes entirely
`null`. The same crash under the `[Loan]` marking stops at the second item, and the rest
of the response survives.

This shows that the `!` mark is a **design decision**. Requiredness makes the client's
job easier — it does not have to write a null check — but the price is that a single
field crashing can take down the entire response. The criterion is this: a field is
written required only if it truly cannot be null under any circumstance. Fields that
come from another service, that are computed, or that depend on authorization do not
meet this criterion.

#### Summary

- A query-based response is two-part: `data` carries the fields that resolved, `errors`
  carries the ones that could not, and both are present in the same response.
- The path in an error record shows its place in the response tree, and it lets the
  client tie the error to the right field.
- Authorization checking is written not inside resolvers but into a layer that wraps
  them; a rule has to see both who is making the request and the record the decision is
  about.
- The same query leaves different fields empty in different contexts; two rules can rest
  on different criteria, and this is what a field-level definition buys.
- An authorization error says the field exists but does not give its value; if the
  field's existence needs to be hidden, it has to be removed from the schema.
- A crash in a required field carries the gap up to the nearest nullable level; under
  `[Loan!]!` a single field takes down the entire response, under `[Loan]` it takes down
  only one item.

#### Next Step

Authorization bounded what a query could **see**, but not how much it could **ask for**.
The Pagination Patterns lesson left this open: even though every field respected its own
limit, the multiplication of nested lists could still grow the response. What is more,
the type system contains cycles — from a loan record to its member, from the member to
their loans, and from there back to a member again — and without a bound on query depth,
there is no way to stop execution. The next lesson computes query depth and cost before
running it, rejects a query that exceeds the bound, and measures where that bound should
be chosen from.

### Query Cost and Depth Limit

Source: https://academia.sh/en/courses/api-design/query-cost-and-depth-limit

Query depth growing without bound in a cyclic type system, a cost ceiling computed before running, and a measurement showing the depth limit alone is not enough.

Authorization bounded what a query could see, not how much it could ask for. The
Pagination Patterns lesson left this open: even though every field respected its own
limit, the multiplication of nested lists could still grow the response.

The real problem is that the type system is **cyclic**. From a loan record you can go to
its member, from the member to their loans, and from there back to a member again. In
the schema this cycle is a bidirectional relationship spelled out in one line each way;
in a query it can be expanded to any requested depth. This lesson measures the query
before running it and rejects the one that exceeds the bound.

This lesson's parser, flattener, and engine are used exactly as they were written in the
previous lessons; a page-size argument is added to the schema, and the measurement layer
is what is new. The data source holds three members and six loan records.

```js
// data.mjs — 3 members, 6 loans; the type system is cyclic, so depth can be walked without limit
export const MEMBERS = new Map(Array.from({ length: 3 }, (_, i) =>
  [`U-${1001 + i}`, { id: `U-${1001 + i}`, createdAt: "2025-01-01", name: `Member ${i}`, fine: i * 5 }]));

export const LOANS = Array.from({ length: 6 }, (_, i) => ({
  id: `O-${i + 1}`, createdAt: `2026-01-0${i + 1}`, memberId: `U-${1001 + (i % 3)}`,
  status: "OPEN", returnDate: "2026-04-01", branch: "central", note: null,
  items: [{ isbn: "978-0262033848", branch: "central" }],
}));

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member") },
  Query: {
    loan: (_, a) => LOANS.find((o) => o.id === a.id) ?? null,
    loans: (_, a) => LOANS.slice(0, a.first ?? LOANS.length),
    member: (_, a) => MEMBERS.get(a.code) ?? null,
  },
  Loan: { member: (o) => MEMBERS.get(o.memberId) },
  Member: { loans: (u, a) => LOANS.filter((o) => o.memberId === u.id).slice(0, a.first ?? LOANS.length) },
};
```

The measurement reads how many records a list field will return from its `first`
argument. If the argument is not declared in the schema, a query using it cannot pass
validation; so both list fields are extended with a page-size argument. The argument
being **optional** already sets up the question this lesson will discuss at the end:
what does the measurement rest on when it is not supplied?

```js
// schema-list.mjs — the schema with a page-size argument added to list fields
import { SCHEMA } from "./schema.mjs";

const LIST_FIELD = { type: "[Loan!]!", args: { status: "Status", first: "Int" } };

export const SCHEMA_L = {
  ...SCHEMA,
  types: {
    ...SCHEMA.types,
    Member: { ...SCHEMA.types.Member, fields: { ...SCHEMA.types.Member.fields, loans: LIST_FIELD } },
    Query:  { ...SCHEMA.types.Query,  fields: { ...SCHEMA.types.Query.fields,  loans: LIST_FIELD } },
  },
};
```

#### Computing the Cost

A query's cost can be computed before it runs, from just the document and the schema.
The model is simple: the node count a field will produce is the product of the
multiplier coming from the parent level and its own multiplier. A list field's
multiplier is read from the `first` argument; if the argument is not given, a default
the schema anticipates is used.

```js
// cost.mjs — computes a query's depth and cost before running it
// Cost model: the node count a field produces = parent multiplier × its own multiplier.
// A list field's multiplier comes from the "first" argument, or the default otherwise.
import { parseType, fieldDef } from "./schema.mjs";
import { flatten } from "./flatten.mjs";

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

export function measure(schema, document, op, { defaultMultiplier = 10, variables = {} } = {}) {
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  const readMultiplier = (a) => {
    const d = a.args.first;
    if (!d) return null;
    return d.kind === "variable" ? variables[d.name] : d.value;
  };

  let deepest = 0, cost = 0;
  const walk = (typeName, selection, depth, parentMultiplier) => {
    deepest = Math.max(deepest, depth);
    for (const a of flatten(schema, document, typeName, selection)) {
      const def = fieldDef(schema.types[typeName], a.name);
      if (!def) continue;
      const t = parseType(def.type);
      const multiplier = parentMultiplier * (t.list ? (readMultiplier(a) ?? defaultMultiplier) : 1);
      cost += multiplier;
      if (!LEAF.has(schema.types[t.name].kind)) walk(t.name, a.selection, depth + 1, multiplier);
    }
  };
  walk(root, op.selection, 1, 1);
  return { depth: deepest, cost };
}

// A query that exceeds the limits is rejected without running.
export function checkLimits(measurement, { maxDepth, maxCost }) {
  const errors = [];
  if (measurement.depth > maxDepth) errors.push(`depth ${measurement.depth}, limit ${maxDepth}`);
  if (measurement.cost > maxCost) errors.push(`cost ${measurement.cost}, limit ${maxCost}`);
  return errors;
}
```

It is worth noticing that the fields the computation walks over come through the
**flattener**: a depth hidden inside a fragment does enter the measurement. If the
measurement were a counter that only looked at the literally written selection set, the
query could be split into fragments to bypass the limit.

#### Measurement

```js
// run.mjs — measures queries' depth and cost, applies limits, tests the estimate
import { parse } from "./parser.mjs";
import { execute } from "./executor.mjs";
import { measure, checkLimits } from "./cost.mjs";
import { SCHEMA_L as SCHEMA } from "./schema-list.mjs";
import { RESOLVERS } from "./data.mjs";

const LIMITS = { maxDepth: 6, maxCost: 1000 };

// Cyclic type system: loan -> member -> loans -> member -> ...
const cycle = (layers) => {
  let inner = "id";
  for (let i = 0; i < layers; i++) inner = `id member { name loans { ${inner} } }`;
  return `{ loans { ${inner} } }`;
};

const QUERIES = {
  "ordinary query": `{ loans { id status member { name } } }`,
  "paged query": `{ loans { id member { name loans { id } } } }`,
  "cycle ×2": cycle(2),
  "cycle ×5": cycle(5),
  "shallow but wide": `{ a: loans(first: 500) { id member { name } } b: loans(first: 500) { id member { name } }
                        c: loans(first: 500) { id member { name } } d: loans(first: 500) { id member { name } } }`,
  "limited query": `{ loans(first: 6) { id status member { name } } }`,
};

// Leaf value count in the response: the real number the estimate is tested against
const countLeaves = (d) =>
  d === null || typeof d !== "object" ? 1 : Array.isArray(d) ? d.reduce((t, x) => t + countLeaves(x), 0)
    : Object.values(d).reduce((t, x) => t + countLeaves(x), 0);

console.log("query             depth  cost  verdict     leaves in response");
for (const [name, text] of Object.entries(QUERIES)) {
  const document = parse(text);
  const measurement = measure(SCHEMA, document, document.operations[0], { defaultMultiplier: 6 });
  const errors = checkLimits(measurement, LIMITS);
  let leaves = "—";
  if (!errors.length) leaves = String(countLeaves((await execute(SCHEMA, RESOLVERS, document)).data));
  console.log(`${name.padEnd(17)} ${String(measurement.depth).padStart(8)}  ${String(measurement.cost).padStart(7)}  ` +
    `${(errors.length ? "REJECT" : "accept").padEnd(9)}  ${leaves.padStart(15)}${errors.length ? "   (" + errors.join("; ") + ")" : ""}`);
}
```

```
query             depth  cost  verdict     leaves in response
ordinary query           3       30  accept                  18
paged query              4       96  accept                  24
cycle ×2                 6      600  accept                  60
cycle ×5                12   130632  REJECT                   —   (depth 12, limit 6; cost 130632, limit 1000)
shallow but wide         3     8000  REJECT                   —   (cost 8000, limit 1000)
limited query            3       30  accept                  18
```

#### What the Three Lines Say

**The cycle ×5 line** shows the size of the problem. A cycle expanded five layers deep
reaches a depth of twelve and a cost past one hundred thirty thousand. This query is
shorter than twenty lines and takes a minute to write; if it were run, it would keep the
processor busy for a long time even against a data source with six loan records.
Against real library data, the result is memory exhaustion.

**The shallow-but-wide line** shows why the depth limit is not enough. This query's
depth is three, well under the limit of six; but because each of its four root fields
asks for five hundred records, its cost climbs to eight thousand. A protection that only
looked at depth would let this query through. **Depth is not a shield, it is a ceiling
measure; cost draws the real boundary.**

**The limited-query line** shows the estimate's character. When `first: 6` is given, the
cost computes to thirty, and eighteen leaves come back in the response. The estimate
sits above reality, and it must: cost also counts composite fields, the real leaf count
only the tips. If an accept decision is going to rest on an estimate, the estimate has to
be an **upper bound**; an estimate that falls short lets through queries that should not
pass.

#### Where the Limit Should Be Chosen From

Both numbers look arbitrary. The basis for choosing them is measurement.

The cost limit is derived from the load the server can afford: the relationship between
the node count a request produces and the time it spends is measured, and the node count
that corresponds to the longest acceptable response time becomes the limit. The depth
limit, on the other hand, is derived from the deepest query real clients actually use;
the distribution of query depths in the logs is examined, and a value above real usage is
chosen.

The default multiplier is the third and most critical number. There is no way to know
how many records a list field will return when its `first` argument is not given, so the
estimate rests on an assumption. There are two fixes: make the `first` argument
**required** on list fields, or declare a per-field upper bound in the schema. If
neither is done, the default multiplier remains a guess, and the real response can
exceed the estimate — which breaks the basic assumption behind cost protection.

How the rejection gets reported is a separate decision. Because the query never ran,
there is no partial data; the response carries only the error. The error has to report
the measured values and the limits, because what the client needs to do is shrink its
query, and it needs to know what to shrink. An error that just says "query too complex"
leaves the developer to trial and error.

#### Summary

- A cyclic type system places no bound on query depth by itself; a short query can be
  expanded to arbitrary depth.
- Cost is computed before running, from just the document and the schema: the node count
  a field produces is the product of the parent multiplier and its own multiplier.
- The measurement happens after fragments are opened; otherwise the query could be split
  into fragments to bypass the limit.
- The depth limit alone is not enough: a query with depth three can multiply its root
  fields to push the cost eight times past the limit.
- The cost estimate must sit above the real leaf count; an estimate that falls short lets
  through queries that should not pass.
- For the default multiplier to be trustworthy, `first` must be required on list fields
  or a per-field upper bound declared in the schema; a rejection must report the
  measured value and the limit.

#### Next Step

Everything written up to this point ran inside a single process: the query was a string,
the result an object. A real service receives the query over the network and returns the
response over the network. This is not just a wrapping job. A transport that uses a
single endpoint and, mostly, a single method requires rethinking everything the previous
topic built — address-based caching, status code mapping, the information intermediaries
see. The next lesson puts GraphQL on top of HTTP and measures what this transport
decision does to caching, error reporting, and logging.

### GraphQL over HTTP

Source: https://academia.sh/en/courses/api-design/graphql-over-http

Transport over a single endpoint, the caching difference between POST and GET, persisted documents, and measuring the information a single address leaves for intermediaries and logs.

Everything written up to this point ran inside a single process: the query was a
string, the result an object. A real service receives the query over the network and
returns the response over the network.

This is not just a wrapping job. A transport that uses a single endpoint and, mostly, a
single method requires rethinking everything the previous topic built: address-based
caching, status code mapping, the information intermediaries and logs see. This lesson
puts the engine on top of HTTP and measures what this decision takes away and what it
brings.

#### The Server

The server supports three transport formats. `POST` carries the query, the variables,
and the operation name in its body. `GET` carries the same three in query parameters.
The third carries, instead of the query text, the identifier of a **persisted
document**.

The parser, validator, schema, cost meter, and engine written in the previous lessons
are used as they are; the server is a thin layer that calls them in sequence.

```js
// server.mjs — puts GraphQL on top of HTTP
//   POST /graphql             { query, variables, operationName } in the body
//   GET  /graphql?query=...   the query in the address; cacheable
//   GET  /graphql?documentId=... a persisted document; short, and bounded by an allowlist
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { parse } from "./parser.mjs";
import { validate } from "./validate.mjs";
import { execute } from "./executor.mjs";
import { measure, checkLimits } from "./cost.mjs";
import { SCHEMA_L as SCHEMA } from "./schema-list.mjs";
import { RESOLVERS } from "./data.mjs";

const LIMITS = { maxDepth: 6, maxCost: 1000 };
export const hashDocument = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12);

// Persisted documents: the client sends only its id.
export const PERSISTED = new Map([
  `query LoanList { loans(first: 6) { id status } }`,
  `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`,
].map((m) => [hashDocument(m), m]));

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

const respond = (response, code, body, extraHeaders = {}) => {
  response.writeHead(code, { "content-type": "application/graphql-response+json; charset=utf-8", ...extraHeaders });
  response.end(JSON.stringify(body));
};

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, "http://local");
  if (url.pathname !== "/graphql") return respond(response, 404, { errors: [{ message: "path not found", code: "path_not_found" }] });

  let text, variables = {}, operationName;
  if (request.method === "POST") {
    const g = JSON.parse((await readBody(request)) || "{}");
    ({ query: text, variables: variables = {}, operationName } = g);
  } else {
    const documentId = url.searchParams.get("documentId");
    text = documentId ? PERSISTED.get(documentId) : url.searchParams.get("query");
    variables = JSON.parse(url.searchParams.get("variables") ?? "{}");
    operationName = url.searchParams.get("operationName") ?? undefined;
    if (documentId && !text) return respond(response, 404, { errors: [{ message: "no persisted document", code: "document_not_found" }] });
  }

  let document;
  try { document = parse(text ?? ""); }
  catch (h) { return respond(response, 400, { errors: [{ message: h.message, code: "parsing" }] }); }

  const validationErrors = validate(SCHEMA, document);
  if (validationErrors.length) return respond(response, 400, { errors: validationErrors.map((i) => ({ message: i, code: "validation" })) });

  const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0];
  const measurement = measure(SCHEMA, document, op, { defaultMultiplier: 6, variables });
  const violations = checkLimits(measurement, LIMITS);
  if (violations.length) return respond(response, 400, { errors: violations.map((i) => ({ message: i, code: "limit" })) });

  const result = await execute(SCHEMA, RESOLVERS, document, { operationName, variables });
  // Field-level errors are partial success: the request was fulfilled, 200 comes back.
  const headers = request.method === "GET" ? { "cache-control": "public, max-age=30" } : { "cache-control": "no-store" };
  respond(response, 200, result, { ...headers, "graphql-operation": op.name ?? "unnamed" });
}).listen(8441, "127.0.0.1", () => {
  console.log("graphql server 127.0.0.1:8441");
  for (const [k, m] of PERSISTED) console.log(`persisted ${k} ${/query (\w+)/.exec(m)[1]}`);
});
```

```bash
#!/usr/bin/env bash
# Three transport formats and two error cases
node server.mjs > /tmp/startup & s=$!
sleep 0.6
cat /tmp/startup
DOCUMENT_ID=$(grep 'LoanDetail' /tmp/startup | cut -d' ' -f2)

show() { printf -- '--- %s ---\n' "$1"; shift; curl -sS -D - -o /tmp/g "$@" | grep -iE '^HTTP|^cache-control|^graphql-operation' | tr -d '\r'; cat /tmp/g; echo; }

show "POST (query in body)" -X POST -H 'content-type: application/json' \
  -d '{"query":"query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }","variables":{"id":"O-2"}}' \
  http://127.0.0.1:8441/graphql

show "GET (query in address)" --get --data-urlencode 'query=query LoanList { loans(first: 2) { id status } }' \
  http://127.0.0.1:8441/graphql

show "GET (persisted document)" "http://127.0.0.1:8441/graphql?documentId=$DOCUMENT_ID&variables=%7B%22id%22%3A%22O-1%22%7D"

show "field not in schema" -X POST -H 'content-type: application/json' \
  -d '{"query":"{ loan(id: \"O-1\") { overdueFine } }"}' http://127.0.0.1:8441/graphql

show "limit exceeded" -X POST -H 'content-type: application/json' \
  -d '{"query":"{ loans(first: 500) { id member { name loans(first: 500) { id } } } }"}' http://127.0.0.1:8441/graphql

kill "$s"; wait "$s" 2>/dev/null
```

```
graphql server 127.0.0.1:8441
persisted 632f08670d3d LoanList
persisted 3b33f0f1112e LoanDetail
--- POST (query in body) ---
HTTP/1.1 200 OK
cache-control: no-store
graphql-operation: LoanDetail
{"data":{"loan":{"id":"O-2","status":"OPEN","member":{"name":"Member 1"}}}}
--- GET (query in address) ---
HTTP/1.1 200 OK
cache-control: public, max-age=30
graphql-operation: LoanList
{"data":{"loans":[{"id":"O-1","status":"OPEN"},{"id":"O-2","status":"OPEN"}]}}
--- GET (persisted document) ---
HTTP/1.1 200 OK
cache-control: public, max-age=30
graphql-operation: LoanDetail
{"data":{"loan":{"id":"O-1","status":"OPEN","member":{"name":"Member 0"}}}}
--- field not in schema ---
HTTP/1.1 400 Bad Request
{"errors":[{"message":"Query.loan: type Loan has no field \"overdueFine\"","code":"validation"}]}
--- limit exceeded ---
HTTP/1.1 400 Bad Request
{"errors":[{"message":"cost 502000, limit 1000","code":"limit"}]}
```

Because document identifiers are derived from a hash function, they do not change from
machine to machine, but the identifier changes when the document text changes.

#### What the Status Code Means

The status codes of the server in the measurement fall into two groups, and the split is
not arbitrary.

**The cases that return 400** are the cases where the query never ran at all: the
document could not be parsed, does not match the schema, or exceeds the limit. The
request was not fulfilled; what the client sent is at fault, and retrying does not help.

**The cases that return 200** are the cases where the query ran — even if a field-level
error is present. This is the partial success built in the previous lesson: a field
crashing does not mean the request was not fulfilled, because the rest of the response
is valid. A response can carry both `data` and `errors`, and the status code cannot
report this.

The split is the same as the 400-versus-422 split from the previous topic: a request
that could not be understood and a request that was understood and partially fulfilled
are different things. The difference here is that the second group is not counted as a
failure.

The **GraphQL over HTTP** specification draws the same split more finely and spreads it
over three codes: a document that cannot be parsed is reported with `400`, one that fails
validation with `422`, and a response carrying both `data` and `errors` with `294`. The
server above folds validation and limit failures into `400` as well and returns `200` on
partial success; the measurement should be read with that simplification in mind. The
split itself — a request that never ran versus one that ran and was partially fulfilled —
is the same in both mappings.

#### What Intermediaries and Logs See

The real cost of the single-endpoint decision is that the request's distinguishing
information moves from the address into the body. A cache or access log along the path
recognizes a request only by method and address.

```js
// intermediary.mjs — an intermediary on the path recognizes a request only by method+address.
// If the same workload is sent through four transport formats, how much does what the
// intermediary sees change?
import { createHash } from "node:crypto";
const hashDocument = (m) => createHash("sha256").update(m).digest("hex").slice(0, 12);

const OPERATIONS = [
  { name: "LoanList", path: "/loans", query: `query LoanList { loans(first: 6) { id status } }`, variables: {} },
  { name: "LoanDetail", path: "/loans/O-1", query: `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: { id: "O-1" } },
  { name: "LoanDetail", path: "/loans/O-2", query: `query LoanDetail($id: ID!) { loan(id: $id) { id status member { name } } }`, variables: { id: "O-2" } },
  { name: "MemberSummary", path: "/members/U-1001", query: `query MemberSummary($k: ID!) { member(code: $k) { name fine } }`, variables: { k: "U-1001" } },
];
const WORKLOAD = Array.from({ length: 12 }, (_, i) => OPERATIONS[i % OPERATIONS.length]);

const enc = encodeURIComponent;
const FORMATS = {
  "resource-based (GET)":  (o) => ["GET", o.path],
  "GraphQL POST":          () => ["POST", "/graphql"],
  "GraphQL GET (query)":   (o) => ["GET", `/graphql?query=${enc(o.query)}&variables=${enc(JSON.stringify(o.variables))}`],
  "GraphQL GET (persisted)": (o) => ["GET", `/graphql?documentId=${hashDocument(o.query)}&variables=${enc(JSON.stringify(o.variables))}`],
};

console.log("transport                requests  cache keys  from cache  distinct operations  avg. address");
for (const [name, build] of Object.entries(FORMATS)) {
  const keys = WORKLOAD.map((o) => build(o).join(" "));
  const unique = new Set(keys);
  // An intermediary can only cache GET requests.
  const cacheable = keys[0].startsWith("GET") ? WORKLOAD.length - unique.size : 0;
  const operationGroups = new Set(keys.map((a) => a.split("?")[0] + (a.includes("query=") || a.includes("documentId=") ? a.split("&")[0].split("?")[1] : "")));
  const avgLength = Math.round(keys.reduce((t, a) => t + a.length, 0) / keys.length);
  console.log(`${name.padEnd(25)} ${String(WORKLOAD.length).padStart(5)}  ${String(unique.size).padStart(17)}  ${String(cacheable).padStart(11)}  ${String(operationGroups.size).padStart(18)}  ${String(avgLength).padStart(10)}`);
}
```

```
transport                requests  cache keys  from cache  distinct operations  avg. address
resource-based (GET)         12                  4            8                   4          14
GraphQL POST                 12                  1            0                   1          13
GraphQL GET (query)          12                  4            8                   3         156
GraphQL GET (persisted)      12                  4            8                   3          69
```

**The POST line** shows the cost of the single endpoint: all twelve requests fall onto a
single cache key, none of them can be served from cache, and the access log sees the
twelve requests as a single row type. To an intermediary, every request looks the same.

**The GET lines** win this back. When the query moves to the address, the cache-key
count matches resource-based transport, and eight of the twelve requests become
servable from cache. The cost is address length: 156 characters on average, and real
queries are far longer than this. Persisted documents bring this cost down to 69
characters.

The 4-versus-3 difference in the distinct-operations column is not a shortfall, it is a
**different grouping**. A resource-based log separates requests by **resource**; a
GraphQL log, by **operation**. When `LoanDetail` is called with two different ids, it is
a single row in the second grouping. Which one is useful depends on the question being
asked: "which resource gets read the most" wants the first, "which screen is slow" wants
the second.

Persisted documents have a second consequence too. Because the server only runs the
documents it has registered, an arbitrary query the client sends is never even parsed.
Part of what the cost limit protects is thereby protected on its own: an unknown
document is rejected without running.

#### Summary

- A GraphQL request can be carried in three ways: POST with the query in the body, GET
  with the query in the address, and GET with a persisted document identifier.
- In the server measured here, requests that never run (parsing, validation, limit) return
  400 and requests that run return 200 even if they contain a field-level error; the
  specification spreads the same split over `400` · `422` · `294`. Partial success counts as
  failure in neither mapping.
- POST requests to a single endpoint fall onto a single cache key; none of the twelve
  requests can be served from cache, and all of them show up as one row type in the log.
- Moving the query to the address matches the cache-key count with resource-based
  transport; the cost is an address length that climbs into the hundreds of characters.
- Persisted documents cut the address down to a fraction of that and ensure the server
  only runs allowed documents.
- A resource-based log groups by resource, a GraphQL log by operation; the two answer
  different questions.

#### Course Wrap-Up

This course took up the design of an interface in four stages, and used the same
criterion at every stage: can a decision's payoff be measured?

**API Styles** compared the resource-based, remote-procedure-call-based, and
query-based approaches. The difference between them is not a ranking of superiority, it
is which side decides how much: in resource-based design the server decides the
response's shape, in query-based design the client does.

**Resource and Contract Design** carried the resource-based approach all the way
through. Resource modeling, address layout, method selection, status code mapping, body
naming, pagination, filtering, partial response, idempotency keys, and connection-based
responses — all of it served a single question: the client should not have to guess what
the server will do.

**Errors, Versions and Documentation** built the contract's shape over time. The error
body was fixed to a single format, validation errors were reported at the field level,
the real difference between versioning styles was measured to lie in intermediaries,
breaking-ness was defined with opposite rules in the request and response directions,
and the version number was derived from the schema difference. Then the contract became
machine-readable: a validator, documentation, contract tests, and a mock server were all
generated from the same definition. This topic's lasting takeaway fits in one sentence —
**a contract is a contract only if it can be validated where it is written.**

**GraphQL in Detail** told the query-based approach by measuring it, and did so without
reaching for a library, by writing its own parser and engine. The type system validated
the query before it ran; the three operation types' execution rule was measured on a
single-copy book; resolver calls were separated from data-source round trips, and the
N+1 problem was brought down from 101 round trips to 3; fragments were shown to
disappear at run time, and variables were shown to fix the distinct-document count;
cursor pagination's advantage over offset was measured while the list changed;
field-level authorization and partial errors were built; the required mark was shown
able to spread one field's crash across the entire response; and why the cost limit is
more fundamental than the depth limit was put down with a number.

The two approaches' shared lesson is visible here too. In resource-based design, the
server guesses what the consumer wants and sends too much or too little data; in
query-based design, the consumer gets exactly what it asked for, but the server cannot
know in advance how much work it will do. The first is a cost paid at design time, the
second at run time. Contract tests make the first measurable, the cost limit makes the
second measurable.

One question kept being deferred throughout this course. We wrote that only the branch
where a loan record was opened should see its note, but we never asked how we would know
the requester really is that branch's clerk; the authorization rules read the `user` and
`role` fields on the context, and how those fields got there was left open. The consumer
identifier arriving as a header in the deprecation telemetry was another view of the same
gap — a client declaring its own identity is not authentication.

The **Authentication and Authorization** course fills this gap. It separates how an
identity is proven from what that identity is permitted to do; it builds the secure
implementation of password and token storage; and it compares the measurable
consequences of defining an authorization model on roles, attributes, or relationships.
When the authorization rules we wrote in this course move there, how the context gets
filled will no longer be an assumption.

