Loose ends have been piling up throughout the module, all of the same kind. loadEvent propagates EVENT_NOT_FOUND with next(error). The domain throws INSUFFICIENT_CAPACITY when Sala Bóveda has no tickets left. express.json() rejects a malformed body. The validate middleware from 06-06 calls next(new ValidationError(...)), a class that does not exist yet. And the 404 middleware from 06-03 propagates ROUTE_NOT_FOUND. Nobody picks up any of that. In this lesson we build the common destination: a central handler that turns any failure into a consistent response, and a clear policy about what can be turned into a friendly response and what cannot. It is the module's last piece, and the one that gives meaning to the STATUS_BY_CODE table from Module 4.

Contents

  1. The error middleware and its four-argument signature
  2. Express's default handler
  3. Synchronous errors, asynchronous errors and the big Express 5 improvement
  4. An error hierarchy of our own
  5. STATUS_BY_CODE finds its home
  6. Operational errors versus programming errors
  7. The complete central handler
  8. The 404 middleware
  9. Errors outside Express
  10. The module checklist

  1. The error middleware and its four-argument signature

An error middleware is identical to the others except for one thing: it has four parameters.

// The first parameter is the error that arrived via next(error) or a rejection.
function errorHandler(error, req, res, next) {
  res.status(500).json({ error: { code: 'INTERNAL_ERROR', status: 500 } });
}
app.use(errorHandler); // registered LAST

// BAD: only three parameters. Express treats it as a NORMAL middleware,
// so it never receives errors and, on top of that, it runs on healthy requests.
app.use((error, req, res) => res.status(500).json({ error: 'failure' }));

Express tells the two kinds apart by inspecting fn.length, the function's arity: if it is 4, it is an error handler; otherwise, it is a normal middleware. The symptoms of the bad version are baffling: errors keep showing up in Express's default format, and on correct requests an inexplicable 500 appears, because your function receives (req, res, next) and treats req as if it were an error. If you do not use next, declare it anyway: configure ESLint to allow unused trailing arguments, or call it _next, but do not remove it.

Why it goes last

An error handler only catches what is propagated from middleware and routes registered above it. Registering it in the middle means that everything declared afterwards escapes its control: if you write app.use(errorHandler) and only then app.use('/api', createApiRoutes()), the API's errors never reach it. It is the same ordering principle from 06-04, applied to the end of the chain.

You can chain several of them

An error handler can call next(error) to pass it to the next one, exactly as in the normal chain, which is useful for separating responsibilities: app.use(logError) logs and propagates, app.use(validationErrorHandler) handles only one type or propagates, and app.use(finalHandler) always responds. What must never happen is that the last one does not respond.

  1. Express's default handler

If you register none, Express has its own, and it does three sensible things: it uses error.status or error.statusCode if they exist (otherwise it responds 500); it sets whatever headers error.headers carries; and it sends the error body, with a critical difference depending on the environment:

NODE_ENV What it sends in the body
development (or undefined) The message and the complete stack trace in HTML
production Only the status text: Internal Server Error

That behavior is correct and it is worth understanding why. A stack trace reveals absolute server paths (/home/deploy/escena-viva/src/...), internal module names, library versions and sometimes fragments of data: a free map for anyone looking for a way in. In development you want to see it in full; in production it must never leave the server. Our own handler keeps exactly that policy, but with our JSON format instead of HTML. A detail that is often ignored: Express decides this from its own env setting, derived from NODE_ENV at startup, so be explicit and do not depend on the framework's internal comparison — that is why our handler consults configuration.isProduction.

  1. Synchronous errors, asynchronous errors and the big Express 5 improvement

Synchronous errors have always been caught by Express: a throw inside a synchronous handler goes to the error handler, no more to it.

Asynchronous errors: the big change

// Express 5: this WORKS. The rejection reaches the error handler.
app.get('/api/events/:id', async (req, res) => {
  const event = await getEventById(req.params.id); // may reject
  res.json(event.toJSON());
});
// Express 4: the wrapper you used to see in EVERY project, because without it
// the request hung forever.
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/api/events/:id', asyncHandler(asyncEventHandler));

In Express 4, the framework called the handler, the handler returned a promise nobody was observing, and the rejection turned into an unhandledRejection with no response to the client: no visible error, no 500, nothing, just the client waiting. If you run into asyncHandler, express-async-handler, catchAsync or wrapAsync in a project, you now know what they are: Express 4 leftovers. In Express 5 they are unnecessary, and removing them strips a layer of noise from every route.

Express 4 Express 5
Synchronous throw Caught Caught
A rejected promise in an async handler, middleware or router.param Hung request Caught
An error inside a setTimeout / callback Not caught Not caught (still yours)

That last row is the exception that does not go away. Express can only observe the promise you hand back to it:

// BAD: nobody catches this. It takes the whole process down.
app.get('/bad', (req, res) => setTimeout(() => { throw new Error('invisible'); }, 100));
// GOOD: promisified with the sleep.js utility from module 2.
app.get('/good', async () => {
  await sleep(100);
  throw new Error('this one does reach the error handler');
});

The rule: if an error can happen inside a callback, promisify that operation. It is one more reason to prefer fs.promises and the utilities from Modules 2 and 3.

  1. An error hierarchy of our own

Until now you created errors with Object.assign(new Error(...), { appCode }). It works, but it does not let you tell types apart with instanceof, it enforces nothing and it gets written differently in every place. Let's formalize it.

// src/errors.js — anything inheriting from the base error is considered
// OPERATIONAL: expected and translatable into a friendly HTTP response.
class ApplicationError extends Error {
  constructor(message, { appCode, appStatus, details, cause } = {}) {
    super(message, { cause });
    this.name = new.target.name;
    this.appCode = appCode ?? 'INTERNAL_ERROR';
    this.appStatus = appStatus; // optional: if absent, STATUS_BY_CODE infers it
    this.details = details;
    this.isOperational = true; // tells the expected apart from a bug
    Error.captureStackTrace(this, new.target); // a clean stack trace
  }
}
/** 400: the input data does not have the expected shape. */
class ValidationError extends ApplicationError {
  constructor(message, details = []) {
    super(message, { appCode: 'INVALID_DATA', appStatus: 400, details });
  }
}
const NOT_FOUND_CODES = {
  event: 'EVENT_NOT_FOUND', session: 'SESSION_NOT_FOUND',
  order: 'ORDER_NOT_FOUND', route: 'ROUTE_NOT_FOUND',
};
/** 404: the requested resource ('event', 'session', 'order', 'route') does not exist. */
class ResourceNotFound extends ApplicationError {
  constructor(type, identifier) {
    const appCode = NOT_FOUND_CODES[type] ?? 'RESOURCE_NOT_FOUND';
    super(`No ${type} found for ${identifier}`, { appCode, appStatus: 404 });
    this.type = type;
    this.identifier = identifier;
  }
}
/** 409: the operation is not valid in the resource's current state. */
class StateConflict extends ApplicationError {
  constructor(message, appCode = 'INVALID_STATE', details) {
    super(message, { appCode, appStatus: 409, details });
  }
}
module.exports = { ApplicationError, ValidationError, ResourceNotFound, StateConflict };

Decisions that deserve an explanation:

  • this.name = new.target.name: every subclass identifies itself with its own name in the logs, without repeating it by hand; and { cause } preserves the original error when wrapping it, with the full chain for the log and without leaking it to the client.
  • An optional this.appStatus lets the domain throw errors with only a code, knowing nothing about HTTP; Error.captureStackTrace removes the constructor from the stack trace, which then starts where the failure really happened; and isOperational = true is the central mark of section 6.

Usage in the domain, which still knows nothing about HTTP:

// src/domain/session.js — only the code; the 409 is set by the central handler.
sell(quantity) {
  if (this.available < quantity) {
    throw new StateConflict(
      `Session ${this.id} only has ${this.available} tickets available`,
      'INSUFFICIENT_CAPACITY',
      [{ sessionId: this.id, requested: quantity, available: this.available }]
    );
  }
  return (this.sold += quantity);
}

  1. STATUS_BY_CODE finds its home

In Module 4 you wrote src/server/http-errors.js with statusForError, bodyForError and the STATUS_BY_CODE table, which lived scattered across the handlers in responses.js. Now it has a single client: the central handler.

// src/server/http-errors.js — the same one from module 4, extended.
const STATUS_BY_CODE = Object.freeze({
  INVALID_QUANTITY: 400, INVALID_PARAMETER: 400, // malformed request
  INVALID_JSON: 400, INVALID_DATA: 400,
  PATH_NOT_ALLOWED: 403, // forbidden
  EVENT_NOT_FOUND: 404, SESSION_NOT_FOUND: 404, // does not exist
  ORDER_NOT_FOUND: 404, ROUTE_NOT_FOUND: 404,
  METHOD_NOT_ALLOWED: 405,
  INSUFFICIENT_CAPACITY: 409, INVALID_STATE: 409, // conflict with the current state
  BODY_TOO_LARGE: 413, UNSUPPORTED_TYPE: 415, ORDER_LIMIT_EXCEEDED: 422,
  TOO_MANY_REQUESTS: 429, EXTERNAL_SERVICE_DOWN: 503,
});

/** Translates an error into an HTTP status. Unknown -> 500. */
function statusForError(error) {
  if (Number.isInteger(error?.appStatus)) return error.appStatus; // 1. our hierarchy
  const byCode = STATUS_BY_CODE[error?.appCode]; // 2. a domain code
  if (byCode) return byCode;
  // 3. Third-party errors that already carry a status (express.json, cors, http-errors).
  const external = error?.status ?? error?.statusCode;
  if (Number.isInteger(external) && external >= 400 && external <= 599) return external;
  return 500; // 4. unknown
}
module.exports = { STATUS_BY_CODE, statusForError };

The four-step cascade makes everything fit in the same place: our hierarchy, the codes the domain throws without knowing about HTTP, and third-party errors. For the latter it is also worth normalizing the code, so the client always sees the Escena Viva vocabulary:

// src/middleware/errors.js (fragment). BY_TYPE are body-parser errors.
const BY_TYPE = {
  'entity.too.large': 'BODY_TOO_LARGE',
  'entity.parse.failed': 'INVALID_JSON',
  'unsupported.media.type': 'UNSUPPORTED_TYPE',
};
const BY_STATUS = { 404: 'ROUTE_NOT_FOUND', 405: 'METHOD_NOT_ALLOWED', 429: 'TOO_MANY_REQUESTS' };
/** Translates known third-party errors into our vocabulary. */
function codeForError(error) {
  const byStatus = BY_STATUS[error?.status ?? error?.statusCode];
  return error?.appCode ?? BY_TYPE[error?.type] ?? byStatus ?? 'INTERNAL_ERROR';
}

  1. Operational errors versus programming errors

This distinction is what decides what gets told to the client and what does not:

Operational Programming (a bug)
What it is An expected real-world situation A defect in your code
Examples Insufficient capacity, nonexistent event, malformed JSON, external service down undefined is not a function, a TypeError, a misspelled variable
Did you foresee it? Is it fixed by deploying? Yes, it is in the design; no, it is not fixed No; yes
Response to the client and logging A specific, useful message; one informative line A generic 500, no details; full stack trace, body, context and an alert
Can the process carry on? Yes, normally It depends: it may be in an inconsistent state

How they are told apart in code:

function isOperational(error) {
  // 1. Our hierarchy marks it explicitly.
  if (error instanceof ApplicationError) return true;
  // 2. A domain code known in the table.
  if (error?.appCode && STATUS_BY_CODE[error.appCode]) return true;
  // 3. Third-party errors with a 4xx status: a bad request, not a bug of ours.
  const status = error?.status ?? error?.statusCode;
  if (Number.isInteger(status) && status >= 400 && status < 500) return true;
  return false; // everything else is suspected of being a bug
}

Why a bug returns a silent 500. If a TypeError leaks to the client with its message, you publish the names of your internal variables, the line in the file and, with the stack trace, half your directory tree. On top of that, that message is of no use to the caller: they cannot fix their request because the problem is not in it. A 500 with the request identifier is more useful for everyone: the client knows the failure is yours and has a reference to complain with, and you have the full stack trace in your logs.

  1. The complete central handler

// src/middleware/errors.js — codeForError and isOperational: sections 5 and 6.
// It needs configuration (config/index.js), ApplicationError (errors.js) and
// STATUS_BY_CODE + statusForError (server/http-errors.js).
const GENERIC_MESSAGE =
  'An internal error occurred. Quote the request identifier when contacting support.';
// The central handler; it is registered LAST in createApplication().
function errorHandler(error, req, res, next) {
  // 1. With the response already started (a stream that failed halfway) headers
  //    can no longer be changed: we delegate to Express, which closes the connection.
  if (res.headersSent) {
    next(error);
    return;
  }
  const status = statusForError(error);
  const operational = isOperational(error);
  const requestId = req.requestId ?? '-';
  const where = `${requestId} ${req.method} ${req.originalUrl}`;
  // 2. LOGGING on stderr, in full. A bug is logged whole: stack trace and cause.
  if (operational) {
    console.error(`[error] ${where} ${status} ${codeForError(error)} ${error.message}`);
  } else {
    console.error(`[BUG] ${where} ${status} ${error?.name}`);
    console.error(error?.stack ?? error);
    if (error?.cause) console.error('Cause:', error.cause);
  }
  // 3. RESPONSE. Only operational errors are explained; a bug stays silent.
  const body = { error: operational
    ? { code: codeForError(error), message: error.message, status, requestId }
    : { code: 'INTERNAL_ERROR', message: GENERIC_MESSAGE, status: 500, requestId } };
  // Details: only those from our hierarchy (validation, conflicts).
  if (operational && error.details?.length > 0) body.error.details = error.details;
  // 4. The stack trace ONLY outside production, just as Express does by default.
  if (!configuration.isProduction && error?.stack) {
    body.error.stack = error.stack.split('\n').map((line) => line.trim());
  }
  // 5. Headers that some errors need.
  if (status === 405 && error?.allowedMethods) {
    res.set('Allow', error.allowedMethods.join(', '));
  }
  if (status === 503) res.set('Retry-After', '30');
  res.status(body.error.status).json(body);
}
module.exports = { errorHandler, codeForError, isOperational };

Two real responses, one operational and one from a bug:

{ "error": { "code": "INSUFFICIENT_CAPACITY",
    "message": "Session ses-001-1 only has 3 tickets available", "status": 409,
    "requestId": "9f2a1c48-3c7e-4a1b-9c62-1d0f8b4a77e1",
    "details": [{ "sessionId": "ses-001-1", "requested": 6, "available": 3 }] } }

{ "error": { "code": "INTERNAL_ERROR", "status": 500,
    "message": "An internal error occurred. Quote the request identifier when contacting support.",
    "requestId": "3b71e0a2-55d4-4a7c-8e19-6f0c2b9d1a44" } }

The second one says absolutely nothing about the failure, but in the server logs the full stack trace sits associated with that very same requestId the client has in front of them. That correlation —the identifier from 06-04 showing up both in the response and in the log— is what turns a useless incident report ("I got an error") into an actionable one ("I got an error, reference 3b71e0a2").

  1. The 404 middleware

// src/middleware/not-found.js — registered after ALL the routes and before
// the error handler. It does not respond: it propagates.
function routeNotFound(req, res, next) {
  next(new ResourceNotFound('route', `${req.method} ${req.originalUrl}`));
}
module.exports = { routeNotFound };

Why it goes before the error handler and after the routes: it is a normal middleware, not an error one, and Express reaches it when no previous route has responded. If you put it before the routes, it would answer 404 to everything; if you put it after the error handler, it would never run. And it propagates instead of responding so that the 404 goes through the same point as every other error and comes out with code, status, requestId and the same format: curl -s localhost:3000/api/does-not-exist returns {"error":{"code":"ROUTE_NOT_FOUND","message":"No route found for GET /api/does-not-exist",...}}. A client that knows how to read your errors should not need a special case for the 404.

  1. Errors outside Express

Express only sees what happens inside a request; there are two process-level events that escape it:

// src/server.js — a rejected promise with no .catch() and no try/catch: we
// turn it into an exception so that it goes through the handler below.
process.on('unhandledRejection', (reason) => {
  console.error('[FATAL] unhandledRejection:', reason);
  throw reason instanceof Error ? reason : new Error(String(reason));
});
// An exception nobody caught: the process is in an unknown state.
process.on('uncaughtException', (error) => {
  console.error('[FATAL] uncaughtException:', error?.stack ?? error);
  closeAndExit(1); // policy: log and terminate GRACEFULLY
});
// closeAndExit stops accepting new requests (activeServer.close +
// closeIdleConnections), waits for the in-flight ones and exits; with a
// setTimeout(...).unref() of 5 s as a safety net if something gets stuck.

Why you do NOT just carry on

The temptation is obvious: catch the error, log it and leave the server standing so as not to lose the service. That is a serious mistake, and these are the reasons:

  1. The process state is unknown. The exception propagated through a stack that was not expecting it: there are half-finished functions, with partially updated variables. In Escena Viva, an exception in the middle of SalesManager.record can leave the tickets deducted from the capacity but the order never created.
  2. Resources are leaked. Every uncaught exception can leave a file descriptor, a socket or a timer unclosed: the process "survives" by degrading until it exhausts the memory.
  3. It hides the bug. A server that keeps running badly creates no urgency, and the failure accumulates for weeks. Besides, the default behavior of uncaughtException with no handler is to terminate, and so it should be.

The correct policy, and the one we apply, is: log the whole error → stop accepting new requests → give the in-flight requests a few seconds → terminate the process → let the supervisor restart it. Who restarts it is a matter for the environment, and in Module 11 we will see it with names: PM2 in cluster mode, restart: always in Docker or the automatic restart of a managed container. The application does not restart itself: it dies with dignity and lets the supervisor do its job.

Event What to do What NOT to do
An operational next(error) Respond with a 4xx Terminate the process
A bug inside a request Silent 500, stack trace logged, process carries on Leak the stack trace to the client
unhandledRejection and uncaughtException Log and terminate gracefully Ignore it or keep serving requests
SIGTERM Graceful shutdown, exit 0 Die abruptly

  1. The module checklist

Before calling Escena Viva finished, go over it point by point:

  • [ ] createApplication() in src/app.js does not call listen and returns the application; src/server.js creates the http server, starts it and registers the graceful shutdown.
  • [ ] src/config/index.js is the only place that reads process.env, and it validates at startup; x-powered-by disabled and trust proxy matching the real deployment.
  • [ ] Routers in src/routes/ and controllers in src/controllers/, both free of business logic; routes ordered from specific to generic, with the wildcard last.
  • [ ] Every path in every middleware either responds or calls next, and the order is the canonical one from 06-05: id, security, CORS, logging, compression, limits, body, static files, routes, 404, errors.
  • [ ] express.json() with limit; express.static with dotfiles: 'ignore'; helmet on with the CSP adjusted to the front end instead of disabled.
  • [ ] CORS with an allowlist from configuration, never '*' with credentials; a rate limit on POST /api/orders.
  • [ ] Every input validated with a schema at the edge (and in req.validatedData, not by reassigning req.query); the domain keeps its invariants.
  • [ ] An error hierarchy in src/errors.js and the central handler last; the stack trace in the response only outside production, and requestId in error responses and in the logs.
  • [ ] unhandledRejection and uncaughtException log and terminate gracefully; npm audit clean and dependencies reviewed with the Module 5 criteria.

Common Mistakes and Tips

  • Forgetting the fourth parameter. Without next, Express treats your handler as a normal middleware: it never receives errors and it breaks healthy requests.
  • Registering it before the routes. It only catches what is declared above it.
  • Leaking the stack trace in production or treating bugs as operational errors. You publish system paths and versions, and a detailed TypeError does not help the client but does give you away; check it with NODE_ENV=production before deploying. And do not carry on after an uncaughtException: the process is in an unknown state, so log and terminate.
  • Responding when res.headersSent is already true. It triggers Cannot set headers after they are sent; check the flag and delegate to Express.
  • Losing the cause when wrapping errors. Use new Error(message, { cause: original }); without it the stack trace starts where you wrapped, not where it failed. And keep the handler itself trivial: if it does a JSON.stringify of something with circular references, it will fail in the worst possible place.
  • Tip: test your errors. In Module 9, with supertest, verify that insufficient capacity returns 409 with INSUFFICIENT_CAPACITY, that an invalid :id returns 400 and that a forced bug returns a 500 with no stack trace in production.

Exercises

Exercise 1: the hierarchy in action

Implement the complete src/errors.js and make the domain use it. Check four cases with curl, all of them carrying requestId: GET /api/events/evt-999 → 404 EVENT_NOT_FOUND; POST /api/orders with quantity: 99 → 400 INVALID_DATA with details; POST /api/orders asking for more tickets than are available → 409 INSUFFICIENT_CAPACITY; and GET /api/does-not-exist → 404 ROUTE_NOT_FOUND.

Exercise 2: the bug that does not leak

Add an /api/debug/failure route that causes a real TypeError (reading a property of undefined). Check that with NODE_ENV=development the response includes stack, that with NODE_ENV=production it returns the silent 500, and that in both cases stderr shows the full stack trace with the [BUG] prefix and the requestId.

Exercise 3: the Express 4 wrapper and why it is unnecessary

Create two async routes that reject, one wrapped in asyncHandler and one unwrapped, and check in Express 5 that they behave the same. Then write a handler that throws inside a setTimeout and explain why that one does take the process down, and how you would fix it.

Solutions

Solution 1

curl -s localhost:3000/api/events/evt-999   # 404 EVENT_NOT_FOUND
curl -s localhost:3000/api/does-not-exist   # 404 ROUTE_NOT_FOUND
# A validation 400 with details, and a state-conflict 409:
curl -s -X POST localhost:3000/api/orders -H 'Content-Type: application/json' \
  -d '{"sessionId":"ses-001-1","quantity":99,"email":"[email protected]"}'
# {"error":{"code":"INVALID_DATA",...,"details":[{"field":"quantity","type":"too_big"}]}}
curl -s -X POST localhost:3000/api/orders -H 'Content-Type: application/json' \
  -d '{"sessionId":"ses-003-2","quantity":6,"email":"[email protected]"}'
# {"error":{"code":"INSUFFICIENT_CAPACITY","message":"...only has 3 tickets available","status":409}}

Note the difference between the last two: quantity: 99 is a problem of shape (400, detected by the schema without looking at the system's state); asking for 6 tickets when 3 are left is a problem of state (409, only the domain can detect it at that instant). Those are the two levels from 06-06 in action.

Solution 2

// src/routes/index.js — the route is registered only outside production: a
// door for triggering errors should not exist in the real deployment.
if (!configuration.isProduction) {
  const session = undefined;
  api.get('/debug/failure', (req, res) => res.json({ capacity: session.capacity })); // TypeError
}
NODE_ENV=development node src/server.js && curl -s localhost:3000/api/debug/failure
# {"error":{"code":"INTERNAL_ERROR",...,"stack":["TypeError: Cannot read properties...",...]}}
NODE_ENV=production node src/server.js && curl -s localhost:3000/api/debug/failure
# {"error":{"code":"INTERNAL_ERROR","status":500,"requestId":"..."}}   no stack trace

In both cases, stderr shows the same full stack trace preceded by [BUG] 3b71e0a2-... GET /api/debug/failure 500 TypeError.

Solution 3

// comparison.js — the final error handler responds 500 with error.message.
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
// A wrapped Express 4 style; B unwrapped: in Express 5 they are identical.
app.get('/a', asyncHandler(async () => { throw new Error('failure in A'); }));
app.get('/b', async () => { throw new Error('failure in B'); });
// C: inside a setTimeout, Express cannot see it. D: the correct version.
app.get('/c', (req, res) => setTimeout(() => { throw new Error('failure in C'); }, 50));
app.get('/d', async () => { await sleep(50); throw new Error('failure in D'); });

With curl, /a, /b and /d return a 500 with their message —the first two identical— while /c kills the process with an uncaughtException, because its throw happens on a later tick of the event loop (Module 2), on a stack where there is nothing of Express left, and the promise the handler returned —none, in this case— cannot observe that failure. The fix is /d: promisify the wait so that the throw happens inside the async chain Express does observe. The practical conclusion: asyncHandler is unnecessary in Express 5, but promisifying is still mandatory.

Conclusion

This lesson closes Module 6, and Escena Viva is now a complete Express API. Errors finally have a single destination. You know that the error handler is identified by its four parameters and that forgetting the last one turns it into a normal middleware with baffling symptoms; that it is registered last because it only sees what is declared above it; and that Express's default handler filters the stack trace in production and shows it in development, a policy your own handler reproduces with your JSON format. You have seen the big Express 5 improvement —an async handler that rejects reaches the error handler on its own, and asyncHandler becomes an Express 4 relic— with its one remaining exception: whatever happens inside a callback is still your business, and that is why you promisify.

You have built a hierarchy of your own in src/errors.js with ApplicationError and its subclasses ValidationError, ResourceNotFound and StateConflict, and the STATUS_BY_CODE table you wrote in Module 4 has finally found its definitive home: a single point where domain codes, your hierarchy's codes and third-party packages' codes are translated into HTTP statuses. You tell operational errors —expected, explained to the client in detail— apart from programming errors —logged in full, returned as a silent 500 with only the request identifier as a reference. And outside Express, unhandledRejection and uncaughtException log and terminate the process gracefully, without pretending nothing happened, leaving the restart to a supervisor that arrives in Module 11.

Now look at the whole project. createApplication() is tested without starting anything. Configuration is read and validated in a single place. The /api/events, /api/sessions and /api/orders routers live in thirty-line files with their controllers beside them. Your own middleware measures, identifies and controls caching; the third-party ones add security headers, CORS, logging, compression and usage limits. The zod schemas describe exactly what each endpoint accepts. And any failure, wherever it comes from, comes out in the same shape and with an identifier that links it to the server logs. That is a production API. But there is something that has not changed since Module 3, and it is starting to hurt: the data still lives in data/events.json. Every sale rewrites an entire file. Two buyers who hit "buy" at the same time for the last tickets in Sala Bóveda both read the same capacity, both see there is room and both write. The file ends up with the second buyer's sales and the first buyer's disappear, or worse: more tickets are sold than fit in the venue. No schema validation prevents that, no middleware detects it and no error handler can fix it, because it is not an error: it is a race between two writes.

In Module 7 the data stops living in a JSON file. We will start by understanding what a database is and what it brings compared to a file, we will model Escena Viva with MongoDB and Mongoose, we will write the complete CRUD, we will explore relationships and advanced queries, we will look at the SQL world with Sequelize and we will finish with migrations, seeds and —the thing you have been waiting for— transactions, which are exactly the answer to the capacity and overselling problem we have just described. The JSON file has served us for seven modules; it is time to retire it.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved