Lesson 08 / 34
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.
Contents
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.
// 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"));
// 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.
# 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.
-- 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);
// 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"));
# 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.
# 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.