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
- The error middleware and its four-argument signature
- Express's default handler
- Synchronous errors, asynchronous errors and the big Express 5 improvement
- An error hierarchy of our own
STATUS_BY_CODEfinds its home- Operational errors versus programming errors
- The complete central handler
- The 404 middleware
- Errors outside Express
- The module checklist
- 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.
- 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.
- 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.
- 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.appStatuslets the domain throw errors with only a code, knowing nothing about HTTP;Error.captureStackTraceremoves the constructor from the stack trace, which then starts where the failure really happened; andisOperational = trueis 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);
}
STATUS_BY_CODE finds its home
STATUS_BY_CODE finds its homeIn 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';
}
- 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.
- 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").
- 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.
- 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:
- 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.recordcan leave the tickets deducted from the capacity but the order never created. - 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.
- It hides the bug. A server that keeps running badly creates no urgency, and the failure accumulates for weeks. Besides, the default behavior of
uncaughtExceptionwith 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 |
- The module checklist
Before calling Escena Viva finished, go over it point by point:
- [ ]
createApplication()insrc/app.jsdoes not calllistenand returns the application;src/server.jscreates thehttpserver, starts it and registers the graceful shutdown. - [ ]
src/config/index.jsis the only place that readsprocess.env, and it validates at startup;x-powered-bydisabled andtrust proxymatching the real deployment. - [ ] Routers in
src/routes/and controllers insrc/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()withlimit;express.staticwithdotfiles: 'ignore'; helmet on with the CSP adjusted to the front end instead of disabled. - [ ] CORS with an allowlist from configuration, never
'*'withcredentials; a rate limit onPOST /api/orders. - [ ] Every input validated with a schema at the edge (and in
req.validatedData, not by reassigningreq.query); the domain keeps its invariants. - [ ] An error hierarchy in
src/errors.jsand the central handler last; the stack trace in the response only outside production, andrequestIdin error responses and in the logs. - [ ]
unhandledRejectionanduncaughtExceptionlog and terminate gracefully;npm auditclean 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
TypeErrordoes not help the client but does give you away; check it withNODE_ENV=productionbefore deploying. And do not carry on after anuncaughtException: the process is in an unknown state, so log and terminate. - Responding when
res.headersSentis alreadytrue. It triggersCannot 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 aJSON.stringifyof 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:idreturns 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 traceIn 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
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
