The server from the previous lesson responds, but all of its logic lives inside the router and it only knows how to read. Today we turn it into a serious implementation of the contract: we will squeeze the req and res objects to know exactly what information a request carries and what we control about the response, we will split the code into three layers —routes, controllers and services— with a clear boundary between them, we will write the mapper that translates the internal model in cents into the public representation in euros with _links, and we will complete the life cycle of coffees: POST with 201 and Location, PUT, PATCH with application/merge-patch+json and its 415 when it is not, and a soft DELETE with 204. On top of that we will implement the collection parameters of 02-06 —filters, search, sorting and pagination— with the complete Link header. It is the longest lesson of the module and the one that turns the most contract into code.
Contents
- What gets refactored today and why
- The
reqobject in depth - The
resobject in depth - The three layers: routes, controllers and services
- The representation mapper
- The in-memory repository, extended
- The coffee service
- The coffee controller
- The routes, now without logic
- Collection parameters: filters and search
- Sorting with
-and a tie-break byid - Pagination and the
Linkheader - Field selection with
fields - Creating:
POSTwith201andLocation - Replacing and modifying:
PUTandPATCH - Deleting: soft
DELETEwith204 - Orders and action links driven by status
async/awaitand thethrowtrap in Express 4
- What gets refactored today and why
Starting point: src/routes/coffees.js queries the repository, transforms the data and responds, all in the same place. It works with two read routes. With twenty-four URIs, filters, validation and permissions, it turns into a thousand-line file that is impossible to test.
Files created today:
| File | Contents |
|---|---|
src/services/mappers.js |
Translation from internal model to public representation |
src/services/coffees.js |
Business logic for coffees |
src/services/orders.js |
Business logic for orders |
src/controllers/coffees.js |
HTTP ↔ domain translation for coffees |
src/controllers/orders.js |
The same for orders |
src/controllers/pagination.js |
Construction of the Link header |
src/repositories/orders-memory.js |
In-memory store of orders |
src/routes/orders.js |
Router for /v1/orders |
Files modified: src/routes/coffees.js (reduced to declarations), src/routes/index.js (mounts orders) and src/repositories/coffees-memory.js (gains write and search methods).
Two warnings about what we are not doing today, so the code does not surprise you:
- There is no real validation. We will check the bare minimum by hand, with provisional messages. The Zod schemas and the
validatemiddleware arrive in 03-04. - There is no centralised error handling. Services will return
nullwhen something does not exist and the controller will answer the 404 by hand. In 03-07 the services will throwApiErrorand a single middleware will take care of everything.
- The
req object in depth
req object in depthreq is the HTTP request turned into a JavaScript object. These are the properties we will use:
| Property | What it contains | Example with GET /v1/coffees/cof_001?fields=id,name |
|---|---|---|
req.method |
HTTP method in uppercase | 'GET' |
req.params |
Path parameters (:id). Always strings |
{ id: 'cof_001' } |
req.query |
Query parameters, already parsed. Always strings | { fields: 'id,name' } |
req.body |
Body parsed by express.json() |
{} (a GET has no body) |
req.headers |
Headers, with the names in lowercase | { host: 'localhost:3000', accept: '*/*' } |
req.get(name) |
A single header, case-insensitive | req.get('Content-Type') |
req.path |
Path without the query string, relative to the mount point | '/cof_001' inside the router |
req.originalUrl |
The full URL exactly as it arrived, with the query | '/v1/coffees/cof_001?fields=id,name' |
req.baseUrl |
Prefix the router is mounted under | '/v1/coffees' |
req.ip |
Client IP | '::1' |
req.protocol |
http or https |
'http' |
Four things worth burning into memory:
Everything arrives as text. ?limit=20 produces req.query.limit === '20', the string. '20' + 1 is '201'. Converting is mandatory, and in 03-04 Zod will do it with z.coerce.
req.query can hand you an array without warning. If the client repeats a parameter:
Code that assumes a string will call roast.toLowerCase() and blow up. The contract of 02-06 says that Aroma Store's filters are single-valued, so an array must produce 400 invalid_parameter.
Headers are read in lowercase in req.headers, or with req.get(), which ignores case. req.headers['Content-Type'] is undefined; req.headers['content-type'] works.
req.path is not req.originalUrl. Inside a mounted router, req.path is relative. To build the Link header we will need the full URL, and there originalUrl is the right one.
A diagnostic middleware you can paste temporarily into app.js to see it all live:
app.use((req, res, next) => {
console.log({
method: req.method,
originalUrl: req.originalUrl,
path: req.path,
params: req.params,
query: req.query,
contentType: req.get('Content-Type'),
body: req.body,
});
next();
});
- The
res object in depth
res object in depthres is the response under construction. We already know status, json, set and end; we now add the ones the contract needs:
| Method | What for | Use in the contract |
|---|---|---|
res.status(n) |
Sets the code | 201, 204, 404… |
res.json(obj) |
Serialises and sends | Every response with a body |
res.set(n, v) |
One header | Location, Link, Allow, Accept-Patch |
res.set({...}) |
Several headers at once | Handy for Link + Allow |
res.location(url) |
Shortcut for the Location header |
After a POST |
res.links({...}) |
Builds the RFC 8288 Link header |
Pagination |
res.vary(header) |
Appends to Vary |
Content negotiation (02-05) |
res.end() |
Closes with no body | 204 responses |
res.links() deserves a demonstration, because it does the RFC 8288 formatting for us:
res.links({
next: 'http://localhost:3000/v1/coffees?limit=20&offset=40',
prev: 'http://localhost:3000/v1/coffees?limit=20&offset=0',
});Link: <http://localhost:3000/v1/coffees?limit=20&offset=40>; rel="next", <http://localhost:3000/v1/coffees?limit=20&offset=0>; rel="prev"Exactly the format we settled on in 02-06, with the angle brackets, the quoted rel and the commas. One detail: res.links() accumulates if it is called several times, so a single call including every relation is enough.
- The three layers: routes, controllers and services
| Layer | File | Responsibility | May touch |
|---|---|---|---|
| Route | routes/coffees.js |
Saying which method and URI invoke which function | Nothing else |
| Controller | controllers/coffees.js |
Reading req, calling the service, writing res with status and headers |
req, res, services, mappers |
| Service | services/coffees.js |
Business rules and orchestration | Repositories. Never req or res |
| Repository | repositories/coffees-*.js |
Storing and retrieving data | The data source |
The rule that sums it up: the service must not know HTTP exists. If a req, a res, a status code or a header shows up in a service, the boundary is broken. The three concrete benefits, all of them within this module:
- It can be tested without a server (03-08): a service test hands it data and checks the result, with no Supertest and no ports.
- It can be reused: a script that imports orders from a CSV calls the same service as the API. If the logic lived in the controller, it would have to be duplicated.
- The storage can be swapped (03-05) without the controller noticing.
- The representation mapper
Converting the internal model into the public representation is a responsibility in its own right and deserves its own file. It is where all the decisions of 02-05 are honoured, in a single place.
// src/services/mappers.js
/** Converts whole cents into euros with two decimals. 1450 → 14.5 */
export function centsToEuros(cents) {
return Number((cents / 100).toFixed(2));
}
/** Converts euros into whole cents. 14.5 → 1450 */
export function eurosToCents(euros) {
// Math.round prevents 14.5 * 100 from giving 1449.9999999999998 in floating point.
return Math.round(euros * 100);
}
/**
* Internal coffee model → public representation.
* The rules of 02-05 are concentrated here:
* - camelCase in every field
* - euros on the outside, cents on the inside
* - fields always present, with null when there is no value
* - empty arrays as [], never null
* - _links with self (coffees have no action links)
*/
export function coffeeToRepresentation(coffee) {
return {
id: coffee.id,
name: coffee.name,
origin: coffee.origin,
roast: coffee.roast,
priceEuros: centsToEuros(coffee.priceCents),
stock: coffee.stock,
tastingNotes: coffee.tastingNotes ?? [],
description: coffee.description ?? null,
createdAt: coffee.createdAt,
_links: {
self: { href: `/v1/coffees/${coffee.id}` },
reviews: { href: `/v1/coffees/${coffee.id}/reviews` },
},
};
}
/**
* An order's action links ACCORDING TO ITS STATUS (02-05, selective
* hypermedia). It is the literal translation of the contract's table:
* pending_payment → self, customer, pay, cancel
* paid → self, customer, invoice, shipment, cancel
* shipped → self, customer, invoice, shipment, return
*/
function orderLinks(order) {
const base = `/v1/orders/${order.id}`;
const links = {
self: { href: base },
customer: { href: `/v1/customers/${order.customerId}` },
};
if (order.status === 'pending_payment') {
links.pay = { href: `${base}/payment`, method: 'POST' };
links.cancel = { href: `${base}/cancellation`, method: 'POST' };
}
if (order.status === 'paid') {
links.invoice = { href: `${base}/invoice` };
links.shipment = { href: `${base}/shipment` };
links.cancel = { href: `${base}/cancellation`, method: 'POST' };
}
if (order.status === 'shipped') {
links.invoice = { href: `${base}/invoice` };
links.shipment = { href: `${base}/shipment` };
links.return = { href: `${base}/return`, method: 'POST' };
}
return links;
}
/** Internal order model → public representation. */
export function orderToRepresentation(order) {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
// The line price is FROZEN: it is the one that applied on the day of
// purchase, not the current catalogue price (02-05, embedding section).
items: order.items.map((item) => ({
coffeeId: item.coffeeId,
name: item.name,
quantity: item.quantity,
priceEuros: centsToEuros(item.priceCents),
})),
totalEuros: centsToEuros(order.totalCents),
createdAt: order.createdAt,
paidAt: order.paidAt ?? null,
shippedAt: order.shippedAt ?? null,
_links: orderLinks(order),
};
}
/**
* Reduced version for collections: in a list, each item carries only
* 'self' (02-05: "items inside a collection do not carry full _links,
* so as not to multiply the weight of the response").
*/
export function toCollectionSummary(representation) {
return { ...representation, _links: { self: representation._links.self } };
}Now we have a single place to change if tomorrow the contract adds a field. Before, that logic was scattered around the router and nothing stopped GET /v1/coffees and GET /v1/coffees/cof_001 from returning different shapes of the same coffee. That is the most frequent consistency failure in real APIs, and a shared mapper makes it impossible.
- The in-memory repository, extended
The repository gains the ability to search by criteria and to write. The signature of findAll() is designed with 03-05 already in mind: it takes criteria in internal units (cents) and returns items and a total, because with SQL those will be two queries.
// src/repositories/coffees-memory.js (extended version)
const coffees = [
/* ... cof_001 and cof_002 as in 03-02 ... */
];
/** Counter for generating new ids. In 03-05 the database will do it. */
let nextId = 3;
function generateId() {
return `cof_${String(nextId++).padStart(3, '0')}`; // cof_003, cof_004...
}
export const coffeeRepository = {
/**
* Searches with filters, sorting and pagination.
* @returns {{items: object[], total: number}}
* 'total' is the number of matches BEFORE paginating: the client
* needs to know how many there are in total, not how many fit on the page.
*/
findAll(criteria = {}) {
const {
origin,
roast,
priceMinCents,
priceMaxCents,
available,
q,
sort = [{ field: 'id', descending: false }],
limit = 20,
offset = 0,
} = criteria;
let result = coffees.filter((coffee) => coffee.active);
// --- Filters (logical AND between all of them, as settled in 02-06) ---
if (origin !== undefined) {
result = result.filter((c) => c.origin.toLowerCase() === origin.toLowerCase());
}
if (roast !== undefined) {
result = result.filter((c) => c.roast === roast);
}
if (priceMinCents !== undefined) {
result = result.filter((c) => c.priceCents >= priceMinCents);
}
if (priceMaxCents !== undefined) {
result = result.filter((c) => c.priceCents <= priceMaxCents);
}
if (available !== undefined) {
result = result.filter((c) => (available ? c.stock > 0 : c.stock === 0));
}
if (q !== undefined) {
const term = q.toLowerCase();
result = result.filter(
(c) =>
c.name.toLowerCase().includes(term) ||
c.origin.toLowerCase().includes(term) ||
c.tastingNotes.some((note) => note.toLowerCase().includes(term))
);
}
const total = result.length; // ← before paginating
// --- Multi-field sorting ---
result = [...result].sort((a, b) => {
for (const { field, descending } of sort) {
const va = a[field];
const vb = b[field];
if (va === vb) continue;
const sign = va > vb ? 1 : -1;
return descending ? -sign : sign;
}
return 0;
});
// --- Pagination ---
const items = result.slice(offset, offset + limit);
return { items: items.map((c) => ({ ...c })), total };
},
findById(id) {
const coffee = coffees.find((c) => c.id === id && c.active);
return coffee ? { ...coffee } : undefined;
},
create(data) {
const coffee = {
id: generateId(),
...data,
createdAt: new Date().toISOString(),
active: true,
};
coffees.push(coffee);
return { ...coffee };
},
/** Applies partial changes to an existing coffee. */
update(id, changes) {
const index = coffees.findIndex((c) => c.id === id && c.active);
if (index === -1) return undefined;
coffees[index] = { ...coffees[index], ...changes };
return { ...coffees[index] };
},
/** SOFT delete (02-03): the record is kept, it stops being active. */
remove(id) {
const coffee = coffees.find((c) => c.id === id && c.active);
if (!coffee) return false;
coffee.active = false;
return true;
},
};Note that the repository speaks in cents (priceMinCents) and knows nothing about euros: the translation is the responsibility of the mapper and the controller. The unit boundary is as clear as the layer boundary.
- The coffee service
// src/services/coffees.js
import { coffeeRepository } from '../repositories/coffees-memory.js';
import { eurosToCents } from './mappers.js';
export const coffeeService = {
/** Returns a page of coffees and the total number of matches. */
list(criteria) {
return coffeeRepository.findAll(criteria);
},
/** Returns a coffee, or undefined if it does not exist. */
get(id) {
return coffeeRepository.findById(id);
},
/** Creates a coffee. Receives data that is already validated and in euros. */
create(data) {
return coffeeRepository.create({
name: data.name.trim(),
origin: data.origin.trim(),
roast: data.roast,
priceCents: eurosToCents(data.priceEuros),
stock: data.stock,
tastingNotes: data.tastingNotes ?? [],
description: data.description ?? null,
});
},
/** Replaces a coffee entirely (PUT). undefined if it does not exist. */
replace(id, data) {
if (!coffeeRepository.findById(id)) return undefined;
return coffeeRepository.update(id, {
name: data.name.trim(),
origin: data.origin.trim(),
roast: data.roast,
priceCents: eurosToCents(data.priceEuros),
stock: data.stock,
tastingNotes: data.tastingNotes ?? [],
description: data.description ?? null,
});
},
/** Partially modifies a coffee (PATCH). Only touches what arrives. */
modify(id, changes) {
if (!coffeeRepository.findById(id)) return undefined;
const partial = {};
if (changes.name !== undefined) partial.name = changes.name.trim();
if (changes.origin !== undefined) partial.origin = changes.origin.trim();
if (changes.roast !== undefined) partial.roast = changes.roast;
if (changes.priceEuros !== undefined) {
partial.priceCents = eurosToCents(changes.priceEuros);
}
if (changes.stock !== undefined) partial.stock = changes.stock;
if (changes.tastingNotes !== undefined) partial.tastingNotes = changes.tastingNotes;
// In Merge Patch, null means "delete this field" (02-03).
if (changes.description !== undefined) partial.description = changes.description;
return coffeeRepository.update(id, partial);
},
/** Soft delete. Returns true if it deleted something. */
remove(id) {
return coffeeRepository.remove(id);
},
};Notice what is not in this file: no res, no 404, no header. Only domain objects. And notice the underlying difference between replace and modify: the first receives the complete resource and substitutes it wholesale; the second only touches the fields that are present, distinguishing "absent" (leave alone) from null (delete the value). It is exactly the Merge Patch semantics of 02-03.
- The coffee controller
The controller is the customs post between HTTP and the domain. We start with reading, which is where the work of 02-06 lives.
// src/controllers/coffees.js
import { coffeeService } from '../services/coffees.js';
import { coffeeToRepresentation, toCollectionSummary, eurosToCents } from '../services/mappers.js';
import { buildLinkHeader } from './pagination.js';
/** Fields that may be sorted by. An allow-list: nothing else is accepted. */
const SORTABLE_FIELDS = new Set(['name', 'priceEuros', 'stock', 'createdAt', 'id']);
/** Translation from public field to internal field for sorting. */
const INTERNAL_FIELD = { priceEuros: 'priceCents' };
/**
* Interprets the 'sort' parameter of 02-06:
* ?sort=-priceEuros,name → price descending, then name ascending.
* 'id' is always appended at the end as a tie-break, so that pagination
* is stable: without a tie-break, two coffees at the same price can swap
* order between page 1 and page 2, and the client sees one repeated and one lost.
*/
function parseSort(text) {
if (!text) return [{ field: 'id', descending: false }];
const criteria = [];
for (const part of text.split(',')) {
const descending = part.startsWith('-');
const publicField = descending ? part.slice(1) : part;
if (!SORTABLE_FIELDS.has(publicField)) {
return { error: `The sort field '${publicField}' does not exist.` };
}
criteria.push({ field: INTERNAL_FIELD[publicField] ?? publicField, descending });
}
criteria.push({ field: 'id', descending: false });
return criteria;
}
/** Applies ?fields=id,name to an already-built representation. */
function project(representation, fields) {
if (!fields) return representation;
const requested = new Set(fields.split(','));
requested.add('id'); // the id is always returned: without it the response is useless
return Object.fromEntries(
Object.entries(representation).filter(([key]) => requested.has(key))
);
}
export const coffeeController = {
/** GET /v1/coffees */
list(req, res) {
const { origin, roast, priceMin, priceMax, available, q, sort, fields } = req.query;
// Pagination with its default values and maximums (02-06).
const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
const offset = req.query.offset === undefined ? 0 : Number(req.query.offset);
// Minimal checks: in 03-04 the validation middleware will do them.
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({
error: {
code: 'invalid_parameter',
message: "The 'limit' parameter must be an integer between 1 and 100.",
details: [{ field: 'limit', code: 'out_of_range', message: 'Maximum 100.' }],
},
});
}
const sortCriteria = parseSort(sort);
if (sortCriteria.error) {
return res.status(400).json({
error: {
code: 'invalid_parameter',
message: sortCriteria.error,
details: [{ field: 'sort', code: 'unknown_value', message: sortCriteria.error }],
},
});
}
const { items, total } = coffeeService.list({
origin,
roast,
// Prices arrive in euros and the repository speaks in cents.
priceMinCents: priceMin === undefined ? undefined : eurosToCents(Number(priceMin)),
priceMaxCents: priceMax === undefined ? undefined : eurosToCents(Number(priceMax)),
available: available === undefined ? undefined : available === 'true',
q,
sort: sortCriteria,
limit,
offset,
});
const links = buildLinkHeader({ req, limit, offset, total });
if (Object.keys(links).length > 0) res.links(links);
res.status(200).json({
data: items
.map(coffeeToRepresentation)
.map(toCollectionSummary)
.map((r) => project(r, fields)),
total,
});
},
/** GET /v1/coffees/:id */
get(req, res) {
const coffee = coffeeService.get(req.params.id);
if (!coffee) return notFound(res, req.params.id);
res.status(200).json(project(coffeeToRepresentation(coffee), req.query.fields));
},
};
/** The catalogue's 404 response. Provisional: in 03-07 ApiError centralises it. */
function notFound(res, id) {
return res.status(404).json({
error: {
code: 'coffee_not_found',
message: `There is no coffee with the identifier '${id}'.`,
details: [],
},
});
}Two security decisions appear here that are worth underlining. The first is the allow-list of sortable fields: if we accepted any name at all, with SQL behind us (03-05) we would be concatenating user text inside an ORDER BY, which is textbook SQL injection. The second is that limit is not silently clamped to 100 but returns 400: the contract of 02-06 decided it that way because clamping in silence makes the client believe it has received everything it asked for.
- The routes, now without logic
// src/routes/coffees.js
import { Router } from 'express';
import { coffeeController } from '../controllers/coffees.js';
export const coffeeRoutes = Router();
coffeeRoutes.get('/', coffeeController.list);
coffeeRoutes.post('/', coffeeController.create);
coffeeRoutes.get('/:id', coffeeController.get);
coffeeRoutes.put('/:id', coffeeController.replace);
coffeeRoutes.patch('/:id', coffeeController.modify);
coffeeRoutes.delete('/:id', coffeeController.remove);Six lines that read like the URI map of 02-02. When validation arrives in 03-04 and authentication in 03-06, they will be inserted here as middleware before the controller, and the file will still be readable at a glance:
// This is how it will look at the end of the module (a preview):
coffeeRoutes.post('/', authenticate, requireRole('employee'), validate(createCoffeeSchema), coffeeController.create);
- Collection parameters: filters and search
With the above in place, the filters of 02-06 already work:
# Combined filters (logical AND) and a price range
curl -s "http://localhost:3000/v1/coffees?roast=light&priceMax=15" | jq '.data[].name'# Text search: looks at name, origin and tasting notes
curl -s "http://localhost:3000/v1/coffees?q=chocolate" | jq '.data[].name'A clarification about ?q=: our in-memory implementation is an includes() with no accent folding and no tolerance for typos. With SQLite (03-05) we will use LIKE, and in a real shop this would end up in a dedicated search engine. The contract of 02-06 was deliberately cautious and only promises "partial matching on name, origin and tasting notes", which is what we can deliver.
- Sorting with
- and a tie-break by id
- and a tie-break by id{ "name": "Ethiopia Yirgacheffe", "priceEuros": 14.5 }
{ "name": "Colombia Huila", "priceEuros": 12.9 }# Nonexistent field → a 400 from the catalogue, not silently ignored
curl -s "http://localhost:3000/v1/coffees?sort=colour" | jq .error{
"code": "invalid_parameter",
"message": "The sort field 'colour' does not exist.",
"details": [
{ "field": "sort", "code": "unknown_value", "message": "The sort field 'colour' does not exist." }
]
}The tie-break by id added by parseSort is not a quirk: without it, sorting by a field with repeated values is undefined, and the order can vary between two identical queries. Since pagination slices that list up, the client would see items duplicated on one page and missing from another. Every paginated sort needs a tie-break on a unique key.
- Pagination and the
Link header
Link header// src/controllers/pagination.js
/**
* Builds the relations of the Link header (RFC 8288) for a collection
* paginated by offset, PRESERVING all the filters of the original
* request: without that, a client following 'next' loses the filter
* and receives results it never asked for.
*/
export function buildLinkHeader({ req, limit, offset, total }) {
// We rebuild the absolute URL of the current request.
const url = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);
/** Returns the current URL with a different offset. */
const withOffset = (value) => {
const copy = new URL(url);
copy.searchParams.set('limit', String(limit));
copy.searchParams.set('offset', String(value));
return copy.toString();
};
const links = {};
const lastOffset = Math.max(0, Math.floor((total - 1) / limit) * limit);
if (offset + limit < total) {
links.next = withOffset(offset + limit);
}
if (offset > 0) {
links.prev = withOffset(Math.max(0, offset - limit));
}
if (total > limit) {
links.first = withOffset(0);
links.last = withOffset(lastOffset);
}
return links;
}The fine points of this function:
new URL(...)withsearchParamsdoes the encoding for us. Concatenating strings by hand breaks the moment a filter contains a space or a non-ASCII character.nextonly exists if there are more items andprevonly if we are not on the first page. The absence of a link is information: it means "there is no more".firstandlastonly if there is more than one page, so as not to clutter the response when they are not needed.
Try it with limit=1 to force several pages:
Link: <http://localhost:3000/v1/coffees?limit=1&sort=name&offset=1>; rel="next", <http://localhost:3000/v1/coffees?limit=1&sort=name&offset=0>; rel="first", <http://localhost:3000/v1/coffees?limit=1&sort=name&offset=1>; rel="last"Notice that sort=name survives in all three links. And in the body, total is still 2 even though data carries a single item: it is the number of matches, not the size of the page.
- Field selection with
fields
fieldsThe id appears even though it was not asked for, because a response without an identifier does not let you navigate anywhere. It is the decision of 02-05: fields trims, but never below the usable minimum.
- Creating:
POST with 201 and Location
POST with 201 and Location// src/controllers/coffees.js (add to the coffeeController object)
/** POST /v1/coffees */
create(req, res) {
const data = req.body;
// Provisional check. In 03-04 validate(createCoffeeSchema) replaces it.
const requiredFields = ['name', 'origin', 'roast', 'priceEuros', 'stock'];
const missing = requiredFields.filter((field) => data?.[field] === undefined);
if (missing.length > 0) {
return res.status(400).json({
error: {
code: 'invalid_data',
message: 'Required fields are missing.',
details: missing.map((field) => ({
field,
code: 'required',
message: `The field '${field}' is required.`,
})),
},
});
}
const coffee = coffeeService.create(data);
const representation = coffeeToRepresentation(coffee);
// The Location header is MANDATORY on every creation (02-04).
res.set('Location', `/v1/coffees/${coffee.id}`);
res.status(201).json(representation);
},curl -i -s -X POST http://localhost:3000/v1/coffees \
-H "Content-Type: application/json" \
-d '{
"name": "Kenya Nyeri",
"origin": "Kenya",
"roast": "medium",
"priceEuros": 16.75,
"stock": 40,
"tastingNotes": ["blackcurrant", "tomato", "citrus"]
}'HTTP/1.1 201 Created
Location: /v1/coffees/cof_003
Content-Type: application/json; charset=utf-8
{"id":"cof_003","name":"Kenya Nyeri","origin":"Kenya","roast":"medium","priceEuros":16.75,"stock":40,"tastingNotes":["blackcurrant","tomato","citrus"],"description":null,"createdAt":"2026-03-14T10:41:22.113Z","_links":{"self":{"href":"/v1/coffees/cof_003"},"reviews":{"href":"/v1/coffees/cof_003/reviews"}}}Check the round trip of the cents: we sent 16.75, the service stored 1675 and the mapper returns 16.75. And note that description comes back as null even though we did not send it: fields always present, as we decided in 02-05.
Why the body of the 201 carries the full representation instead of being empty: it saves the client an immediate GET to learn the id and the createdAt the server has generated. Location is mandatory; the body is a very profitable courtesy.
- Replacing and modifying:
PUT and PATCH
PUT and PATCH// src/controllers/coffees.js (continued)
/** PUT /v1/coffees/:id — full replacement */
replace(req, res) {
const coffee = coffeeService.replace(req.params.id, req.body);
if (!coffee) return notFound(res, req.params.id);
res.status(200).json(coffeeToRepresentation(coffee));
},
/** PATCH /v1/coffees/:id — partial modification with Merge Patch */
modify(req, res) {
const type = req.get('Content-Type') ?? '';
// The contract of 02-03 accepts ONLY merge-patch (and application/json for
// convenience). JSON Patch (application/json-patch+json) is rejected with
// 415 and the Accept-Patch header saying what IS accepted.
const allowed = ['application/merge-patch+json', 'application/json'];
const isAllowed = allowed.some((t) => type.startsWith(t));
if (!isAllowed) {
res.set('Accept-Patch', 'application/merge-patch+json');
return res.status(415).json({
error: {
code: 'unsupported_format',
message: `The type '${type}' is not accepted on PATCH. Use application/merge-patch+json.`,
details: [],
},
});
}
const coffee = coffeeService.modify(req.params.id, req.body);
if (!coffee) return notFound(res, req.params.id);
res.status(200).json(coffeeToRepresentation(coffee));
},# Correct PATCH: only the stock is touched
curl -s -X PATCH http://localhost:3000/v1/coffees/cof_001 \
-H "Content-Type: application/merge-patch+json" \
-d '{"stock": 95}' | jq '{name, stock, priceEuros}'The name and the price remain untouched: that is exactly what PATCH promises and what distinguishes it from PUT, which would have required resending the whole resource.
# PATCH with JSON Patch: 415 + Accept-Patch
curl -i -s -X PATCH http://localhost:3000/v1/coffees/cof_001 \
-H "Content-Type: application/json-patch+json" \
-d '[{"op":"replace","path":"/stock","value":95}]' | head -5HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Content-Type: application/json; charset=utf-8The Accept-Patch header is what turns a rejection into a useful response: it does not just say "not this", it says "this instead". It is the same philosophy as Allow on a 405.
- Deleting: soft
DELETE with 204
DELETE with 204// src/controllers/coffees.js (continued)
/** DELETE /v1/coffees/:id — soft delete */
remove(req, res) {
const deleted = coffeeService.remove(req.params.id);
if (!deleted) return notFound(res, req.params.id);
// 204 No Content: no body. Not {} and not null: NOTHING.
res.status(204).end();
},curl -i -s -X DELETE http://localhost:3000/v1/coffees/cof_003 | head -2
curl -s http://localhost:3000/v1/coffees/cof_003 | jq .error.codeThe record is still in the array with active: false —that is the soft delete of 02-03— but the API behaves as if it did not exist. It is kept because historical orders reference that coffee and deleting it for real would leave orphaned invoices.
A note on idempotency: a second DELETE on the same id returns 404. That does not break the idempotency of 02-03: the state of the server is identical after one DELETE or a hundred, which is what the definition requires. The response code may differ.
- Orders and action links driven by status
We add the order store and its read path, which is where selective hypermedia shines.
// src/repositories/orders-memory.js
const orders = [
{
id: 'ord_5001',
customerId: 'cus_842',
status: 'pending_payment',
items: [
{ coffeeId: 'cof_001', name: 'Ethiopia Yirgacheffe', quantity: 2, priceCents: 1450 },
],
totalCents: 2900, // €29.00 — the sum of the lines, in cents
createdAt: '2026-03-14T10:30:00Z',
paidAt: null,
shippedAt: null,
},
];
export const orderRepository = {
findAll({ customerId, status, limit = 20, offset = 0 } = {}) {
let result = [...orders];
if (customerId !== undefined) result = result.filter((o) => o.customerId === customerId);
if (status !== undefined) result = result.filter((o) => o.status === status);
const total = result.length;
// Default ordering from 02-06: the most recent first.
result.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id));
return {
items: result.slice(offset, offset + limit).map((o) => ({ ...o })),
total,
};
},
findById(id) {
const order = orders.find((o) => o.id === id);
return order ? { ...order } : undefined;
},
};// src/services/orders.js
import { orderRepository } from '../repositories/orders-memory.js';
export const orderService = {
list(criteria) {
return orderRepository.findAll(criteria);
},
get(id) {
return orderRepository.findById(id);
},
};// src/controllers/orders.js
import { orderService } from '../services/orders.js';
import { orderToRepresentation, toCollectionSummary } from '../services/mappers.js';
import { buildLinkHeader } from './pagination.js';
export const orderController = {
/** GET /v1/orders */
list(req, res) {
const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
const offset = req.query.offset === undefined ? 0 : Number(req.query.offset);
const { items, total } = orderService.list({
customerId: req.query.customerId,
status: req.query.status,
limit,
offset,
});
const links = buildLinkHeader({ req, limit, offset, total });
if (Object.keys(links).length > 0) res.links(links);
res.status(200).json({
data: items.map(orderToRepresentation).map(toCollectionSummary),
total,
});
},
/** GET /v1/orders/:id */
get(req, res) {
const order = orderService.get(req.params.id);
if (!order) {
return res.status(404).json({
error: {
code: 'order_not_found',
message: `There is no order with the identifier '${req.params.id}'.`,
details: [],
},
});
}
res.status(200).json(orderToRepresentation(order));
},
};// src/routes/orders.js
import { Router } from 'express';
import { orderController } from '../controllers/orders.js';
export const orderRoutes = Router();
orderRoutes.get('/', orderController.list);
orderRoutes.get('/:id', orderController.get);
// POST /v1/orders arrives in 03-04 (validation) and 03-05 (transaction with stock).And in src/routes/index.js, one more line:
Now for the interesting test:
{
"status": "pending_payment",
"totalEuros": 29,
"_links": {
"self": { "href": "/v1/orders/ord_5001" },
"customer": { "href": "/v1/customers/cus_842" },
"pay": { "href": "/v1/orders/ord_5001/payment", "method": "POST" },
"cancel": { "href": "/v1/orders/ord_5001/cancellation", "method": "POST" }
}
}This is the selective hypermedia of 01-05 and 02-05 in action: the order is pending_payment, so it offers pay and cancel, and it does not offer invoice or return, because right now they are not possible. Change the status to 'shipped' by hand in the repository, restart, and you will see invoice, shipment and return appear and pay disappear. Aroma Store's SPA draws its buttons from this object and does not need to replicate the state machine.
async/await and the throw trap in Express 4
async/await and the throw trap in Express 4Today everything is synchronous because the store is an array. In 03-05, with a database, and in 03-06, with bcrypt, the handlers will be async. And there lies a trap worth knowing about before you fall into it.
// An asynchronous handler that fails. Express 4 does NOT catch this error.
coffeeRoutes.get('/:id', async (req, res) => {
const coffee = await coffeeService.get(req.params.id);
if (!coffee) throw new Error('not found'); // ← it gets lost
res.json(coffeeToRepresentation(coffee));
});What happens exactly: an async function does not throw, it returns a rejected promise. Express 4 calls the handler and discards the returned value, so nobody is watching that promise. The result is the worst possible one: UnhandledPromiseRejection in the console and the request hanging until the client's timeout expires. No 500, no response: silence.
With synchronous code, by contrast, Express does catch it:
coffeeRoutes.get('/:id', (req, res) => {
throw new Error('Express does catch this one'); // → 500 with the default HTML page
});The solution is a five-line wrapper:
/** Wraps an async handler and routes any rejection to next(). */
export function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
// Usage:
coffeeRoutes.get('/:id', asyncHandler(coffeeController.get));Promise.resolve(...) works whether fn is async or not, and .catch(next) forwards the error to the error middleware. We will write this file properly in 03-07, along with the ApiError class and the middleware that gives it meaning; for today, keep the diagnosis, which is what saves you an afternoon of debugging.
As a note about the future: Express 5 already catches promise rejections from handlers automatically and makes the wrapper unnecessary. It is one of the strong reasons to migrate when the project allows it (02-07 on changes and migrations).
Common Mistakes and Tips
1. Putting business logic in the controller. If the controller calculates discounts or checks stock, that rule cannot be tested without HTTP nor reused from a script. The controller only translates.
2. Letting the service return status codes. return { status: 404, message: '...' } from a service is the broken boundary in disguise. The service returns data or throws a domain error (03-07); the controller decides the code.
3. Confusing total with the length of the page. total is the number of matches for the filter; data.length is what fits on this page. Returning data.length as total breaks the page-count calculation in every client.
4. Losing the filters in the Link header. Building next as a bare /v1/coffees?offset=20 is a classic failure: the client follows the link and receives the unfiltered collection. Always start from req.originalUrl.
5. Forgetting Location on the 201. It is mandatory in the contract. A client that creates a resource and does not know where it is has to guess the URL.
6. Returning a body on a 204. res.status(204).json({}) is self-contradictory: 204 means "there is no content". Use .end().
7. Converting euros into cents with euros * 100. 16.75 * 100 can yield 1674.9999999999998, and truncating loses a cent. Use Math.round, as eurosToCents does.
8. Accepting any field in sort. Without an allow-list, with SQL behind it that is direct injection. The allow-list is non-negotiable.
Tip: when you are unsure which layer something belongs in, ask yourself "would this still make sense if the API were a desktop application?". If the answer is yes, it belongs to the service. If it talks about headers, codes or URLs, it belongs to the controller.
Exercises
Exercise 1
Implement the available filter of 02-06 with its complete behaviour: ?available=true returns only coffees with stock > 0, ?available=false only the sold-out ones, and any other value (?available=yes, ?available=1) must produce 400 invalid_parameter instead of being treated as false. Explain why silence is worse than an error.
Exercise 2
The SPA team reports a bug: when walking through /v1/coffees?sort=roast&limit=1 page by page, coffee cof_002 appears twice and another one never appears at all. With 40 coffees in the catalogue and only three values of roast, diagnose the cause, explain why the order can change between two queries and say which line of code fixes it.
Exercise 3
Write the controller for GET /v1/customers/:id/orders (a subresource from the URI map of 02-02). It must return that customer's orders with the usual envelope and pagination, and 404 customer_not_found if the customer does not exist. Assume a customerService.get(id) is already available. What is the difference between this route and GET /v1/orders?customerId=cus_842, and why does the contract have both?
Solutions
Solution 1
In controllers/coffees.js, inside list, before calling the service:
let available;
if (req.query.available !== undefined) {
if (req.query.available !== 'true' && req.query.available !== 'false') {
return res.status(400).json({
error: {
code: 'invalid_parameter',
message: "The 'available' parameter only accepts 'true' or 'false'.",
details: [
{
field: 'available',
code: 'invalid_value',
message: `Received '${req.query.available}'.`,
},
],
},
});
}
available = req.query.available === 'true';
}And available is passed to the service instead of the inline conversion.
Why silence is worse: with the naive conversion available === 'true', the request ?available=yes is interpreted as false and returns the sold-out coffees, precisely the opposite of what the client asked for. The client receives a 200 OK with incorrect data and has no way of detecting it; the bug is discovered weeks later with real customers looking at an empty catalogue. An immediate 400 flags the error on the integrator's very first test. It is the principle of 02-06: an unknown parameter or value gets an explicit error, never a creative interpretation.
Solution 2
Cause: roast has only three possible values (light, medium, dark), so with 40 coffees there are huge groups of ties. When sorting only by roast, the order within each group is undefined: Array.prototype.sort does not guarantee stability with respect to a previous ordering if the starting array changes, and with SQL (03-05) the engine can return tied rows in whatever order is cheapest for it, which depends on the execution plan, the cache and the state of the indexes.
Why pagination fails: each page is an independent query. If in the query for page 1 cof_002 lands in position 10 and in the query for page 2 the engine puts it in position 25, that coffee appears on both pages and another one falls through the gap. It is the classic manifestation of the problem, and that is why it is so bewildering: the failure depends on the data distribution and does not reproduce with two test coffees.
What fixes it: the last line of parseSort:
By appending id —which is unique— as the last criterion, the total ordering becomes completely determined and identical across every query. The general lesson: any sort that is going to be paginated must end in a unique key.
Solution 3
// src/controllers/customers.js
import { customerService } from '../services/customers.js';
import { orderService } from '../services/orders.js';
import { orderToRepresentation, toCollectionSummary } from '../services/mappers.js';
import { buildLinkHeader } from './pagination.js';
export const customerController = {
/** GET /v1/customers/:id/orders */
listOrders(req, res) {
// First, the customer must exist: if not, the customer's 404, not an empty list.
if (!customerService.get(req.params.id)) {
return res.status(404).json({
error: {
code: 'customer_not_found',
message: `There is no customer with the identifier '${req.params.id}'.`,
details: [],
},
});
}
const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
const offset = req.query.offset === undefined ? 0 : Number(req.query.offset);
const { items, total } = orderService.list({
customerId: req.params.id, // ← the id comes from the PATH, not the query
status: req.query.status,
limit,
offset,
});
const links = buildLinkHeader({ req, limit, offset, total });
if (Object.keys(links).length > 0) res.links(links);
res.status(200).json({
data: items.map(orderToRepresentation).map(toCollectionSummary),
total,
});
},
};Difference from GET /v1/orders?customerId=cus_842:
/v1/customers/cus_842/orders |
/v1/orders?customerId=cus_842 |
|
|---|---|---|
| Nonexistent customer | 404 customer_not_found |
200 with an empty list |
| Semantics | "the orders of this customer" | "all orders, filtered" |
| Typical consumer | The SPA's customer area | The internal panel |
| Permissions (03-06) | The customer themselves | Role employee |
Why both exist: they are different viewpoints on the same data. The subresource expresses a relationship of belonging, it is what the SPA navigates to from the customer's record and it allows a natural authorisation rule ("you only see what is yours"). The filtered collection is the internal panel's tool, combining customerId with status and dateFrom. What matters, and the reason for this exercise, is that the implementation is not duplicated: both controllers call the same orderService.list. Duplication would be the problem; two routes into a single service is not.
Conclusion
The API now implements the coffee contract from beginning to end. You know what req carries —and that everything arrives as text, that a repeated parameter becomes an array and that headers are read in lowercase— and what you control about res, including res.links() for the RFC 8288 Link header. The collection parameters of 02-06 genuinely work: filters combined with logical AND, ?q= search, sorting with - and a mandatory tie-break by id, pagination with its defaults and a maximum that returns 400 rather than clamping in silence, and fields to trim the representation. The write cycle is complete: 201 with Location, PUT for replacement, PATCH with Merge Patch and its 415 accompanied by Accept-Patch, and a soft DELETE with 204 and no body.
But what will pay off most from now on is the separation into layers. The routes are six readable lines; the controllers translate HTTP and know nothing about business; the services have never seen a req in their lives; the mapper concentrates the representation rules of 02-05 into a single file, including the action _links that appear and disappear depending on the order's status. Thanks to that separation, in 03-05 we will be able to swap the array for SQLite by touching one import, and in 03-08 we will be able to test the services without starting a server.
Two things are obviously still lame, and both have a lesson of their own. The first is those hand-written checks —required fields, limit, sort— repeated, incomplete and with messages written one at a time: a body with priceEuros: "very expensive" goes through unopposed and ends up stored as NaN. In 03-04, Input Data Validation, we will write the Zod schemas for coffees and orders and a single validate(schema, source) middleware that rejects strictly, coerces the types of the query parameters and returns all the failures at once in details, exactly as we settled in 02-04.
REST API Course: Principles of Designing and Developing RESTful APIs
Module 1: Introduction to RESTful APIs
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
