Resource Modeling and REST Semantics
A route table can look tidy and still ship a bad contract. Here is one that does:
POST /createOrder
GET /order/:id
POST /orders/:id/cancel
GET /userOrders/:userId
POST /accounts/:accountId/order/:orderId/retryEvery line maps to working Node code. The handlers parse JSON, hit a database, and send back valid HTTP responses. Chapter 10 already went through the request object, the response object, route matching, and the HTTP semantics underneath all of it, so the machinery is not the issue here.
The problem is what the table tells a client, which is almost nothing useful. The URLs are really handler names written out as paths. The nouns flip between singular and plural with no rule. Cancellation appears as a verb stuck onto an order path, retry sits buried under an account, and order data has two different parents depending on which line you read. A client can call all five of these and still have no idea how the system behind them is laid out.
A few terms are about to do real work, so let me pin them down first. An API is the interface one program exposes for another program to call. For a Node service, that interface is the HTTP surface: request targets, methods, headers, bodies, response statuses, and response bodies. The surface is the full set of those visible choices. The contract is the narrower part that clients actually depend on from one deploy to the next, things like the route names, the inputs you accept, the fields in a representation, how statuses behave, and what you promise to keep stable.
That gap between surface and contract is the whole game, because changing a Node handler is cheap and changing a contract clients already depend on is not.
Here is the same domain with a resource model underneath it:
GET /orders
POST /orders
GET /orders/:orderId
PATCH /orders/:orderId
POST /orders/:orderId/cancellationsStill plain HTTP. What changed is the model you are exposing. Now orders reads as a collection, :orderId picks out one item, and cancellation has turned into its own subresource because it has a request, a result, and probably a stored record. A client can read this surface without knowing or caring which handler function runs.
This is what resource-oriented API design comes down to. You put the thing being addressed in the URL, you let the method say which protocol operation runs against it, and the response carries the state plus a status code that reports how it went. Each of those four pieces does one job, and the client can read all of them off the wire.
The Contract Starts At The Surface
You have a contract before you write a single schema file. The moment a route exists, it is already making promises.
GET /orders/ord_123 promises a few things just by existing. It says /orders/ord_123 is stable enough to call and will keep meaning the same thing. It says GET hands back a representation instead of changing something. It says a success response has a structure the client can parse, and that the common failures land on statuses the client can branch on.
You can document all of that later with OpenAPI. You can validate pieces of it later with JSON Schema. A framework registers the actual route later still. None of those is the contract. The contract exists the second you expose a method, a route pattern, and a payload structure, whether or not anything is written down.
People throw the word endpoint around loosely, so here is the tight version. An endpoint is one callable operation on the surface, a method plus a route pattern plus the request and response behavior that goes with it. GET /orders/:orderId is an endpoint. POST /orders is a different endpoint, even though both live on the orders collection.
Clients reach an endpoint through docs, SDKs, generated code, hand-written browser calls, tests, and live production traffic. Whatever handler structure you built sits behind all of that, out of view.
routes.post('/orders', createOrder);
routes.get('/orders/:orderId', showOrder);
routes.post('/orders/:orderId/cancellations', cancelOrder);That snippet uses a framework style, but the framework is beside the point. What the public sees is the method and the route pattern. The handler names stay private. The contract lives at the seam where an incoming HTTP request turns into behavior the client can observe.
Route names harden into compatibility constraints fast, especially when they copy your handler names. /createOrder advertises an implementation action. /orders advertises a collection and lets the method carry the verb, and it keeps doing that even when the handler behind it literally calls createOrder().
Clients see names, fields, and statuses, and they bake assumptions about all three into their own code. Your handler stack never leaves the service.
Here is what the surface actually includes:
methods
route patterns
path parameter names
query parameter names
request body structures
response body structures
status behavior
header behaviorAny one of these can turn into something you cannot easily change later. A mobile client might hardcode orderId as a field name. Maybe a frontend bundle branches on a 409. An internal SDK could generate a cancelOrder() method straight from the route description, and a third-party integration might read the Location header off a creation response and store it. Once any of that ships, you own it.
The contract also includes the things you left out. A DELETE /orders/:orderId that answers 405 is telling clients deletion is not supported. When GET /orders accepts status=open, clients learn status is a query parameter they can count on. A response that never includes internalCost teaches clients that field is not part of the representation. Change any of that later, whether you add a field, drop one, or alter how it behaves, and every client inherits the work.
You can see this concretely in a Node route table. The request listener, or the framework adapter, gets req.method, req.url, the headers, and a body stream. Routing code picks a handler. The handler builds a response. From where the client stands, that entire internal stack collapses down to one endpoint contract.
Because of that collapse, the name you expose has to describe the resource and the operation the way the protocol presents them, while the function name stays local to whatever the implementation happens to be today.
Do your first design review at this level, before any handler exists. Write the surface out as plain HTTP:
GET /orders
POST /orders
GET /orders/:orderIdA table like that gives the team something concrete to argue over, and it keeps the argument out of framework syntax. A route decorator, an Express call, a Fastify schema, or a raw matcher can all register this exact surface. So the surface, not the framework code, is what you are really deciding on.
Add the request and response structures next:
POST /orders
request: order creation command
response: order representation
status: 201 with LocationThe words are deliberately plain, because plain words force a decision before helper names can hide it. The order creation command is the input; the order representation is what comes back; the 201 with Location says a new resource now exists and the client is being handed its identifier.
A route with a fuzzy resource model usually gives itself away during this pass:
POST /retryOrderPayment
request: order id plus payment options
response: maybe an order, maybe an error
status: usually 200Look at what each line is doing. The name describes an action. The body has to carry the target identifier because the path does not. The response type is vague. The status line is barely contributing. You can absolutely implement this, but every client now has to learn a private convention to use it.
Resource modeling slots those same pieces into stable positions:
POST /orders/:orderId/payment-attempts
request: payment attempt creation command
response: payment attempt representation
status: 201 with LocationNow the target order lives in the path. The thing being created has a name. The response comes back as a representation, and the client knows exactly where to look next.
Resource Boundaries
Every route you will write sits on top of a decision you make first, the resource model. That is the named set of addressable concepts your API puts on offer. Picking it settles which parts of the backend become HTTP targets. It also settles which identifiers clients get to hold, and which operations live on each target.
So what counts as a resource? It is any concept with its own identity that you let clients address. An order counts. An account counts. A user's order history can count too, once you decide to expose it that way. Plenty of internal things never become resources at all. A nightly job row stays behind a handler, and so does an internal lock or a database join table.
This is your external model, the API's own view of the world rather than the database's. Sometimes it lines up with tables one for one. Other times it folds several tables into a single representation, or exposes a computed view that has its own public identity even though no single table backs it.
Start from the concept a client needs to address.
/accounts/:accountId
/users/:userId
/orders/:orderIdEach path carries a resource identifier, the stable value that names one specific resource in the API. acct_42 names an account, usr_9 a user, ord_123 an order. The path parameter is the slot for that identifier in the pattern, and the real request fills the slot with a value.
Identifiers need more thought than they usually get. They end up in logs, bookmarks, caches, SDK method arguments, tests, and support tickets. A stable opaque identifier is the safe default; it gives clients something durable while keeping your storage details private. Sequential integers work for an internal API, but you pay for them with enumeration risk and migration pain. A compound identifier only makes sense when that combined value is genuinely part of the public identity, and not before.
Point the identifier straight at the resource:
GET /orders/ord_123That route says the client is addressing one order, full stop. The account relationship can still exist in the representation, in the authorization check, and in the database query. The path keeps the order's identity in front.
Compare it with this:
GET /accounts/acct_42/orders/ord_123The nested version says the order is reached through an account. That can be right when the API's public identity for an order really is scoped to an account. It turns into a problem the day an order moves between accounts, or support needs to look up an order globally, or some client already holds nothing but ord_123.
So treat nesting as a statement about identity. Nest when the parent is part of the public identity, or when the child has no meaning outside that parent. When the parent is just how the current handler happens to find the data, use a direct item route instead.
Nesting is hard to walk back. Once /accounts/:accountId/orders/:orderId is live, clients save that pair, and so do support tools and bookmarks. The day an order moves to a different account, or someone needs to look an order up globally, that path becomes a migration project. Nest only when the account is genuinely part of the order's public identity, not because it happens to be how today's query joins its tables.
Two small labels carry a lot here. A collection resource stands for a set of items, like /orders. Depending on your contract you might read it, filter it, or post a new item to it. An item resource stands for one member of that set, and /orders/ord_123 is one of those members.
Small as they are, those labels clean up your method choices:
GET /orders read the collection representation
POST /orders create or submit to the collection
GET /orders/:orderId read one item representationMembership operations belong to the collection. Operations on one identified resource belong to the item. Keeping that split is what keeps your route names centered on resources instead of actions.
Subresources hold state that lives under a parent but has its own API behavior. /orders/:orderId/cancellations works as a subresource. It sits under an order because a cancellation has no useful identity on its own in this API, and it takes a POST because the client is submitting a cancellation request into the order's cancellation collection.
Here is another one:
GET /orders/:orderId/eventsevents is a collection under the order. It can expose the order's status history while the main order representation stays focused on current state. The parent-child link here is public and stable, these events belong to this order in the API model, and clients can build on that.
Keep subresources lean. They pay off when the data is clearly scoped to one parent, and they become clutter the moment every internal relationship you have gets promoted to its own path segment.
/accounts/:accountId/users/:userId/orders/:orderId/items/:itemIdA route like that publishes a full traversal. It tells clients that account, user, order, and item ownership all take part in the public identity. For a tenant-scoped admin API, maybe that is true. More often it is just an ORM relationship, the joins your object-relational mapper produces, written out into the URL.
Your resource model should name things clients can actually reason about. The backend-only objects can stay where they are, behind the handler.
The model gets sharper once you separate ownership from lookup.
Take an order. It might belong to an account, get placed by a user, hold line items, and carry payment attempts and fulfillment events. All of those relationships are real and all of them live in the backend. Your API still has to choose which one shows up in the public address.
Use account nesting when the account scopes the order identifier:
GET /accounts/acct_42/orders/ord_123That route reads ord_123 inside acct_42. A different account could, in principle, reuse the same order identifier. Or the API might require the account segment because every order operation is tenant-scoped at the surface.
Use direct order identity when the order identifier is global across the API:
GET /orders/ord_123The handler can still check account membership, turn away callers from outside the account, and load the order through an account index. None of that changes the public identifier, which stays the order id.
Those two choices produce different client code:
await api.getOrder('ord_123');
await api.getAccountOrder('acct_42', 'ord_123');Both can be right. The first client keeps one identifier, the second keeps a pair. Reach for the pair when the pair is the stable public identity, and for the single identifier when the account is only there for policy or filtering.
Computed resources want the same care. /users/:userId/order-summary is worth modeling as a resource when clients fetch that summary view over and over. It has an identifier through the parent user and a stable representation, so it can be cached, tested, documented, and changed over time. Hand the same data a handler-flavored name like /calculateUserOrderStuff and you have given it a much weaker contract.
Workflow resources are another regular case. An export, a checkout, a cancellation, an invitation, a password reset, an upload session, an approval, any of these can become a resource once a client needs to watch its state across more than one request. There is a simple test for it. If the operation produces state the client can name and return to later, make it a resource.
For a one-shot operation whose only result is the changed parent, keep the parent in focus. A small status change can be a PATCH on the item. A process that runs with retries, status, an actor, timestamps, and failure reasons should get its own resource.
This model also drives authorization. Chapter 24 is where the authorization models live, but the route already fixes which resource a policy attaches to. POST /orders/:orderId/cancellations makes the policy about cancelling one specific order. POST /cancelOrder forces the policy to dig the target out of the body before it can even name the resource it is protecting.
When a handler has to reach into the body just to find out which resource it is acting on, something is off. Operation data goes in the body. The addressed resource goes in the path.
Route Patterns Carry Identity
Start with what a route pattern actually is. It is the template your router matches a request path against. /orders/:orderId is the pattern. /orders/ord_123 is a real request path that matches it.
Chapter 10 covered raw routing, the matching itself. The design question here is a different one. A route pattern is part of the public contract because it shows clients where the identifiers live and how you have grouped your resources.
These three routes can all return order data:
GET /order/:id
GET /orders/:orderId
GET /accounts/:accountId/orders/:orderIdFor most public APIs the second one is the cleanest. Plural orders names the collection, :orderId names one item, and the identifier name is specific enough to survive a handler that also juggles accountId, userId, or paymentId.
The first route uses a singular collection name and a vague :id. It runs fine, but it reads badly the moment a request context holds more than one identifier. The third route scopes the order under an account. That is the right call when you want account membership to be part of the address, and it adds friction when order identity is already global.
Pluralization is mostly boring, which is exactly what you want from it. Use /orders for the collection and /orders/:orderId for the item, and then stop. Do not let /order/:id, /orders/:id, and /orderHistory/:id all show up across one surface. Clients form expectations from your names, and inconsistent names train them badly.
Action names raise a separate question.
POST /orders/:orderId/cancel
POST /orders/:orderId/retryPayment
POST /orders/:orderId/markFraudThese routes expose commands, and commands are not automatically wrong. Plenty of APIs need operations that do more than swap one JSON document for another. The real question is whether the operation leaves behind resource state worth naming.
Cancellation has its own state, the time it was requested, who requested it, the reason, the status, the outcome. So it can become a resource:
POST /orders/:orderId/cancellations
GET /orders/:orderId/cancellations/:cancellationIdPayment retry can become a resource too, once clients need to track individual attempts:
POST /orders/:orderId/payment-attempts
GET /orders/:orderId/payment-attempts/:attemptIdNow the route exposes the durable thing the operation created. Internally, the handler can still call a function named retryPayment(). The difference is that the contract hands clients a resource they can go and look at.
Some operations stay action-like because the result has nothing addressable worth keeping. An internal admin endpoint might fairly expose:
POST /internal/orders/:orderId/reindexThat path belongs to an internal surface with a small, known set of callers. Even there, make the action a deliberate choice. Public APIs stay easier to evolve when operations that create state expose that state as a resource.
Identifier placement should also steer clear of false hierarchy.
GET /users/:userId/orders/:orderIdThat route says the order is reached through a user. When users are really just a filter over the orders collection, prefer this:
GET /orders?userId=usr_9
GET /orders/:orderIdThe collection route takes a query parameter to narrow the collection representation. The item route keeps the order's identity direct. Subchapter 4 is where pagination and filtering get their full treatment, so the point here stays small. Use query parameters to pick within a collection, and keep identity in the path parameter.
A small map keeps the roles straight:
route pattern /orders/:orderId
identifier name orderId
identifier value ord_123
resource order ord_123
representation JSON returned for that orderThe route pattern carries the address and captures the identifier. The handler takes that identifier and uses it to load state, apply policy, and pick a response. The representation only starts once that resource lookup is done.
This separation pays off in tests. A routing test can check that /orders/ord_123 captures orderId=ord_123. A handler test can check that ord_123 produces the right status and representation. A contract test, later, can check that the endpoint surface still matches what you documented.
Representations Are The Payload Contract
A resource and its representation are not the same thing, and the difference is worth holding onto.
The resource is the addressable thing. The representation is the actual payload you send over HTTP for one particular request. Throughout these chapters that payload is usually JSON, though HTTP can carry other media types just fine.
An order resource might have its real state scattered across several tables, the order row, line items, payment state, shipment state, fraud review, account settings. The representation decides what the client actually receives out of all that:
{
"id": "ord_123",
"status": "paid",
"total": { "amount": "49.00", "currency": "USD" }
}So the representation exposes a chosen slice of the state. It names the fields, decides how they nest, and fixes their string formats. It can drop internal fields, add computed ones, and carry links or request cues.
Representation state is just the resource state as it shows up inside the representation. status: "paid" is representation state. So is total.currency: "USD". Your database might store those values in some other form, but the client only ever sees the representation.
Application state is something else, it is the client's own progress through its interaction with the API. A client that has fetched an order, picked a cancellation reason, and is about to submit the cancellation is holding application state. The server keeps sending representations that help the client choose its next request, and the client holds its own progress between those requests.
REST leans on that division. The server exposes resource state as representations. The client moves its application state forward by choosing requests, based on what it knows about the API and, in richer designs, on cues inside the representations it gets back.
A request affordance is a cue inside the representation that tells a client about a request it could make. The cue might be a link, a form description, an action object, or just a simpler field convention. Full hypermedia design is a much bigger subject. For a Node JSON API, a small affordance can be as plain as this:
{
"id": "ord_123",
"status": "paid",
"actions": ["cancel"]
}That representation tells the client cancellation is available right now. It still counts on the client already knowing the cancellation endpoint contract. A more explicit version could spell out a URL and method, but plenty of JSON APIs keep their affordances lighter than that. The idea is behavior driven by state. The client reads a cue from the current resource state before it offers the next request.
Designing the representation is every bit as much a contract decision as designing the route.
Changing /orders/:orderId is obviously risky. Renaming status from "paid" to "PAID" is just as risky, even though the URL never moved an inch. Drop total.currency and you can break a mobile checkout summary. Turn items from an array into an object and you can break an SDK. Adding a field is usually gentler on tolerant clients, but even that touches generated types, strict decoders, caching, and display code.
The representation also decides where related data shows up.
{
"id": "ord_123",
"account": { "id": "acct_42" }
}That field hands the client the account relationship without forcing the order route under /accounts/:accountId. You get to expose direct order identity and still include the relationship data.
Embedding has a cost, though. If every order representation drags in the full account, the user, the items, the shipment, and the payment attempts, the response gets heavy and harder to change later. If it carries only identifiers, clients may have to make many follow-up requests. Field selection and expansion policies belong to Subchapter 4. The rule here is the simpler one. The structure of a representation is a contract in its own right, separate from how your internal objects are laid out.
Serialization is the step that turns a representation into bytes.
function orderView(order) {
return {
id: order.id,
status: order.status,
total: order.total,
};
}That function builds the representation in plain JavaScript. It translates internal state into API state. It should stay boring, local, and tested. It also gives the service one place to keep internal fields out of the public payload.
Hand the storage object straight back and you publish every column it has today plus every column anyone adds later. A new internalCost, fraudScore, or passwordResetToken reaches clients the instant a migration creates it, without anyone touching the handler. Send the payload through a view function instead. The view spells out which fields clients receive, so a new internal column stays internal until you choose otherwise.
Response headers are in the contract too. Content-Type: application/json tells the client how to parse the representation. Location can identify a freshly created resource. Cache headers, content negotiation, and validators have deeper mechanics that come from Chapter 10 and the later production chapters. Here, the point is just that headers join the contract whenever clients rely on them.
Representations should fit the operation. A collection representation can carry a list plus some collection metadata:
{
"items": [{ "id": "ord_123", "status": "paid" }],
"count": 1
}Subchapter 4 handles pagination in full. For now, just notice that /orders comes back as a collection representation, not the same structure as /orders/:orderId. The collection has its own state, which items it included, which filters it applied, and which metadata the client can use.
The item representation carries one resource. The collection representation is a view over many. Blur the two and clients get confused, especially when one endpoint sometimes returns an array and sometimes returns an object depending on the filters.
Write payloads sit next to representations, but they need their own structure.
A creation request is usually a command-style payload:
{
"items": [{ "sku": "sku_1", "quantity": 2 }],
"currency": "USD"
}That body is the input to POST /orders, and it can look nothing like the order representation that GET /orders/:orderId returns. The client is asking the collection to build an order from the data it supplied. The server is the one that picks the identifier, the status, the totals, the timestamps, and any derived fields.
The response representation can include the fields the server owns:
{
"id": "ord_123",
"status": "pending_payment",
"total": { "amount": "49.00", "currency": "USD" }
}The request body and the response body share some field names, but they are doing different jobs. The request body says what the client wants. The response representation says what the resource looks like after the server acted on that want.
Partial updates create another split:
{
"shippingAddress": {
"postalCode": "10001"
}
}That body might patch a single field on the order. It is a patch document, defined by the endpoint contract, and it is far smaller than the full order representation. Treat it as "an order with most of its fields missing" and you walk straight into accidental clearing, ambiguous defaults, and ambiguous validation. Treat it as its own request structure instead.
Run a PATCH body through the full-resource decoder and every field the client left out reads as empty, then overwrites whatever was stored. Absent and null mean different things, and the patch contract has to keep them apart. Give the patch document its own type and its own validation, kept separate from the read representation.
Subchapter 2 covers schema vocabulary in depth. What the resource model gives that work is a target. A schema for POST /orders validates a collection submission. A schema for PATCH /orders/:orderId validates a partial item update. A schema for POST /orders/:orderId/cancellations validates a cancellation submission. Those are three different contracts, even when they happen to share field names.
Representation variants need names of their own. An API might return a compact order inside a list and a fuller order from the item endpoint:
{
"id": "ord_123",
"status": "paid"
}The compact one belongs to the collection view. The item representation can carry totals, addresses, and links. Make both of them on purpose. A client listing orders should get enough data to render the list. A client opening one order should get enough data for the detail view.
The easy mistake is to keep bolting fields onto the list response until every client is happy, which leaves the collection slow and hard to change. A cleaner contract names the representation level instead, list item, order detail, cancellation detail, payment attempt detail. Generated schemas can encode that split later.
Representations also need stable value vocabularies. status is a set of values clients branch on. The moment a client uses paid, canceled, shipped, or refunded, those strings are part of the contract. Adding payment_review can be safe for clients that handle unknown values carefully, and it can break clients that switch exhaustively over the values they know. That is contract behavior, even though the field is only one string of JSON.
Renaming status to STATUS, dropping total.currency, or reusing a field for a new meaning breaks clients the same way a URL change would, except the response still parses, so nobody sees the break until something downstream behaves wrong. Adding an enum value like payment_review looks purely additive, and yet it breaks any client that switches exhaustively over the values it already knows. The value vocabulary is part of the contract, so change it on the same schedule you change routes.
Dates, money, and identifiers come with the same constraints. A date string needs a format. Money needs rules for amount and currency. An identifier needs rules for opacity and casing. Chapter 18 gets into money modeling inside data systems later on, but API representation design still has to pick the JSON contract the client sees today.
Keep representation construction close to where you send the response:
function orderListItem(order) {
return { id: order.id, status: order.status };
}That tiny function is holding the contract. It keeps stray fields out when the internal order object grows new ones. It gives tests one function to assert against, and it gives later schema work a concrete structure to match.
REST Without Theory Drift
People argue about REST endlessly, so let me keep this grounded. REST is an architectural style for network APIs built around resources, representations, stateless requests, and a uniform interface. The part that pays off in backend design is the discipline it puts on the surface. Clients address resources, send self-contained requests, and read responses through shared HTTP semantics plus your own representation contract.
A REST constraint is one rule from that style. The complete set belongs to architecture history and protocol design. For a Node service, the useful subset is short:
resources have identifiers
representations carry resource state
requests carry enough context to be handled alone
methods and statuses keep shared meaning
clients advance through representations and known affordancesUniform interface is the part that gives HTTP APIs most of their structure. You reuse the same small set of method and status meanings across every resource, instead of inventing a fresh verb system for each handler. GET reads a representation. POST submits to a target. PATCH applies a partial change. The domain behavior still lives in the endpoint contract, but the protocol vocabulary stays shared across all of them.
This is the gap between /orders/:orderId/cancel and POST /orders/:orderId/cancellations. The first puts the operation name in the URL. The second exposes a resource that can have representations, statuses, and a history. Both can run identical code underneath. The second one just gives the API more state it can describe.
Statelessness shows up at the handler. A request should carry everything the server needs to handle it at the HTTP API layer, the target, the method, the headers, the body, the credentials once the auth chapters arrive, and any preconditions the operation needs. The server is still free to use databases and caches. Server-side storage is normal and expected. Stateless is describing the client-server interaction, not the server's memory.
That fits Node's request lifecycle well. http.Server takes one request, builds one IncomingMessage and one ServerResponse, and fires a handler. Underneath, the handler may share connection state through HTTP keep-alive, but the API contract should still depend only on the current HTTP request. Reusing the connection is a transport optimization and nothing more; the resource state still lives in storage and in the representations you send.
The uniform interface also keeps middleware manageable. A framework handler can branch on method, route, media type, and body structure precisely because those pieces have stable meanings. A route named /doThing with an undocumented body can still work, but the tooling around it has far less to grab onto. Logs, generated clients, contract docs, tests, and client retries all get easier when the surface uses protocol meaning the same way everywhere.
REST still has to handle operations past CRUD, the create, read, update, delete set. Real APIs have approvals, cancellations, retries, checkouts, uploads, exports, and imports. The way through is to settle three things: which resource actually changes, which representation the client should see afterward, and which method and status pair reports the result.
An approval can be a subresource:
POST /orders/:orderId/approvalsA checkout can be a resource under a cart:
POST /carts/:cartId/checkoutsAn export can be a job resource:
POST /exports
GET /exports/:exportIdIn each case the path names the durable thing the client deals with, the method submits the request, and the response can point back at whatever resource got created. From there the client can poll it, display it, cancel it, or inspect it, as long as the contract supports that.
Hypermedia lives around this area, and it gets detailed quickly. The version this book needs is small. Representations can carry affordances that help a client decide its next request. A strict hypermedia API pushes more of the navigation into the response bodies themselves. Plenty of production JSON APIs settle for documented route contracts plus a few state fields, and that is a real tradeoff, so make it on purpose.
The bigger mistake is to claim REST while quietly dropping the constraints that make the style worth anything. When every endpoint is POST /someVerb, every response is 200, every body has its own ad hoc structure, and clients need hidden ordering rules between calls, the API has walked away from resource-oriented HTTP. It can still be a perfectly functional RPC-over-HTTP API, remote procedure calls where the client names a function to run. If that is what it is, name it and document it that way.
Chapter 12 gets into GraphQL and gRPC, because those models make different contract choices. REST-style HTTP names resources and representations. GraphQL exposes a typed query surface. gRPC exposes typed remote methods over HTTP/2. Which one fits comes down to the contract you want and the runtime mechanics you can live with.
The Node Handler Layers
The part of this design that goes deepest is the handoff between layers.
Node takes in HTTP bytes, parses the message, and gives you a request object. The router matches a route pattern. The handler turns the captured identifiers and request data into a domain operation. The serializer builds a representation. The response writer commits the status, the headers, and the body bytes.
One request moves through several stages, and different code owns each one:
HTTP parser
router
resource loader
operation policy
representation serializer
response writerThe resource model sits after the parser's output and before anything the client sees. It hands the router and the handler a shared vocabulary, the words collection, item, identifier, representation, and status behavior. Once they share those words, the route table starts describing API resources rather than handler entrypoints.
In a raw Node server, that first layer is right out in the open. Keep the async work behind a catch, because http.createServer() registers the request listener as an event listener:
http.createServer((req, res) => {
handle(req, res).catch(() => {
json(res, 500, { code: 'internal_error' });
});
});http.createServer calls the listener as an event handler, so a promise it returns is never awaited. An async handler that rejects without a .catch() becomes an unhandledRejection, and current Node defaults to ending the process. That means one bad request can take down the server and every live connection on it. Wrap the dispatch so a rejection turns into a response instead of a process exit.
The async routing work can still stay small:
async function handle(req, res) {
const url = new URL(req.url, 'http://localhost');
const hit = match(req.method, url.pathname);
await hit.handler({ req, res, url });
}req.method and url.pathname come out of HTTP parsing and URL parsing. match() applies the route table. The context object carries request state into the handler. Nothing here mentions resources yet, but the route table is where they get encoded.
Add the route records:
route('GET', '/orders/:orderId', showOrder);
route('PATCH', '/orders/:orderId', updateOrder);
route('POST', '/orders/:orderId/cancellations', cancelOrder);Each record maps a protocol form to a piece of resource work. The matcher captures orderId and the handler receives it as text. The handler should treat that text as an API identifier first. It might map ord_123 to an internal row id, call a service, or reject a malformed identifier before it ever touches storage.
The next layer loads or changes state:
async function showOrder(ctx) {
const order = await orders.load(ctx.params.orderId);
if (!order) return json(ctx.res, 404, error('not_found'));
json(ctx.res, 200, orderView(order));
}Here the handler maps an identifier to resource state, orderView() maps internal state to representation state, and json() commits the response. The status reports how things came out at the resource level, whether the order was found, was missing, changed, was rejected, or failed.
The code is short on purpose, and the layering is where the value is. The route parameters coming in are API input, nothing more. The storage object the loader returns is internal state that should never leak as-is. The view function is the piece that defines the representation contract, and the response helper is what actually writes HTTP output. Four different jobs, and keeping them in four different places is what keeps the handler honest.
When those layers blur together, the API starts drifting out of consistency. Send a database row straight back and its internal columns are suddenly public. If every route parameter is named id, you can no longer tell at a glance which identifier a handler actually wants. Set every error to 200 and clients have to crack open the body just to learn whether the call worked. Path segments added for internal ownership push your current query structure out into the contract. And once every handler hand-rolls its own JSON, small field differences creep in that clients come to rely on anyway.
A framework changes the mechanics but not the layers. Express and Fastify differ in how they register routes, match them, run hooks, and reach the serializer. Subchapter 3 gets into those internals. The resource contract still shows the same pieces underneath, the method, the route pattern, the identifiers, the request body, the representation, the headers, and the status.
The layer handoff also makes it clear where validation belongs. Syntax parsing confirms the body is JSON. Structural validation confirms the JSON has the fields you expect. Semantic validation confirms the requested operation makes sense for the resource's current state. Subchapter 2 covers JSON Schema and where validation actually runs. What the resource model already tells validation is what it is checking, a creation request for /orders, a partial update for /orders/:orderId, or a cancellation request under one order.
Pick the status code at the same layer. If orders.load(orderId) turns up no resource the client can see, that is the 404 path. A representation update that conflicts with current state is the conflict path. A body that fails its schema check is the client-input path. The exact error envelope waits for Subchapter 4, but the resource outcome is already settled here.
Committing the response is the last mechanical step. Once res.writeHead(), res.write(), or res.end() has gone out, the endpoint has started delivering its contract. Find out after that point that the resource was missing, and you have a broken handler. So the resource lookup, the operation checks, and the representation choice all need to happen before the first response byte, unless the endpoint is deliberately a streaming response.
Streaming APIs and realtime transports get their own chapters. Plain resource endpoints get simpler when they hold off committing until they know how the resource came out.
Walk the state through one request slowly.
Start at the parsed request head. Node has already turned the bytes into req.method, req.url, req.headers, and a readable request body. The HTTP parser owns the message framing, and everything above it, the interpretation, is yours.
The router then applies its method and path rules. It should hand back one route record and one params object:
{
name: 'orders.show',
params: { orderId: 'ord_123' }
}That object is still pure API input. It holds the public identifier value and nothing else. Keep the database rows behind the resource loader, where they belong, because logging and validation both stay clearer that way. At this point the route is matched, the identifier is captured, and the resource lookup has not happened yet.
The resource loader is where you cross from API input into backend state:
const order = await orders.findVisible(ctx.params.orderId, ctx.viewer);The function name barely registers next to the separation it draws. It takes the API identifier and the caller context. It returns the resource state that caller is allowed to see, or an empty result. Chapter 24 will give the caller context much richer authorization rules. What the resource model already tells you is which resource the visibility check is about.
The operation policy runs after the current state is loaded:
if (order.status === 'shipped') {
return conflict(ctx.res, 'order_already_shipped');
}That branch belongs to resource state. A request body can pass every validation check while the current state of the resource still rejects the operation. A cancellation request against a shipped order is a state conflict. A malformed cancellation body is an input problem. A missing order is a visibility or existence problem. Those three should land on three different status paths.
Serialization comes once the operation has decided how the resource turned out:
return json(ctx.res, 200, orderView(order));At that moment internal state becomes representation state. The view function picks the fields. The response helper picks the status, the headers, and the bytes. By the time you get here, the handler should know enough to commit the response exactly once.
That sequence gives every failure a home:
route miss route table
bad identifier route input policy
missing resource resource loader
state conflict operation policy
bad representation serializer or contract test
late write response boundaryThe names are plain because the debugging should be plain too. A 404 from a route miss and a 404 from an invisible resource share a status, but they come out of different layers. Your logs can name the layer. Your tests can target the layer. The API contract can still show the client the same visible outcome whenever policy calls for it.
Node's streaming request body adds one timing detail. The route can match before the body has fully arrived. A handler can look at the identifiers and headers, decide the resource path is wrong, and respond early. If the request is carrying a large body, the server still has to deal with the unread bytes, the way Chapter 10 described. That part is lifecycle mechanics. The API design rule is smaller, the path identity and the method choice should both be knowable from the request head, and body parsing supplies the operation data after the route is already known.
That ordering keeps your body parsers scoped. A POST /orders body can be parsed as an order creation request. A POST /orders/:orderId/cancellations body can be parsed as a cancellation request. A "parse every body before routing" stack parses bytes it did not need and makes error placement less precise.
Resource-first routing gives middleware a stable target:
parse URL
match route
capture identifiers
choose body parser
run operation
serialize representationFrameworks wrap those steps in hooks and plugins. Raw Node code spells them out by hand. Either way, the contract only stays visible when the resource model is visible, both in the route table and in the handler layers.
Methods As Resource Operations
Chapter 10 already gave the HTTP methods their semantics. Resource modeling is about pointing those semantics at named resources.
GET reads a representation:
GET /orders/ord_123The endpoint returns the current order representation the client is allowed to see. It can pull fields from several storage records along the way. The route is still addressing one order resource.
POST submits data to the target resource. On a collection, it usually creates or starts a new item:
POST /ordersThe client is submitting an order creation request to the orders collection. A successful response often comes back as 201 Created, with a representation of the new order and a Location header pointing at /orders/:orderId.
PUT replaces the representation at a known resource identifier, when the API supports full replacement:
PUT /orders/ord_123Full replacement means the request body stands for the new item state at that target. Many JSON APIs hold PUT back for smaller resources, because full replacement is hard to define safely. That choice comes out of how the domain is defined.
PATCH applies a partial change:
PATCH /orders/ord_123The contract has to define what the patch document looks like. A small API might take { "shippingAddress": ... }. A different one might use a standard patch media type. Either way the endpoint contract defines the body grammar.
DELETE removes a resource, archives it, or marks it gone, depending on the API's domain rules:
DELETE /orders/ord_123Some domains keep tombstones, marker rows that record a delete without erasing the data. Some treat cancellation as the public way to remove something. Some expose deletion only to internal operators. Your route table should express whichever operations the domain rules actually support.
Creation can target a client-chosen identifier:
PUT /orders/ord_123That route means the client picked ord_123 and wants the server to create or replace that specific item under the contract's rules. Many public APIs would rather generate the identifier themselves through POST /orders. Both ways of owning identity can be valid. Pick the one that matches who actually owns the identity.
Operations that create process state often work best as posts to a subresource collection:
POST /orders/ord_123/cancellations
POST /orders/ord_123/payment-attempts
POST /exportsWhen the client needs to watch the result, the response should make it addressable:
201 Created
Location: /exports/exp_77The export job is a resource now. The client can fetch it later. If the operation finished right away, the response can just return the resulting representation. If it is still running in the background, the job resource gives the client a stable place to check on it.
Keep method semantics visible for ordinary HTTP APIs:
POST /orders/ord_123
{ "method": "delete" }A request like that hides the protocol meaning inside the body. Intermediaries, logs, tests, and client tooling all see only POST. Some constrained environments do force method-override conventions, but a normal Node API can expose the method directly and should.
POST /orders/search is a common case where the design gets ambiguous. Search can be modeled as a filtered collection read with query parameters when the inputs are small and bookmarkable:
GET /orders?status=paid&accountId=acct_42When the search input is large, sensitive, or heavily structured, a posted search resource can be the better call:
POST /order-searches
GET /order-searches/:searchIdSubchapter 4 covers the filtering mechanics. The resource modeling point here is narrower, even a large operation can expose resources instead of collapsing into a loose verb.
Keep the method set small per resource. A collection might support GET and POST. An item might support GET and PATCH. A subresource collection might support only POST and GET. A method outside that set should get a deliberate 405 with an Allow header, rather than falling through to a generic missing route.
And the distinction does real work. A 404 says the target resource path is unknown or unavailable, while a 405 says the path exists but the method is not supported. Clients and debugging tools read those two very differently.
Status Codes As Resource Signals
A status code reports how the HTTP exchange came out. In a resource API, you want it carrying the resource-level outcome, with any custom success or failure fields riding along as secondary detail inside the representation.
Match the success status to the operation:
200 OK representation returned
201 Created new resource created
202 Accepted work accepted for later processing
204 No Content operation completed with no bodyThose names came from HTTP, and Chapter 10 is where the protocol layer is covered. The design choice here is when each status applies to your resource contract.
For POST /orders, 201 fits when the server created an order resource during the request. The response can include the representation and a Location.
For POST /exports, 202 fits when the server accepted the work and exposed a job resource to inspect later. The response body can carry the job representation, and Location can point at /exports/:exportId.
For PATCH /orders/:orderId, 200 fits when you return the updated representation, and 204 fits when you choose to send no body at all. Pick one of those and keep it stable across endpoints that behave alike.
Client-error statuses should map to resource facts the same way:
400 malformed request structure or syntax at the API layer
404 resource path or visible item unavailable
405 method unsupported for the target
409 requested change conflicts with current resource state
415 unsupported request media typeThe detailed error envelope waits for Subchapter 4. Even so, the status alone already tells the client where to branch. A missing order and an invalid JSON body call for different protocol signals, and mapping both to 200 with { "ok": false } throws that signal away.
404 has a privacy and authorization wrinkle. An API can answer 404 for a resource the client is not allowed to see. Authentication and authorization are Chapter 24's subject, but resource modeling has to make room for that policy. The visible contract can say the resource is unavailable to this caller while keeping its global existence private.
A 403 on a resource the caller cannot see still confirms that the resource exists, which hands out information and lets a caller enumerate identifiers. For sensitive resources, answer 404 so that "you are not allowed" and "it is not there" look identical from outside. Decide this per resource as part of the contract, instead of ad hoc inside each handler.
409 belongs to the current resource state. If an order is already shipped and the client asks to cancel it, the request conflicts with that state. The exact error body can name a machine-readable code later. The status itself already says the request was understood and the state is what blocked the operation.
422 shows up in a lot of JSON APIs for semantic validation failures. Reach for it only when your contract actually defines how it differs from 400, because clients will learn the split and depend on it. A simpler surface can use 400 for request validation failures and keep 409 for state conflicts. Being consistent counts for more than getting the exact split perfect.
Status codes that contradict the operation turn into long-lived bugs. A 201 from a GET claims a creation that never happened. A 204 with a JSON body sends the client two contradicting signals at once. A 200 on every failed operation forces clients to parse a custom body before they can even tell whether the exchange succeeded.
Node will send whatever status your code sets. ServerResponse starts on a success default. A missing branch can ship 200 by accident.
res.statusCode starts at 200. A branch that calls res.end(...) without ever setting a status sends 200 OK with an error body, and retries, metrics, and client code all read that as success. Set the status and the body together in the same branch, so the protocol signal and the payload can never disagree.
if (!order) {
res.end(JSON.stringify({ error: 'missing' }));
return;
}That handler returns a success status unless some earlier code already changed it. The body says missing. The protocol says success. Clients will pick one of those, and different clients can pick different ones.
Set the status right at the branch:
if (!order) {
return json(res, 404, { code: 'order_not_found' });
}The helper commits the status and the body together. The body can grow into the Chapter 12 error envelope later on. The status is already right.
Consistent status codes help observability too. Logs and metrics can group 404, 409, and 5xx straight from the status. Client libraries can throw typed errors, tests can assert behavior with a single check, and reverse proxies can apply generic HTTP behavior at the edge. The API still keeps the domain detail in the representation.
Compatibility Pressure
A contract picks up more callers the longer it is live, and that is where the pressure comes from.
A route name is easy to invent on day one. Six months later that same route shows up in a React app, a mobile release, an internal SDK, a partner integration, synthetic monitoring, support scripts, and a runbook. Changing it has quietly become a coordination project.
Representation fields turn into constraints the same way. status, total.amount, and account.id become code inside clients. Remove one and clients break. Rename one and they break again. Reuse a field for a new meaning and the break is worse, because the response still parses and nothing complains until something downstream goes wrong.
Status behavior becomes a constraint as well. If clients treat 409 as "show a retryable state conflict" and 400 as "fix the form", then swapping those two responses turns into a product bug. If clients poll an export job after a 202, switching that endpoint to block until completion changes the timeout behavior and the whole user flow.
This is why resource modeling comes before framework routing. Frameworks make registering a route easy. Deciding which route should still exist in two years is the API design work.
Clients come in different kinds:
browser frontend deployed many times per day
mobile app deployed through an app store
internal service using an SDK
partner integration updated on its own schedule
CLI script copied into operations docsEach kind migrates at a different speed, and the slowest one usually sets the minimum behavior you have to keep supporting.
That minimum should guide how you name and expose resources. A browser frontend can usually take a route update fast. A mobile app might keep the old route alive for months. A partner integration could be running code you will never see. An internal batch job might have been copied off a wiki page and then forgotten until quarter close.
The resource model is what gives those clients durable handles. /orders/:orderId is easy to keep alive while you add new collection filters, new subresources, or new representation fields. /getOrdersForUserAndMaybeAccount leaves you with a handler-named route that has to change every time one caller wants another variant.
Compatibility also affects how deprecation plays out. A route can become discouraged before it disappears. A field can be marked legacy before a newer field fully takes over. A status can pick up a new error code in the body while the broad HTTP status holds steady. Subchapter 7 covers the versioning and deprecation mechanics, but the resource model is what decides whether a compatible path forward even exists.
Design for additive change from the start. A new subresource, an optional field, a query parameter whose default keeps the old behavior, a new representation link or action cue, all of those are easier when the original resources were named cleanly.
Backward-compatible changes usually mean adding optional data or new endpoints. Add GET /orders/:orderId/events and existing order reads keep working, untouched. A new optional response field is usually fine for tolerant JSON clients. A new query parameter on /orders is safe as long as old clients that leave it off get the same behavior they got before.
Breaking changes alter expectations that already exist. Removing /userOrders/:userId, renaming orderId to id, changing the status values, moving POST /orders from 201 to 202, or pushing order identity under /accounts/:accountId/orders/:orderId, every one of those forces clients to migrate.
The versioning mechanics are Subchapter 7's job. The earlier move, the one you make now, is to avoid needless breaks by choosing stable resource names, stable identifiers, and stable representation structures from the start.
That messy route table from the top would set migration traps:
GET /userOrders/:userId
POST /orders/:id/cancel
POST /accounts/:accountId/order/:orderId/retryuserOrders bakes one query into the route name. Cancellation leaves behind no addressable result. Retry is scoped under account ownership even though the operation is really about an order's payment attempt. Clients will build around all three of those choices, and then you are stuck with them.
A resource-oriented surface leaves you more room:
GET /orders?userId=usr_9
POST /orders/:orderId/cancellations
POST /orders/:orderId/payment-attemptsThe collection can pick up more filters later. Cancellation can grow a representation later. Payment attempts can expose their status later. None of that asks clients to learn an internal handler name.
Compatibility constraints apply inside a company too. Internal APIs break builds, dashboards, queues, jobs, and incident tooling when they change. "Internal" only means the set of callers is known. The contract still needs deliberate change control.
A resource model also gives a team a shared review language. You can replace subjective arguments about route naming with concrete checks:
addressed resource
resource identifier position
changed representation
status used for the resource outcome
client assumption that becomes durableThose questions fit a Node service whether it runs on raw node:http, Express, Fastify, or something else. The request still arrives as HTTP. The router still maps method and path. The handler still takes input and writes output. The client still ends up holding a contract.
The next step is making that contract executable, through documents, schemas, generated clients, request validation, and response validation. Those tools start paying off once the resource model underneath is worth preserving. Run them over an imprecise model and it stays imprecise.