Let us take stock of the debt we have piled up. In controllers/coffees.js there is a res.status(404).json({error: {...}}) repeated three times; in controllers/orders.js, another almost identical one; the validation middleware builds its own 400; the authentication one, two variants of 401 and a 403; the authentication service returns {error: 'email_already_registered'} and the order service {notFound: true}, two different conventions for the same thing; and ever since bcrypt brought async controllers, an unexpected exception leaves the request hanging with no response. Today all of that is replaced by two pieces: a domain error class, ApiError, that the services throw without knowing HTTP exists, and a single error middleware that translates it into the contract's format. By the end, no file outside src/middleware/errors.js will build an error response.
Contents
- Why centralise
- The
ApiErrorclass - The catalogue's error factories
- Services that throw instead of returning
- The
asyncHandler(fn)wrapper - Express's error middleware
ApiErrorversus an unexpected error- The
traceIdand its correlation with the logs - Translating the
ZodError - Translating SQLite's errors
- Translating
express.json()'s errors - The route 404 and the 405 with
Allow - The definitive ordering of
src/app.js - What must never be exposed in an error
- Uncaught process errors and graceful shutdown
- Reference table: situation → exception → response
problem+jsonand why we keep our own format
- Why centralise
The four problems with the scattered approach, in order of severity:
| Problem | Consequence |
|---|---|
| Inconsistent format | One 404 with details and another without; clients have to code defensively |
| Impossible changes | Adding traceId to every 5xx means touching twenty files |
| Accidental leaks | One res.json({ error: err.message }) publishes a system path or a SQL query |
| Contaminated layers | The service decides HTTP codes and stops being reusable outside the API |
The solution has two halves that have to be understood together:
- Services throw domain errors. They say what happened (
the coffee does not exist), not how to respond. They never mention HTTP codes. - A middleware translates. It is the only piece that knows the format of the error response, and therefore the only one that can guarantee it is always the same.
graph TD S[Service: throw ApiError] --> C[Controller] C -->|does not catch| E[Error middleware] V[Zod: ZodError] --> E B[SQLite: SqliteError] --> E J[express.json: SyntaxError] --> E X[Unexpected bug: TypeError] --> E E --> R[One single contract response]
- The
ApiError class
ApiError class// src/errors/api-error.js
/**
* The API's domain error.
*
* It carries the information needed to build the response, but it is thrown
* from the services without them knowing anything about Express: 'status' is
* a number, not a call to res.status().
*/
export class ApiError extends Error {
/**
* @param {number} status HTTP code (404, 409, ...)
* @param {string} code Code from the catalogue of 02-04 ('coffee_not_found')
* @param {string} message Human-readable message for the consumer
* @param {object[]} details List of specific problems; ALWAYS present
*/
constructor(status, code, message, details = []) {
super(message);
// Without this, error.name would be 'Error' and the logs would lose information.
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
// An explicit marker: the middleware uses it to tell an expected
// catalogue error from an unexpected program failure.
this.isApiError = true;
// Trims the stack so it starts where it was thrown, not in the constructor.
Error.captureStackTrace?.(this, ApiError);
}
}Two decisions. Extending Error preserves the stack trace and makes the class work with throw, try/catch and the debugging tools. And the isApiError marker rather than relying on instanceof alone: if for any reason two copies of the module were loaded —something that happens with certain test configurations or with duplicated dependencies— instanceof would fail while the property is still there.
- The catalogue's error factories
Writing new ApiError(404, 'coffee_not_found', '...') in twenty places reintroduces the very problem we are trying to solve: nothing guarantees that the code and the status match. The factories guarantee it.
// src/errors/api-error.js (continued)
export const errors = {
// --- 400 ---
invalidData(details = []) {
return new ApiError(
400,
'invalid_data',
'The request body contains validation errors.',
details
);
},
invalidParameter(details = []) {
return new ApiError(
400,
'invalid_parameter',
'The request parameters contain errors.',
details
);
},
// --- 401 ---
notAuthenticated(message = 'This operation requires authentication.') {
return new ApiError(401, 'not_authenticated', message);
},
tokenExpired() {
return new ApiError(401, 'token_expired', 'The token has expired. Sign in again.');
},
// --- 403 ---
permissionDenied(allowedRoles = []) {
const detail =
allowedRoles.length > 0
? ` One of these roles is required: ${allowedRoles.join(', ')}.`
: '';
return new ApiError(403, 'insufficient_permissions', `You do not have permission.${detail}`);
},
// --- 404 ---
notFound(type, id) {
// Entity → catalogue code map. A new type is added here.
const codes = {
coffee: 'coffee_not_found',
customer: 'customer_not_found',
order: 'order_not_found',
review: 'review_not_found',
cart: 'cart_not_found',
};
return new ApiError(
404,
codes[type] ?? 'route_not_found',
`There is no ${type} with the identifier '${id}'.`
);
},
routeNotFound(method, url) {
return new ApiError(404, 'route_not_found', `The resource ${method} ${url} does not exist.`);
},
// --- 405 / 409 / 413 / 415 ---
methodNotAllowed(method, path) {
return new ApiError(405, 'method_not_allowed', `${method} is not allowed on ${path}.`);
},
conflict(code, message, details = []) {
return new ApiError(409, code, message, details);
},
bodyTooLarge() {
return new ApiError(413, 'body_too_large', 'The body exceeds the maximum size (100 kB).');
},
unsupportedFormat(type) {
return new ApiError(
415,
'unsupported_format',
`The content type '${type}' is not accepted on this operation.`
);
},
// --- 500 ---
internalError() {
return new ApiError(500, 'internal_error', 'An unexpected error has occurred.');
},
};Note conflict(code, ...): the 409s share a status but not a code —insufficient_stock, order_already_paid, version_conflict, empty_cart— so the factory takes the code and guarantees only the 409. What matters is that the catalogue of 02-04 and this file are the same thing; if a code is not here, it does not exist.
- Services that throw instead of returning
Now the services get cleaned up. Before:
// BEFORE: ad hoc conventions the controller had to know about
get(id) {
return coffeeRepository.findById(id); // undefined if it does not exist
},After:
// src/services/coffees.js (modified)
import { errors } from '../errors/api-error.js';
export const coffeeService = {
/** Returns a coffee. Throws if it does not exist. */
get(id) {
const coffee = coffeeRepository.findById(id);
if (!coffee) throw errors.notFound('coffee', id);
return coffee;
},
replace(id, data) {
this.get(id); // throws 404 if it does not exist: no duplicated message
return coffeeRepository.update(id, { /* ...fields... */ });
},
remove(id) {
if (!coffeeRepository.remove(id)) throw errors.notFound('coffee', id);
},
};And the order service, with its business errors:
// src/services/orders.js (modified)
import { errors } from '../errors/api-error.js';
import { createOrderAtomically } from '../repositories/orders-sqlite.js';
export const orderService = {
getFor(id, user) {
const order = orderRepository.findById(id);
const isStaff = ['employee', 'administrator'].includes(user.role);
// A nonexistent order and somebody else's order give EXACTLY the same
// error, so as not to leak existence (03-06, section 14).
if (!order || (order.customerId !== user.id && !isStaff)) {
throw errors.notFound('order', id);
}
return order;
},
create({ customerId, items }) {
try {
const id = createOrderAtomically({ customerId, items });
return orderRepository.findById(id);
} catch (error) {
// The repository throws errors carrying 'domainCode' (03-05); here they
// become ApiError. The translation lives in the service because it is
// the service that decides that insufficient stock is a 409.
if (error.domainCode === 'insufficient_stock') {
throw errors.conflict('insufficient_stock', error.message);
}
if (error.domainCode === 'coffee_not_found') {
throw errors.notFound('coffee', 'in one of the order items');
}
throw error; // not ours: let the middleware treat it as a 500
}
},
pay(id, user) {
const order = this.getFor(id, user);
if (order.status === 'paid') {
throw errors.conflict('order_already_paid', `Order '${id}' has already been paid.`);
}
// ...mark as paid...
},
};That last throw error is important: whatever we do not know how to translate is propagated. Swallowing an unknown error "so nothing breaks" is the most effective way of letting a serious failure go unnoticed.
And the controller is reduced to its real role:
// src/controllers/coffees.js (modified)
get(req, res) {
const coffee = coffeeService.get(req.params.id); // if it fails, it throws
res.status(200).json(project(coffeeToRepresentation(coffee), req.query.fields));
},One line of work and one of response. There is no if (!coffee), no hand-written 404, no duplicated message.
- The
asyncHandler(fn) wrapper
asyncHandler(fn) wrapperWith synchronous controllers, Express 4 catches the throw and carries it to the error middleware. With async controllers —the customer and session ones already are, and all of them would be with PostgreSQL— it does not, exactly as we diagnosed in 03-03: the function returns a rejected promise that nobody observes, and the request hangs.
// src/middleware/async-handler.js
/**
* Wraps a handler so that any promise rejection ends up in next(error) and
* therefore in the error middleware.
*
* Promise.resolve() works the same whether fn is synchronous (it returns an
* already-resolved promise) or asynchronous, so you can wrap EVERYTHING
* without thinking about it.
*/
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}Usage in the routes:
// src/routes/coffees.js (modified)
import { asyncHandler } from '../middleware/async-handler.js';
coffeeRoutes.get('/', validate(coffeeQuerySchema, 'query'), asyncHandler(coffeeController.list));
coffeeRoutes.get('/:id', validate(coffeeIdParamsSchema, 'params'), asyncHandler(coffeeController.get));
coffeeRoutes.post(
'/',
authenticate,
requireRole('employee', 'administrator'),
validate(createCoffeeSchema),
asyncHandler(coffeeController.create)
);
// ...and so on for all of themWrap every handler, synchronous ones included. The cost is nil and it eliminates the hardest class of bug to detect: the day somebody turns a controller into async without remembering the wrapper, that route would stop responding to any error and nobody would notice until production.
There are packages that do this (express-async-errors, which patches Express when you import it) and Express 5 already does it out of the box: an async handler that rejects goes straight to the error middleware. It is the most practical reason to migrate. Our wrapper is explicit, it is four lines and it depends on nothing.
- Express's error middleware
// src/middleware/errors.js
import { ZodError } from 'zod';
import { ApiError, errors } from '../errors/api-error.js';
import { environment } from '../config/environment.js';
/**
* Error middleware.
*
* THE FOUR-ARGUMENT SIGNATURE IS MANDATORY. Express tells an error
* middleware from a normal one by counting the function's parameters:
* three means normal, four means error. That is why 'next' must be
* declared even when it is unused, and why ESLint needed the
* argsIgnorePattern: '^_' rule we configured in 03-01.
*/
// eslint-disable-next-line no-unused-vars
export function errorHandler(err, req, res, next) {
const apiError = translate(err);
// --- Logging for the team ---
if (apiError.status >= 500) {
// 5xx are OUR failures: they are logged in full, with a stack trace.
console.error(
JSON.stringify({
level: 'error',
traceId: req.traceId,
method: req.method,
path: req.originalUrl,
user: req.user?.id ?? null,
message: err.message,
stack: err.stack,
})
);
} else {
// 4xx are the client's failures: one informative line is enough.
console.warn(
JSON.stringify({
level: 'warn',
traceId: req.traceId,
method: req.method,
path: req.originalUrl,
code: apiError.code,
})
);
}
// --- Headers the contract demands in each case ---
if (apiError.status === 401) {
res.set('WWW-Authenticate', 'Bearer realm="api.aromastore.example"');
}
if (apiError.status === 405 && err.allowedMethods) {
res.set('Allow', err.allowedMethods.join(', '));
}
if (apiError.code === 'unsupported_format' && req.method === 'PATCH') {
res.set('Accept-Patch', 'application/merge-patch+json');
}
// --- The contract's body (02-04) ---
const body = {
error: {
code: apiError.code,
message: apiError.message,
details: apiError.details ?? [],
},
};
// traceId ONLY on 5xx, as we decided in 02-04.
if (apiError.status >= 500) {
body.error.traceId = req.traceId;
// In development it helps to see the cause; in production, NEVER.
if (environment.nodeEnv !== 'production') {
body.error.debug = err.message;
}
}
res.status(apiError.status).json(body);
}And the function that decides what each error is:
// src/middleware/errors.js (continued)
/** Turns any exception into an ApiError from the catalogue. */
function translate(err) {
// 1. It is already ours: used as it is.
if (err?.isApiError) return err;
// 2. A Zod validation error that escaped the validation middleware.
if (err instanceof ZodError) {
return errors.invalidData(
err.issues.map((i) => ({
field: i.path.join('.') || '(body)',
code: 'invalid_value',
message: i.message,
}))
);
}
// 3. Malformed JSON, detected by express.json().
if (err instanceof SyntaxError && 'body' in err) {
return new ApiError(400, 'invalid_data', 'The body is not valid JSON.', [
{ field: '(body)', code: 'malformed_json', message: err.message },
]);
}
// 4. Body too large (express.json's limit).
if (err?.type === 'entity.too.large') return errors.bodyTooLarge();
// 5. SQLite errors.
const fromSqlite = translateSqlite(err);
if (fromSqlite) return fromSqlite;
// 6. Anything else is a bug of ours: a 500 leaking nothing.
return errors.internalError();
}
ApiError versus an unexpected error
ApiError versus an unexpected errorThe distinction in step 6 is the heart of the middleware:
ApiError (expected) |
Unexpected error (a bug) | |
|---|---|---|
| Origin | Thrown deliberately by a service | TypeError, ReferenceError, a library failure |
| Status | Whatever the error says: 400, 404, 409… | Always 500 |
code |
From the catalogue | Always internal_error |
| Message to the client | Specific and useful | Generic: "An unexpected error has occurred" |
traceId |
No | Yes |
| Log | A one-line warning | A full error with a stack trace |
| Whose fault? | The client's | Ours |
An example of the second case. If one day a bug produces TypeError: Cannot read properties of undefined (reading 'priceCents'), the client receives:
{
"error": {
"code": "internal_error",
"message": "An unexpected error has occurred.",
"details": [],
"traceId": "trc_8f4a1c92"
}
}And the server logs keep the full detail, with the file, the line, the user and the path. The consumer gets what they need in order to ask for help; the team gets what it needs in order to fix it. That asymmetry is intentional and it is a security measure, not just an aesthetic one.
- The
traceId and its correlation with the logs
traceId and its correlation with the logs// src/middleware/trace.js
import { randomBytes } from 'node:crypto';
/**
* Assigns a unique identifier to every request.
* If it comes from a proxy or gateway that already generated one, it is
* respected: that way the trace is continuous across the whole system (04-07).
*/
export function assignTraceId(req, res, next) {
const inherited = req.get('Aroma-Trace-Id');
req.traceId = inherited ?? `trc_${randomBytes(4).toString('hex')}`;
// It is always returned, not only on errors: it lets the client refer to
// any request when opening a support ticket.
res.set('Aroma-Trace-Id', req.traceId);
next();
}The flow when something fails is this: the user sees traceId: "trc_8f4a1c92", opens a ticket with that identifier, and the team searches for that string in the logs and finds the exact request, with its method, its path, its user and the error's stack. Without a traceId, the investigation starts with "roughly what time was it?".
Note the prefix: Aroma-Trace-Id, not X-Trace-Id, following the decision of 02-05 and RFC 6648.
This is the first rung of observability. Structured logs with levels and pino, metrics, distributed traces with OpenTelemetry and correlation across services are lesson 04-07; here it is enough that every request has a name and that the name appears at both ends.
- Translating the
ZodError
ZodErrorThe validate middleware of 03-04 built its own response. Now it throws and stops knowing about HTTP:
// src/middleware/validation.js (modified)
import { errors } from '../errors/api-error.js';
export function validate(schema, source = 'body') {
return (req, res, next) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
const details = toDetails(result.error);
// The error middleware takes care of the format and the logging.
return next(
source === 'body' ? errors.invalidData(details) : errors.invalidParameter(details)
);
}
req[source] = result.data;
next();
};
}Calling next(error) with one argument skips every normal middleware and goes straight to the first error middleware. It is Express's standard mechanism for propagating failures from a middleware, and the counterpart of throw inside handlers.
The toDetails and translateCode functions stay where they were: they remain the validation layer's responsibility, because they know Zod's structure. What changes is that they no longer decide the shape of the response.
Step 2 of translate() is a safety net for ZodErrors thrown outside the middleware —a parse() inside a service, for instance. It should not happen, and if it does, the result is still correct.
- Translating SQLite's errors
better-sqlite3 throws errors carrying a very informative code field:
// src/middleware/errors.js (continued)
/** SQLite errors → catalogue errors. Returns null if it is not one. */
function translateSqlite(err) {
const code = err?.code;
if (typeof code !== 'string' || !code.startsWith('SQLITE_')) return null;
switch (code) {
case 'SQLITE_CONSTRAINT_UNIQUE':
case 'SQLITE_CONSTRAINT_PRIMARYKEY':
// Somebody tried to duplicate a unique value (an email, for instance).
return new ApiError(409, 'duplicate_resource', 'A resource with that unique value already exists.');
case 'SQLITE_CONSTRAINT_FOREIGNKEY':
// Something nonexistent is referenced: an order for a nonexistent customer.
return new ApiError(
409,
'invalid_reference',
'The operation references a resource that does not exist.'
);
case 'SQLITE_CONSTRAINT_CHECK':
// A schema CHECK (invalid roast, negative stock). If it gets here, the
// validation of 03-04 has a gap: it is logged as a 5xx so the team sees
// it, even though the blame seems to lie with the client.
return new ApiError(400, 'invalid_data', 'The data breaks a constraint of the model.');
case 'SQLITE_BUSY':
return new ApiError(503, 'service_unavailable', 'The database is busy. Please retry.');
default:
return null; // unknown: let it be a 500 with its full log
}
}Two warnings. The first: the message returned is not SQLite's. The original would say something like UNIQUE constraint failed: customers.email, revealing the name of the table and the column; that is exactly the kind of data an attacker uses to map your schema. The second: this translation is a safety net, not the first line. A duplicated email is already checked in the registration service (03-06) and returns a 409 email_already_registered with a far more useful message. Having the translation as well covers the race between two simultaneous registrations with the same address, where the prior check can pass and the database is the only one to notice.
A note for PostgreSQL: the codes are different —23505 for uniqueness, 23503 for foreign keys— but the structure of the function would be identical.
- Translating
express.json()'s errors
express.json()'s errorsTwo frequent cases that today produce Express's HTML responses:
# Malformed JSON: a quote is missing
curl -s -X POST http://localhost:3000/v1/coffees \
-H "Content-Type: application/json" \
-d '{"name": "Kenya, "origin": "Kenya"}' | jq{
"error": {
"code": "invalid_data",
"message": "The body is not valid JSON.",
"details": [
{
"field": "(body)",
"code": "malformed_json",
"message": "Unexpected token o in JSON at position 20"
}
]
}
}# A 2 MB body against a 100 kB limit
curl -s -X POST http://localhost:3000/v1/coffees \
-H "Content-Type: application/json" \
--data-binary @huge.json | jq .error.codeThe check err instanceof SyntaxError && 'body' in err deserves explaining: SyntaxError is a standard JavaScript class and can come from anywhere, but the body-parser package Express uses adds a body property with the text received. That property is what distinguishes "the client sent broken JSON" from "there is a badly written eval in the code".
About the message: exposing "Unexpected token o in JSON at position 20" is acceptable because it describes the client's own input, not our system, and it tells them exactly where to look. It is the exception that proves the rule of section 14.
- The route 404 and the 405 with
Allow
AllowThe catch-all of 03-02 now delegates:
// src/middleware/not-found.js
import { errors } from '../errors/api-error.js';
/** No route matched: 404 route_not_found. */
export function notFoundHandler(req, res, next) {
next(errors.routeNotFound(req.method, req.originalUrl));
}The 405 is different and more subtle: the URI exists, but not for that method. DELETE /v1/coffees (on the collection) is not in the URI map, but GET and POST are. Returning 404 would be lying; the right answer is 405 with the Allow header, mandatory under RFC 9110.
// src/middleware/not-found.js (continued)
import { ApiError } from '../errors/api-error.js';
/**
* Returns a handler that responds 405 with Allow.
* It is registered with router.all() at the end of each group of routes, so
* it is only reached if the URI matched but no method did.
*/
export function methodNotAllowed(...allowedMethods) {
return (req, res, next) => {
const error = new ApiError(
405,
'method_not_allowed',
`${req.method} is not allowed on ${req.baseUrl}${req.path}.`
);
// The error middleware will read this property to set Allow.
error.allowedMethods = [...allowedMethods, 'OPTIONS'];
next(error);
};
}// src/routes/coffees.js (at the end of the file, after all the routes)
import { methodNotAllowed } from '../middleware/not-found.js';
coffeeRoutes.all('/', methodNotAllowed('GET', 'POST'));
coffeeRoutes.all('/:id', methodNotAllowed('GET', 'PUT', 'PATCH', 'DELETE'));HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, OPTIONS
Content-Type: application/json; charset=utf-8
{"error":{"code":"method_not_allowed","message":"DELETE is not allowed on /v1/coffees.","details":[]}}router.all() intercepts any method on that path, and it must be declared after the specific routes: if it came first, it would swallow the legitimate GETs too. It is the same ordering rule from 03-02 applied at the end of the list.
- The definitive ordering of
src/app.js
src/app.js// src/app.js (the module's final version)
import express from 'express';
import { v1Routes } from './routes/index.js';
import { assignTraceId } from './middleware/trace.js';
import { notFoundHandler } from './middleware/not-found.js';
import { errorHandler } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Request identity: FIRST of all, so everything else can use it ---
app.use(assignTraceId);
// --- 2. Request logging (04-07 will replace it with structured logs) ---
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - start} ms)`);
});
next();
});
// --- 3. Body parsers ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 4. Health (outside /v1: not part of the contract) ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', version: '1.0.0', timestamp: new Date().toISOString() });
});
// --- 5. Versioned API ---
app.use('/v1', v1Routes);
// --- 6. No route matched ---
app.use(notFoundHandler);
// --- 7. Error middleware: ALWAYS LAST ---
app.use(errorHandler);Why the error middleware goes last, without exceptions. Express walks the chain in order; when somebody calls next(error), it looks forwards for the next middleware with four arguments. If you registered it before the routes, an error thrown in a route would find no handler ahead of it and would fall into Express's default handler: an HTML page with the full stack in development. An ordering mistake here leaks your code's stack trace to the internet.
- What must never be exposed in an error
| Do not expose | Example | Why |
|---|---|---|
| Stack traces | at /home/aroma/src/services/orders.js:42 |
Reveals system paths, project structure and your username |
| SQL queries | SELECT * FROM customers WHERE email = ... |
A map of the schema served to the attacker |
| Table or column names | UNIQUE constraint failed: customers.email |
Likewise |
| Versions | Express 4.18.2, SQLite 3.45 |
Lets an attacker look up known vulnerabilities of that exact version |
| Libraries' internal messages | ECONNREFUSED 10.0.3.14:5432 |
Reveals network topology and internal IPs |
| The existence of other people's resources | 403 instead of 404 |
Allows enumeration (03-06) |
| Distinguishing user from password | "That email does not exist" | Allows account enumeration |
The operational rule is the asymmetry between the two audiences:
| Response to the consumer | Log for the team | |
|---|---|---|
| Audience | Anyone, an attacker included | People with access to the system |
| Contents | Code, message, details, traceId |
Everything: stack, SQL, user, headers |
| Goal | That they know what to do | That the team can fix it |
The traceId is what makes it possible for the two views to be so different without losing the connection between them.
And a warning about the debug field we add in development: it is conditioned on environment.nodeEnv !== 'production'. Getting that condition right is critical; if NODE_ENV is not defined in production, internal messages would leak. It is one more argument for the start-up configuration validation we built in 03-01.
- Uncaught process errors and graceful shutdown
The middleware only sees what happens inside a request. A failure in a setTimeout, in an event handler or in a loose promise escapes Express and reaches the process:
// src/server.js (the module's final version)
import { app } from './app.js';
import { environment } from './config/environment.js';
import { closeDatabase } from './config/database.js';
const server = app.listen(environment.port, () => {
console.log(`Aroma Store API listening on ${environment.baseUrl}/v1`);
});
let shuttingDown = false;
function shutdownGracefully(reason, exitCode = 0) {
if (shuttingDown) return; // two signals in a row must not duplicate the shutdown
shuttingDown = true;
console.log(`Shutting down because of: ${reason}`);
server.close(() => {
closeDatabase();
console.log('Server and database closed.');
process.exit(exitCode);
});
// Safety net: if it has not finished within 10 s, the exit is forced.
// Without this, one open connection can block the shutdown forever.
setTimeout(() => {
console.error('Shutdown forced after the timeout.');
process.exit(1);
}, 10000).unref();
}
process.on('SIGINT', () => shutdownGracefully('SIGINT'));
process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));
/**
* An uncaught exception: the process is in an UNKNOWN state.
* It is logged and we exit. Trying to carry on is worse: there may be
* half-finished transactions, open files and corrupt state.
*/
process.on('uncaughtException', (error) => {
console.error(
JSON.stringify({ level: 'fatal', type: 'uncaughtException', message: error.message, stack: error.stack })
);
shutdownGracefully('uncaughtException', 1);
});
/** A promise rejected with no catch. Since Node 15, it also ends the process. */
process.on('unhandledRejection', (reason) => {
console.error(
JSON.stringify({ level: 'fatal', type: 'unhandledRejection', message: String(reason) })
);
shutdownGracefully('unhandledRejection', 1);
});Why exit instead of carrying on. It is counter-intuitive: "soldiering on" sounds more robust. It is not. An uncaught exception means the code reached a point nobody anticipated, and from then on you cannot reason about the state of the process: there may be an unclosed transaction, a locked file or a corrupt variable producing incorrect responses —which is worse than no response at all. The right thing is to log everything, shut down gracefully and let the supervisor bring up a clean process. That supervisor (systemd, Docker with restart: always, Kubernetes) is part of the deployment and is covered in 05-05.
And that is why graceful shutdown matters so much: between SIGTERM and the death of the process, server.close() stops accepting new connections but finishes those in flight. Without it, every deployment would cut off mid-response the clients that happened to be waiting.
- Reference table: situation → exception → response
| Situation | What is thrown | HTTP | code |
Extra header |
|---|---|---|---|---|
| Body with invalid fields | errors.invalidData(details) |
400 | invalid_data |
— |
| Unknown or out-of-range query parameter | errors.invalidParameter(details) |
400 | invalid_parameter |
— |
| Malformed JSON | body-parser's SyntaxError |
400 | invalid_data |
— |
Missing Authorization |
errors.notAuthenticated() |
401 | not_authenticated |
WWW-Authenticate |
| Expired token | errors.tokenExpired() |
401 | token_expired |
WWW-Authenticate |
| Insufficient role | errors.permissionDenied([...]) |
403 | insufficient_permissions |
— |
| Nonexistent coffee | errors.notFound('coffee', id) |
404 | coffee_not_found |
— |
| Somebody else's order | errors.notFound('order', id) |
404 | order_not_found |
— |
| Nonexistent URI | errors.routeNotFound(...) |
404 | route_not_found |
— |
| Method not supported on that URI | errors.methodNotAllowed(...) |
405 | method_not_allowed |
Allow |
| Out of stock | errors.conflict('insufficient_stock', ...) |
409 | insufficient_stock |
— |
| A second payment | errors.conflict('order_already_paid', ...) |
409 | order_already_paid |
— |
| Stale version | errors.conflict('version_conflict', ...) |
409 | version_conflict |
— |
| A 2 MB body | body-parser's entity.too.large |
413 | body_too_large |
— |
| PATCH with JSON Patch | errors.unsupportedFormat(type) |
415 | unsupported_format |
Accept-Patch |
| A program bug | TypeError and the like |
500 | internal_error |
— (and traceId in the body) |
| Database busy | SQLITE_BUSY |
503 | service_unavailable |
Retry-After |
This table is the executable summary of the catalogue of 02-04 and the reference you consult when adding a new endpoint. The right-hand column is the one most often forgotten.
problem+json and why we keep our own format
problem+json and why we keep our own formatIn 02-04 we compared our format with the standard RFC 9457 (application/problem+json), which would look like this:
{
"type": "https://api.aromastore.example/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Only 3 units of 'Ethiopia Yirgacheffe' remain and 5 were requested.",
"instance": "/v1/orders"
}problem+json |
Aroma Store's format | |
|---|---|---|
| A standard | Yes, RFC 9457 | No |
| Machine identifier | A URI in type |
code in snake_case |
| List of validation failures | A custom extension | details out of the box |
Content-Type |
application/problem+json |
application/json |
| Client support | Growing | Requires reading the documentation |
Aroma Store keeps its format, and the reasons are still those of 02-04: a code in snake_case is more convenient in a switch than comparing long URIs; details as a first-class field fits the decision to return every validation failure at once; and using application/json saves clients from having to negotiate a different type. Now there is an implementation argument to add: changing the format would be trivial —only src/middleware/errors.js is touched— and that is precisely the proof that centralising was worth it. If tomorrow a partner demands problem+json, we can even emit it based on Accept, with Vary: Accept, without touching a single service.
What is not optional, whichever format you use: the right HTTP code, a stable identifier for machines, a useful message for people, and never leaking the inside of the system.
Common Mistakes and Tips
1. Declaring the error middleware with three arguments. Express treats it as a normal middleware and it never runs. The four parameters are mandatory, even if next is unused.
2. Registering it before the routes. It catches nothing and errors fall into Express's default handler, which in development returns the stack in HTML.
3. Forgetting asyncHandler() on an async controller. The request hangs with no response and no visible error. Wrap every handler.
4. Using throw inside a setTimeout or a callback. Neither Express nor the wrapper catches it: it ends up as an uncaughtException. Inside callbacks, propagate with next(error).
5. Returning err.message unfiltered. It publishes paths, SQL, IPs and versions. Only ApiErrors carry their own message; everything else gets the generic one.
6. Responding and also calling next(). ERR_HTTP_HEADERS_SENT. One path or the other, never both.
7. Swallowing unknown errors. A catch that returns null turns a serious failure into missing data. Rethrow whatever you cannot translate.
8. Putting traceId in the 4xx. The contract reserves it for the 5xx, which are the only ones requiring investigation on our side.
9. Not shutting down the process after an uncaughtException. The state is unknown; carrying on serving requests can produce incorrect responses, which is worse than not responding.
Tip: deliberately provoke an error now and then —a temporary throw new Error('test') in a controller— and check that the response is a clean 500 with a traceId and that the log contains the full stack. It is the only way to know that the error path works; nobody tests it until they need it.
Exercises
Exercise 1
Implement the middleware requireIdempotencyKey that the contract of 02-03 demands on POST /v1/orders and POST /v1/orders/{id}/payment: if the Idempotency-Key header is missing, it must produce 400 idempotency_key_required; if the key has already been used with a different body, 422 idempotency_key_reused. Use ApiError and explain why the second case is 422 and not 409.
Exercise 2
A colleague writes this controller and in production requests appear that never receive a response. Diagnose the three problems and write the correct version.
orderRoutes.post('/', authenticate, async (req, res) => {
try {
const order = await orderService.create(req.body);
res.status(201).json(orderToRepresentation(order));
} catch (error) {
res.status(500).json({ error: error.message });
}
});Exercise 3
Design the complete response —code, headers and body— for these four situations, stating which errors factory produces it and what gets written to the log:
PATCH /v1/coffees/cof_001withContent-Type: application/json-patch+json.GET /v1/orders/ord_5001with the token of a customer who is not the owner.POST /v1/coffeeswith a valid token of rolecustomer.- A
TypeErrorinsidecoffeeToRepresentationbecause a seeded coffee hastasting_notesset toNULL.
Solutions
Solution 1
// src/middleware/idempotency.js
import { createHash } from 'node:crypto';
import { ApiError, errors } from '../errors/api-error.js';
import { database } from '../config/database.js';
// Table required (migration 003):
// CREATE TABLE idempotency_keys (
// key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
// response TEXT, created_at TEXT NOT NULL
// );
const findKey = database.prepare('SELECT * FROM idempotency_keys WHERE key = ?');
const saveKey = database.prepare(
'INSERT INTO idempotency_keys (key, fingerprint, created_at) VALUES (?, ?, ?)'
);
export function requireIdempotencyKey(req, res, next) {
const key = req.get('Idempotency-Key');
if (!key) {
return next(
new ApiError(
400,
'idempotency_key_required',
'This operation requires the Idempotency-Key header.'
)
);
}
// A fingerprint of the body: it identifies "the same request".
const fingerprint = createHash('sha256').update(JSON.stringify(req.body ?? {})).digest('hex');
const record = findKey.get(key);
if (record) {
if (record.fingerprint !== fingerprint) {
// Same key, different body: the client is contradicting itself.
return next(
new ApiError(
422,
'idempotency_key_reused',
'That Idempotency-Key was already used with a different body.'
)
);
}
if (record.response) {
// A legitimate retry: the original response is returned.
return res.status(200).json(JSON.parse(record.response));
}
return next(
new ApiError(409, 'operation_in_progress', 'An identical request is being processed.')
);
}
saveKey.run(key, fingerprint, new Date().toISOString());
req.idempotencyKey = key;
next();
}orderRoutes.post(
'/',
authenticate,
requireRole('customer', 'employee', 'administrator'),
requireIdempotencyKey,
validate(createOrderSchema),
asyncHandler(orderController.create)
);Why 422 and not 409: both codes say the request cannot be processed, but they point at different things. A 409 Conflict says the request was correct and clashes with the resource's current state: there is no stock, the order was already paid. A 422 Unprocessable Content says the request is syntactically well formed but semantically contradictory in itself: with the key the client asserts "this is the same request as before" while sending a different body. No resource is in conflict; the contradiction lives inside the request. That is why the contract of 02-04 reserved 422 exclusively for this case, and everything else goes through 400 or 409.
Solution 2
| # | Problem | Consequence |
|---|---|---|
| 1 | Every error becomes a 500 |
Insufficient stock, which is 409 insufficient_stock, is reported as a server failure. The client retries believing it is temporary, the monitoring fills up with false 5xx, and the ApiError the service carefully threw is lost |
| 2 | error.message is returned raw |
An information leak: it may contain system paths or SQLite messages. Besides, the format is {error: "text"}, not {error: {code, message, details}}, so it breaks the contract |
| 3 | No asyncHandler() and a catch that can itself fail |
If orderToRepresentation throws after the await, the error happens inside the try and enters the catch… but if res.json has already sent headers, the catch will try to respond again and will throw ERR_HTTP_HEADERS_SENT outside any capture. Nobody sees that second exception: rejected promise, hanging request |
The correct version:
// src/controllers/orders.js
create: async (req, res) => {
const order = await orderService.create({
customerId: req.user.id, // from the token, NEVER from the body (03-06)
items: req.body.items,
});
res.set('Location', `/v1/orders/${order.id}`);
res.status(201).json(orderToRepresentation(order));
},// src/routes/orders.js
orderRoutes.post(
'/',
authenticate,
requireIdempotencyKey,
validate(createOrderSchema),
asyncHandler(orderController.create)
);No try/catch. The controller deals with the happy path; asyncHandler() routes any rejection to errorHandler, which already knows how to tell an ApiError from a bug, set the right code, leak nothing and log what is appropriate. A try/catch in a controller is only justified when an error has to be translated into a more specific one —as orderService.create does with the repository's errors— never to "stop it breaking".
Solution 3
1. PATCH with JSON Patch
HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Aroma-Trace-Id: trc_1a2b3c4d
{"error":{"code":"unsupported_format","message":"The content type 'application/json-patch+json' is not accepted on this operation.","details":[]}}Factory: errors.unsupportedFormat(type), thrown from the PATCH controller. The Accept-Patch header is added by the error middleware when it sees code === 'unsupported_format' on a PATCH. Log: a one-line warning, it is a client error.
2. Somebody else's order
HTTP/1.1 404 Not Found
Aroma-Trace-Id: trc_5e6f7a8b
{"error":{"code":"order_not_found","message":"There is no order with the identifier 'ord_5001'.","details":[]}}Factory: errors.notFound('order', id) from orderService.getFor. 404 and not 403, and with exactly the same body as if the order did not exist, so as not to confirm its existence (03-06). Log: a warning; it is worth recording the user and the requested id, because a run of these is a sign of enumeration and in 04-07 it will become an alert.
3. POST /v1/coffees with the customer role
HTTP/1.1 403 Forbidden
Aroma-Trace-Id: trc_9c0d1e2f
{"error":{"code":"insufficient_permissions","message":"You do not have permission. One of these roles is required: employee, administrator.","details":[]}}Factory: errors.permissionDenied(['employee', 'administrator']) from requireRole. 403 and not 404, because /v1/coffees is public and its existence is no secret: being explicit here helps the integrator without leaking anything. Log: a warning.
4. A TypeError in the mapper
HTTP/1.1 500 Internal Server Error
Aroma-Trace-Id: trc_3a4b5c6d
{"error":{"code":"internal_error","message":"An unexpected error has occurred.","details":[],"traceId":"trc_3a4b5c6d"}}Factory: none; it is the default case of translate(), which returns errors.internalError(). The client does not see the TypeError, nor the name of the function, nor the line. Everything stays in the log:
{"level":"error","traceId":"trc_3a4b5c6d","method":"GET","path":"/v1/coffees/cof_007","user":null,
"message":"Cannot read properties of null (reading 'map')",
"stack":"TypeError: ... at coffeeToRepresentation (/app/src/services/mappers.js:31:29) ..."}And the underlying diagnosis: the real cause is that the tasting_notes column accepted NULL despite being declared NOT NULL DEFAULT '[]', probably through a manual insert. The lasting fix is not a ?? [] in the mapper —that covers up the symptom— but making the data safe in the repository and finding out why the constraint was bypassed. The traceId is what makes it possible to get from the user's complaint to that conclusion.
Conclusion
There are no more hand-written error responses scattered around the project. The services throw ApiError saying what happened with a code from the catalogue of 02-04, without mentioning HTTP or touching res; the factories guarantee that status and code always match; and a single four-argument middleware, registered last, translates everything that reaches it —ApiError, ZodError, SQLite errors, malformed JSON, oversized bodies and unexpected bugs— into the same {"error": {"code", "message", "details"}} format, with traceId only on the 5xx, WWW-Authenticate on the 401s, Allow on the 405s and Accept-Patch on the 415s. The asyncHandler() wrapper finally closes the rejected-promise hole we have been dragging since 03-03, and the process knows how to die properly: a graceful shutdown on SIGTERM, a full log and an exit on uncaughtException.
The most valuable part of this lesson is not the code but the asymmetry it establishes: the consumer is given a stable code, a useful message and an identifier to complain with; the team is given the full stack, the query, the user and the path. Never the other way round. And because everything goes through a single file, tomorrow we can add a field to every error, emit problem+json based on Accept or send the 5xx to an alerting system by changing one place.
With this, the Aroma Store API is complete: routes, representations, validation, persistence, authentication and errors. All that is missing is the one thing that turns "it works on my machine" into "it complies with the contract": checking it. In 03-08, Testing and Validation, we close the module with the testing pyramid applied to this API: unit tests of the services and of the cents-to-euros mapper using the in-memory repository as a double —possible thanks to the layer separation of 03-03—, integration tests with Supertest over the app object without opening a port —possible thanks to the separation of 03-02—, verification of codes, of the Location, Link and Allow headers and of the exact shape of the body, generation of valid tokens inside the tests themselves, isolation with a temporary SQLite database, coverage with the native runner, and a contract checklist before signing the API off.
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
