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

  1. Why centralise
  2. The ApiError class
  3. The catalogue's error factories
  4. Services that throw instead of returning
  5. The asyncHandler(fn) wrapper
  6. Express's error middleware
  7. ApiError versus an unexpected error
  8. The traceId and its correlation with the logs
  9. Translating the ZodError
  10. Translating SQLite's errors
  11. Translating express.json()'s errors
  12. The route 404 and the 405 with Allow
  13. The definitive ordering of src/app.js
  14. What must never be exposed in an error
  15. Uncaught process errors and graceful shutdown
  16. Reference table: situation → exception → response
  17. problem+json and why we keep our own format

  1. 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]

  1. The 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.

  1. 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.

  1. 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.

  1. The asyncHandler(fn) wrapper

With 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 them

Wrap 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.

  1. 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();
}

  1. ApiError versus an unexpected error

The 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.

  1. The 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.

  1. Translating the ZodError

The 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.

  1. 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.

  1. Translating express.json()'s errors

Two 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.code
"body_too_large"

The 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.

  1. The route 404 and the 405 with Allow

The 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'));
curl -i -s -X DELETE http://localhost:3000/v1/coffees | head -5
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.

  1. The definitive ordering of 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.

  1. 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.

  1. 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.

  1. 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.

  1. problem+json and why we keep our own format

In 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:

  1. PATCH /v1/coffees/cof_001 with Content-Type: application/json-patch+json.
  2. GET /v1/orders/ord_5001 with the token of a customer who is not the owner.
  3. POST /v1/coffees with a valid token of role customer.
  4. A TypeError inside coffeeToRepresentation because a seeded coffee has tasting_notes set to NULL.

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

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved