Lesson 02 / 34
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.
Contents
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.
// data.mjs — the shared library data all three styles run on export const MEMBERS = [{ id: 1, name: "Alice Kane", email: "[email protected]", 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.
// 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.
// 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.
// 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();
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.