In the previous lesson we left a project with empty folders, installed dependencies and validated configuration, but unable to answer anything at all. Today that changes: by the end you will have an Express server listening on http://localhost:3000 that serves GET /v1/coffees and GET /v1/coffees/cof_001 with the exact format we settled on in module 2, a health endpoint, and a 404 that already speaks the language of the contract. Along the way we will understand the one idea you really must understand in order to work with Express —middleware—, we will see why separating app.js from server.js is an architectural decision and not a whim, and we will mount the Router under /v1, which is where the path versioning decided in 02-07 stops being a diagram and becomes code.
Contents
- What Express is and what it is not
- The central concept: middleware
- The life cycle of a request
src/app.jsandsrc/server.js: why they are separate- First start-up and the health endpoint
- Built-in middleware:
express.jsonandexpress.urlencoded - Express's
Routerand mounting/v1 - In-memory data:
src/repositories/coffees-memory.js - The first coffee routes
- Routes with parameters and
req.params - Sending responses:
res.status,res.json,res.set,res.sendStatus - The generic 404 with the contract's error format
- Testing it all with
curl - Request logging:
morganand how far we go today
- What Express is and what it is not
Node includes an http module with which you can already put together a server:
// A server using Node's http module, without Express. Just to see the difference.
import { createServer } from 'node:http';
const server = createServer((req, res) => {
if (req.url === '/v1/coffees' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ data: [], total: 0 }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
}
});
server.listen(3000);It works, and it teaches something important: Express does nothing you could not do yourself. But that if/else grows unmanageable the moment there are twenty routes, path parameters, JSON bodies to parse and behaviour common to every request.
Express is a thin layer over node:http that contributes exactly four things:
| Contributes | Without Express | With Express |
|---|---|---|
| Routing | if (req.url === ...) with hand-written regular expressions |
app.get('/v1/coffees/:id', handler) |
| Middleware | Chaining functions by hand | An ordered chain with app.use() |
Helpers on req |
Parsing the query string and the body yourself | req.query, req.params, req.body |
Helpers on res |
writeHead + end + JSON.stringify |
res.status(200).json(object) |
And it is just as important to know what it is not: Express is not a "batteries included" framework. It brings no ORM, no validation, no authentication, no mandatory folder structure. All of that is on you —which is why this module has eight lessons. In exchange, there is no magic: everything that happens during a request is written in one of your own files. For learning how an API really works it is the best possible choice. In 05-03 we will compare Express with Fastify, NestJS and others.
- The central concept: middleware
A middleware is a function that receives the request, may do something with it and decides whether to pass it on to the next function in the chain. Its entire definition fits into one signature:
function myMiddleware(req, res, next) {
// 1. It can read or modify req
// 2. It can read or write res
// 3. It calls next() to hand over the turn... or it responds and finishes
next();
}| Parameter | What it is |
|---|---|
req |
Request object: URL, method, headers, parameters, body |
res |
Response object: status, headers, body to return |
next |
Function that hands control to the next middleware in the chain |
And three rules that explain 90% of a beginner's problems with Express:
- Middleware run in the order in which they are registered. There are no priorities and no magic: it is a list, from top to bottom.
- If a middleware neither calls
next()nor responds, the request hangs until the client gives up. It is the most frequent mistake. - If a middleware responds (
res.json(...)), the chain ends there. Callingnext()afterwards causes the notoriousERR_HTTP_HEADERS_SENT.
An example with three chained middleware, to see the ordering live:
app.use((req, res, next) => {
console.log('1: the request comes in');
next(); // hand over the turn
});
app.use((req, res, next) => {
console.log('2: my turn now');
req.receivedAt = Date.now(); // I can enrich req for the ones that follow
next();
});
app.get('/v1/coffees', (req, res) => {
console.log('3: final handler');
res.json({ data: [], total: 0 }); // I respond: the chain ends here
});A route handler (app.get, app.post) is also a middleware; it is simply conditioned on a method and a path. That uniformity is the key to Express's design: everything is the same thing. The validation of 03-04, the authentication of 03-06 and the error handling of 03-07 will all be middleware with this very signature.
- The life cycle of a request
This is the complete journey a request will take through Aroma Store by the end of the module. Today we build the grey boxes; the rest arrive in the lessons indicated.
graph TD
A[Client: curl / SPA] --> B[express.json parses the body]
B --> C[Router mounted at /v1]
C --> D{Does any route match?}
D -->|No| E[404 route_not_found]
D -->|Yes| F[Validation 03-04 and authentication 03-06]
F --> G[Controller 03-03]
G --> H[Service and repository]
H --> I[res.status.json]
F -->|Error| J[Error middleware 03-07]
E --> J
J --> K[Response in the contract's format]
What you have to retain: a request travels down the chain from top to bottom and leaves through one of two exits, a normal response or the error middleware. Nothing else.
src/app.js and src/server.js: why they are separate
src/app.js and src/server.js: why they are separateAlmost every Express tutorial puts the creation of the app and the call to listen() in a single file. We do not, and the reason is concrete:
| File | Responsibility | Knows about… |
|---|---|---|
src/app.js |
Building the application: middleware and routes | Express |
src/server.js |
Starting the process: port, signals, shutdown | Operating system, network |
The payoff shows up in 03-08. Supertest, the tool we will use to test the API, accepts Express's app object and fires requests at it without opening any TCP port. If app.js called listen(), every test file would occupy port 3000 and two suites running in parallel would clash with EADDRINUSE. By separating them, the app is a reusable object and the port is a deployment detail.
We start with src/app.js:
// src/app.js
import express from 'express';
// We create the application instance. It is not listening on any port yet.
export const app = express();
// By default Express adds the 'X-Powered-By: Express' header, which reveals
// the server technology while contributing nothing. Always remove it (see 04-02).
app.disable('x-powered-by');
// Health endpoint: checks that the process is alive and responding.
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
version: '1.0.0',
timestamp: new Date().toISOString(),
});
});And src/server.js:
// src/server.js
import { app } from './app.js';
import { environment } from './config/environment.js';
// listen() opens the TCP socket and leaves the process listening.
const server = app.listen(environment.port, () => {
console.log(`Aroma Store API listening on ${environment.baseUrl}/v1`);
console.log(`Environment: ${environment.nodeEnv}`);
});
// Graceful shutdown: when the system asks us to stop (Ctrl+C or the
// orchestrator during a deployment), we stop accepting new connections and
// wait for the in-flight requests to finish before exiting.
function shutdownGracefully(signal) {
console.log(`\nReceived signal ${signal}. Shutting down the server...`);
server.close(() => {
console.log('Server closed. Goodbye.');
process.exit(0);
});
}
process.on('SIGINT', () => shutdownGracefully('SIGINT')); // Ctrl+C
process.on('SIGTERM', () => shutdownGracefully('SIGTERM')); // docker stop, kubernetesAbout the graceful shutdown: without it, Ctrl+C cuts everything off abruptly and a half-answered request dies with no response. With server.close(), the socket stops accepting new clients but the live requests finish. In 03-05 we will add the database shutdown here, and in 03-07 the capture of the process's unhandled errors.
Notice the direction of the imports: server.js imports app.js, never the other way round.
- First start-up and the health endpoint
In another terminal:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 71
ETag: W/"47-mQb..."
Date: Sat, 14 Mar 2026 10:30:00 GMT
Connection: keep-alive
{"status":"ok","version":"1.0.0","timestamp":"2026-03-14T10:30:00.000Z"}Three details of that response are worth pausing on:
Content-Type: application/json; charset=utf-8was set automatically byres.json(). It is what we declared in the contract.ETagalso comes as standard. It is the basis of conditional caching, which we cover in 04-06.- There is no
X-Powered-By, thanks toapp.disable.
Why is /health outside /v1? Because it is not part of the public API: it is not consumed by the SPA or Aroma Mobile, but by the load balancer and the monitoring system. It is not part of the versioned contract, so it must not be versioned. It is the exception that proves the rule of 02-07.
- Built-in middleware:
express.json and express.urlencoded
express.json and express.urlencodedWhen a POST arrives with a JSON body, the body travels as a stream of bytes. Without help, req.body is undefined. express.json() is a middleware that reads that stream, parses it and leaves the object in req.body.
Add it to src/app.js, before the routes:
// src/app.js (add after app.disable)
// Parses JSON bodies and leaves them in req.body.
app.use(
express.json({
// Size limit: above it, respond with 413. Without a limit, anyone
// can bring the process down by sending a 2 GB body (04-02).
limit: '100kb',
// Which Content-Type is accepted as JSON. We add merge-patch because the
// contract of 02-03 requires PATCH with application/merge-patch+json.
type: ['application/json', 'application/merge-patch+json'],
})
);
// Parses forms (Content-Type: application/x-www-form-urlencoded).
// Our API is JSON, but we keep it in case an HTML form calls an
// endpoint: better a clear 400 than an empty, inexplicable req.body.
app.use(express.urlencoded({ extended: false, limit: '10kb' }));| Option | What it controls | Chosen value |
|---|---|---|
limit |
Maximum body size | 100kb (plenty for an order with 50 lines) |
type |
Which Content-Type it processes |
JSON and Merge Patch |
strict (true by default) |
Only accepts objects and arrays at the root | Kept as is |
extended (in urlencoded) |
Allow nested objects in the form | false, we do not need them |
Two consequences you should know about right away. The first: if the client sends malformed JSON, express.json() throws a SyntaxError which today produces an ugly HTML error page; in 03-07 we will turn it into a 400 invalid_data from the contract. The second: if the client does not send Content-Type: application/json, the middleware parses nothing and req.body stays as {}; it is the number one cause of "my POST arrives empty".
- Express's
Router and mounting /v1
Router and mounting /v1Putting every route in app.js works with two of them and is untenable with twenty-four. A Router is a mini Express application: it has its own routes and its own middleware, and it is mounted under a prefix.
We are going to create two files. First, the coffee router (src/routes/coffees.js), which for now only declares its relative routes:
// src/routes/coffees.js
import { Router } from 'express';
export const coffeeRoutes = Router();
// Routes are declared RELATIVE to the mount point.
// '/' here will end up being '/v1/coffees' once it is mounted.
coffeeRoutes.get('/', (req, res) => {
res.json({ data: [], total: 0 });
});And now the aggregator (src/routes/index.js), which brings together all the version 1 routers:
// src/routes/index.js
import { Router } from 'express';
import { coffeeRoutes } from './coffees.js';
export const v1Routes = Router();
// Each collection from the URI map of 02-02 is mounted under its prefix.
v1Routes.use('/coffees', coffeeRoutes);
// As the module progresses these will be added here:
// v1Routes.use('/customers', customerRoutes); → 03-06
// v1Routes.use('/orders', orderRoutes); → 03-03
// v1Routes.use('/sessions', sessionRoutes); → 03-06And it is mounted in src/app.js:
// src/app.js (add after the parsers)
import { v1Routes } from './routes/index.js';
// THIS is where the path versioning decided in 02-07 lives: everything
// hanging off v1Routes answers under /v1 and only under /v1.
app.use('/v1', v1Routes);The result is a composition of prefixes across three levels:
| Level | File | Prefix contributed |
|---|---|---|
| Application | app.js |
/v1 |
| Aggregator | routes/index.js |
/coffees |
| Resource router | routes/coffees.js |
/ or /:id |
| Result | /v1/coffees, /v1/coffees/:id |
And here is the real payoff of 02-07 turned into code: the day a v2 exists, you create src/routes/v2/ and add app.use('/v2', v2Routes). The two versions coexist in the same process, sharing services where the behaviour does not change and diverging where it does. Without this mounting, "coexisting versions" would be a nice phrase that is impossible to implement.
- In-memory data:
src/repositories/coffees-memory.js
src/repositories/coffees-memory.jsWe do not have a database yet —that is 03-05— so the coffees will live in an array. But we already place it in the repositories folder and behind an interface, because the stated goal is to replace it with SQLite without the rest of the code noticing.
// src/repositories/coffees-memory.js
/**
* In-memory store of coffees.
*
* IMPORTANT: this is the INTERNAL MODEL, not the public representation.
* Money is stored in WHOLE CENTS (priceCents), as we decided in 02-05:
* €14.50 is 1450 cents. Never a float for money, because
* 0.1 + 0.2 !== 0.3 in floating point and one cent lost per order is
* an accounting discrepancy at the end of the month.
*/
const coffees = [
{
id: 'cof_001',
name: 'Ethiopia Yirgacheffe',
origin: 'Ethiopia',
roast: 'light',
priceCents: 1450,
stock: 120,
tastingNotes: ['citrus', 'floral', 'black tea'],
description: null,
createdAt: '2026-01-15T09:00:00Z',
active: true,
},
{
id: 'cof_002',
name: 'Colombia Huila',
origin: 'Colombia',
roast: 'medium',
priceCents: 1290,
stock: 80,
tastingNotes: ['chocolate', 'caramel', 'nutty'],
description: null,
createdAt: '2026-01-20T11:15:00Z',
active: true,
},
];
export const coffeeRepository = {
/** Returns every active coffee. Defensive copy: nobody mutates the array. */
findAll() {
return coffees.filter((coffee) => coffee.active).map((coffee) => ({ ...coffee }));
},
/** Returns a coffee by its id, or undefined if it does not exist. */
findById(id) {
const coffee = coffees.find((c) => c.id === id && c.active);
return coffee ? { ...coffee } : undefined;
},
};Two decisions that look minor and are not:
- The method names (
findAll,findById) are the repository's interface. In 03-05 we will writecoffees-sqlite.jswith exactly the same names, and switching from one to the other will mean changing oneimport. - Copies are returned (
{ ...coffee }), not the array's references. If we returned the reference, any layer above could modify the "store" by accident. With a real database this is impossible by construction; in memory it has to be enforced by hand.
- The first coffee routes
Now we connect the repository to the router. Replace the contents of src/routes/coffees.js:
// src/routes/coffees.js
import { Router } from 'express';
import { coffeeRepository } from '../repositories/coffees-memory.js';
export const coffeeRoutes = Router();
/**
* Converts the internal model into the contract's public representation.
*
* PROVISIONAL: in 03-03 this function moves to src/services/mappers.js,
* which is where it belongs. Here it saves us from introducing layers too early.
*/
function toRepresentation(coffee) {
return {
id: coffee.id,
name: coffee.name,
origin: coffee.origin,
roast: coffee.roast,
// Cents → euros with two decimals. Number() turns it back into a number
// so that the JSON carries 14.5 and not the string "14.50".
priceEuros: Number((coffee.priceCents / 100).toFixed(2)),
stock: coffee.stock,
tastingNotes: coffee.tastingNotes,
// Fields that are always present, with null when there is no value (02-05).
description: coffee.description ?? null,
createdAt: coffee.createdAt,
_links: {
self: { href: `/v1/coffees/${coffee.id}` },
},
};
}
// GET /v1/coffees → collection with the contract's envelope
coffeeRoutes.get('/', (req, res) => {
const coffees = coffeeRepository.findAll();
res.status(200).json({
data: coffees.map(toRepresentation),
total: coffees.length,
});
});
// GET /v1/coffees/:id → single item, no envelope
coffeeRoutes.get('/:id', (req, res) => {
const coffee = coffeeRepository.findById(req.params.id);
if (!coffee) {
// Minimal check and a hand-written response. In 03-07 this will be a
// throw of ApiError formatted by a single error middleware.
return res.status(404).json({
error: {
code: 'coffee_not_found',
message: `There is no coffee with the identifier '${req.params.id}'.`,
details: [],
},
});
}
res.status(200).json(toRepresentation(coffee));
});Important details in this file:
- The envelope only appears on the collection.
GET /v1/coffeesreturns{data, total};GET /v1/coffees/:idreturns the bare object. It is exactly what we decided in 02-05. return res.status(404)...: thereturngives nothing useful back to Express, but it stops the function from running on. Without it, execution would carry on tores.status(200)and causeERR_HTTP_HEADERS_SENT.totalis today the length of the array. When 03-03 brings filters and pagination,totalwill be the number of items that match the filter, not the number returned in the page. That distinction costs bugs.?? null(nullish coalescing) returns the left-hand value unless it isnullorundefined. Do not confuse it with||, which would also replace0or the empty string.
- Routes with parameters and
req.params
req.params'/:id' declares a variable segment. Express captures whatever sits in that position and leaves it in req.params.id, always as a string.
| Declared route | URL received | req.params |
|---|---|---|
/:id |
/v1/coffees/cof_001 |
{ id: 'cof_001' } |
/:id/reviews |
/v1/coffees/cof_001/reviews |
{ id: 'cof_001' } |
/:id/reviews/:reviewId |
/v1/coffees/cof_001/reviews/rev_101 |
{ id: 'cof_001', reviewId: 'rev_101' } |
The fact that our identifiers are prefixed strings (cof_001) plays in our favour: there is nothing to convert and no need to worry about a parseInt returning NaN. It was a decision made in 02-02 and here it pays its first dividend.
A warning about ordering, which is the classic trap:
// WRONG: '/featured' is never reached. The '/:id' route matches first
// and the handler receives req.params.id === 'featured'.
coffeeRoutes.get('/:id', byIdHandler);
coffeeRoutes.get('/featured', featuredHandler);
// RIGHT: the specific first, the generic afterwards.
coffeeRoutes.get('/featured', featuredHandler);
coffeeRoutes.get('/:id', byIdHandler);Express walks the routes in declaration order and keeps the first one that matches. The concrete goes before the variable.
A very useful sibling is router.param(), which runs code every time a particular parameter appears —for example, loading the coffee and leaving it in req.coffee. We mention it so you know it exists; in this course we prefer to do it explicitly in the service.
- Sending responses:
res.status, res.json, res.set, res.sendStatus
res.status, res.json, res.set, res.sendStatus| Method | What it does | Example |
|---|---|---|
res.status(code) |
Sets the code. Sends nothing: it is chainable | res.status(201) |
res.json(obj) |
Serialises to JSON, sets the Content-Type and sends |
res.json({ id: 'cof_001' }) |
res.send(x) |
Sends text, HTML or a buffer, guessing the type | res.send('ok') |
res.set(n, v) |
Adds a response header | res.set('Location', '/v1/coffees/cof_003') |
res.sendStatus(code) |
Sets the code and sends its standard text as the body | res.sendStatus(204) |
res.end() |
Ends the response with no body | res.status(204).end() |
Three practical warnings:
// 1. res.status() on its own does NOT respond. This leaves the request hanging:
res.status(204);
// Correct for a 204 (no body, like the DELETE in 02-03):
res.status(204).end();
// 2. res.sendStatus(204) sends the body "No Content" as PLAIN TEXT.
// For a 204 it is harmless, but getting used to it leads to mistakes
// such as res.sendStatus(404), which returns "Not Found" in text/plain
// instead of the contract's error format. Avoid it in this API.
// 3. Headers are set BEFORE sending the body. Afterwards it is too late.
res.set('Location', '/v1/coffees/cof_003');
res.status(201).json(representation);In this module we will almost always use the same pattern: res.set(...) for the contract's headers and res.status(...).json(...) for the body.
- The generic 404 with the contract's error format
If you request GET /v1/nonexistent, no route matches and Express answers with its default 404: an HTML page with the text Cannot GET /v1/nonexistent. For a consumer expecting JSON, that breaks the contract on three fronts: the wrong Content-Type, an unknown body shape and a leak of the framework.
We add a final middleware in src/app.js, after all the routes:
// src/app.js (at the end, after app.use('/v1', v1Routes))
// If the request gets this far, no route has matched.
// Since it carries no path, this app.use() runs for any request
// that has not already been served: it is the catch-all.
app.use((req, res) => {
res.status(404).json({
error: {
code: 'route_not_found',
message: `The resource ${req.method} ${req.originalUrl} does not exist.`,
details: [],
},
});
});Two clarifications about the catalogue. The code route_not_found was not in the catalogue of 02-04, which only had the specific 404s (coffee_not_found, order_not_found…). We are adding it now for the generic case —a URI that does not exist at all— and that is perfectly legitimate: the rule we set is that the catalogue only grows; adding a code is an additive change, withdrawing one would be breaking. And it must be documented in openapi.yaml, because an undocumented error code is not part of the contract.
The difference between the two 404s is worth being clear about:
| Situation | Code | Meaning for the consumer |
|---|---|---|
GET /v1/coffees/cof_999 |
coffee_not_found |
The route exists; that coffee does not |
GET /v1/coffeess |
route_not_found |
That URI does not exist in the API. Check the documentation |
The order is critical. This middleware must be registered last among the normal ones; if it came before app.use('/v1', v1Routes), it would answer 404 to absolutely everything. In 03-07 we will add the error middleware behind it, which is the only one that goes afterwards.
The complete src/app.js ends up like this:
// src/app.js
import express from 'express';
import { v1Routes } from './routes/index.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Body parsers ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 2. Health endpoint (outside /v1: it is not part of the contract) ---
app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
version: '1.0.0',
timestamp: new Date().toISOString(),
});
});
// --- 3. Versioned API ---
app.use('/v1', v1Routes);
// --- 4. Catch-all: no route has matched ---
app.use((req, res) => {
res.status(404).json({
error: {
code: 'route_not_found',
message: `The resource ${req.method} ${req.originalUrl} does not exist.`,
details: [],
},
});
});
// --- 5. (03-07) The error middleware will go here, always last ---Those five numbered blocks are the application's definitive ordering. Throughout the rest of the module we will only insert pieces between them; we will never change the sequence.
- Testing it all with
curl
curlWith npm run dev running, in another terminal:
{"data":[{"id":"cof_001","name":"Ethiopia Yirgacheffe","origin":"Ethiopia","roast":"light","priceEuros":14.5,"stock":120,"tastingNotes":["citrus","floral","black tea"],"description":null,"createdAt":"2026-01-15T09:00:00Z","_links":{"self":{"href":"/v1/coffees/cof_001"}}},{"id":"cof_002",...}],"total":2}If you have jq installed, it reads much better:
# 2. A specific item: no envelope
curl -s http://localhost:3000/v1/coffees/cof_001 | jq '.id, .priceEuros'Note the 14.5. The contract of 02-05 says "euros with two decimals", but JSON does not distinguish 14.50 from 14.5: they are the same number and that is how JSON.stringify serialises it. Two decimals are a matter of presentation format, the client's responsibility, not the transport's. What matters —and what we do control— is that the value is exact, and it is, because underneath it is 1450 whole cents. If a consumer literally needed the string "14.50", the price would have to be serialised as text, and that is a contract decision we already ruled out in 02-05.
# 3. Nonexistent coffee: a 404 from the catalogue
curl -i -s http://localhost:3000/v1/coffees/cof_999HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":{"code":"coffee_not_found","message":"There is no coffee with the identifier 'cof_999'.","details":[]}}Five checks, five responses that conform to the contract. In 03-08 we will turn exactly these calls into automated tests with Supertest, so that nobody ever has to run them by hand again.
- Request logging:
morgan and how far we go today
morgan and how far we go todayRight now the terminal says nothing when a request arrives, and that makes debugging uncomfortable. The minimal solution is a five-line middleware, which doubles as a perfect example of the signature we have learned:
// src/app.js (right after app.disable, before the parsers)
app.use((req, res, next) => {
const start = Date.now();
// 'finish' fires once the response has been fully sent,
// so by then we already know the status code and the duration.
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - start} ms)`);
});
next();
});The usual alternative is morgan (npm install morgan), a logging middleware with predefined formats:
import morgan from 'morgan';
app.use(morgan('dev')); // compact, colourised format
app.use(morgan('combined')); // standard Apache format, for productionEither will do for development. But let us be clear that this is not observability: there are no log levels, no structured JSON format, no correlation identifier, no metrics and no traces. console.log in production is a log nobody can query or aggregate. Serious observability —structured logs with pino, metrics, distributed traces and the traceId we will emit on 500s— is lesson 04-07. In 03-07 we will take the first step by generating that traceId to correlate an error with its log.
Common Mistakes and Tips
1. The request hangs forever. A middleware neither called next() nor responded. Walk the chain from top to bottom looking for the one that closes no path.
2. Error: Can't set headers after they are sent. You responded twice: a res.json() without return followed by another one, or a next() after responding. Always use return res.json(...) when the function could carry on.
3. Routes return 404 even though the file exists. It is almost always the mounting: remember that a router's routes are relative. If you write coffeeRoutes.get('/coffees', ...) and mount it at /v1/coffees, the real URL is /v1/coffees/coffees.
4. req.body is undefined. express.json() is missing, or it is registered after the routes. Order rules.
5. req.body arrives empty even though express.json() is in place. The client did not send Content-Type: application/json. In curl, the option is -H "Content-Type: application/json".
6. /:id declared before a fixed route. /featured stops existing. The specific comes first.
7. EADDRINUSE: address already in use :::3000. Another process holds the port, usually an earlier npm run dev that did not die. lsof -i :3000 identifies it; kill <pid> closes it.
8. Putting business logic inside the route. Today we queried the repository directly from the router because there is nothing else. It is the last time: in 03-03 it is split into controller and service, and it will not happen again.
Tip: define the middleware ordering once, comment it with numbers as we have done in app.js, and respect it. The hardest failures to diagnose in Express are always ordering failures.
Exercises
Exercise 1
Add to the coffee router the route GET /v1/coffees/:id/reviews, which is part of the URI map of 02-02. There are no reviews yet, so it must return an empty collection with the contract's envelope, but only if the coffee exists; if it does not, it must return 404 coffee_not_found. Explain why an empty collection is 200 and not 404.
Exercise 2
Write a middleware called serverHeader that adds the header Aroma-Version: 1.0.0 to every response. Register it in src/app.js in the right position and justify that position. Check the result with curl -i. Why Aroma- and not X-Aroma-?
Exercise 3
This code has three errors. Find them, explain what happens to the request in each case and write the corrected version.
coffeeRoutes.get('/:id', (req, res, next) => {
const coffee = coffeeRepository.findById(req.params.id);
if (!coffee) {
res.status(404).json({ error: 'not found' });
}
res.status(200);
res.json(toRepresentation(coffee));
next();
});Solutions
Solution 1
// src/routes/coffees.js (adding it BEFORE the '/:id' route is not necessary here,
// because '/:id/reviews' has two segments and does not collide with '/:id')
coffeeRoutes.get('/:id/reviews', (req, res) => {
const coffee = coffeeRepository.findById(req.params.id);
if (!coffee) {
return res.status(404).json({
error: {
code: 'coffee_not_found',
message: `There is no coffee with the identifier '${req.params.id}'.`,
details: [],
},
});
}
// The collection exists (the coffee exists) but it has no items.
res.status(200).json({ data: [], total: 0 });
});Why 200 and not 404: the requested resource is the collection of reviews of coffee cof_001, and that collection exists; it simply happens to be empty. A 404 would mean "this URI identifies no resource", and it would force the client to treat the perfectly normal case of "no reviews yet" as an error. The rule, which we already saw in 02-04: an empty collection is 200 with {"data": [], "total": 0}; a nonexistent resource is 404. Consistent with the decision of 02-05 that empty arrays are represented as [] and never as null or omitted.
Solution 2
// src/app.js (right after app.disable('x-powered-by'))
app.use((req, res, next) => {
res.set('Aroma-Version', '1.0.0');
next();
});Why that position: it must go before anything that could respond —routes, the 404, errors— because headers can only be set while the response has not yet been sent. Placed first, the header accompanies every response, including the 404s and the 500s.
Why Aroma- and not X-Aroma-: the X- prefix for non-standard headers was deprecated by RFC 6648 in 2012. The historical problem is that many X- headers ended up being standardised and then two names coexisted for the same thing (X-Forwarded-For is the canonical example). The current recommendation is to use your own vendor prefix without X-, which is why the contract of 02-05 settled on Aroma-.
Solution 3
| # | Error | What happens to the request |
|---|---|---|
| 1 | Missing return before res.status(404) |
With a nonexistent id it responds 404 and carries on executing; on reaching res.json(toRepresentation(coffee)) with coffee as undefined, it throws a TypeError after the headers have already been sent → ERR_HTTP_HEADERS_SENT |
| 2 | The error body does not follow the contract | It returns {"error": "not found"}, a string, instead of the object {error: {code, message, details}} settled in 02-04. Any client reading response.error.code gets undefined |
| 3 | next() after responding |
It hands control to the next middleware —the 404 catch-all— which will try to respond again on an already-sent response |
Corrected version:
coffeeRoutes.get('/:id', (req, res) => {
const coffee = coffeeRepository.findById(req.params.id);
if (!coffee) {
return res.status(404).json({
error: {
code: 'coffee_not_found',
message: `There is no coffee with the identifier '${req.params.id}'.`,
details: [],
},
});
}
res.status(200).json(toRepresentation(coffee));
});A note on next: it has been removed from the signature altogether. A final route handler does not need next because it always responds; declaring it invites calling it by mistake. In this module we will only use it in intermediate middleware and, from 03-07 onwards, to propagate errors with next(error).
Conclusion
There is a server now. And beyond the fact that it responds, what matters is what you have understood about it: that Express is an ordered chain of middleware with the (req, res, next) signature, that each piece decides whether to hand over the turn or respond, and that the registration order is the execution order, without exceptions. That idea is 90% of Express, and with it the validation of 03-04, the authentication of 03-06 and the error handling of 03-07 will slot in without surprises.
You have also taken three structural decisions that hold up the rest of the module. app.js separated from server.js, so that the application is an object the tests of 03-08 can use without opening a port. The Router mounted at /v1, which turns the path versioning of 02-07 into a three-level composed prefix and makes a future v2 a line of code rather than a migration. And the store behind a repository interface with findAll and findById, whose names will reappear identically in 03-05 when SQLite sits behind them. On top of that, the responses already comply with the contract: the {data, total} envelope on the collection, the bare object on the item, cents on the inside and euros on the outside, and a 404 with code, message and details.
What grates today is that all the logic lives inside the router, that the conversion to the public representation is a loose function with a "provisional" comment, and that we only know how to read. In 03-03, Handling Requests and Responses, we fix that: we will squeeze the req and res objects, split the code into routes → controllers → services, write the representation mapper where it belongs, and implement the complete write contract —POST with 201 and Location, PUT, PATCH with merge-patch+json and its 415, soft DELETE— along with the filtering, sorting, pagination and the Link header we designed in 02-06.
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
