
If your Node.js app started as a single index.js and now spreads across thirty files that all import each other in weird ways, you are not alone. A clean Node.js project structure is the single biggest lever for keeping a service maintainable as it grows, and it directly shapes how error handling works end to end. This deep dive walks through a layered architecture that holds up in production, the error handling patterns that fit cleanly inside it, and the decisions that separate a codebase you enjoy touching from one you avoid.
The post is aimed at intermediate Node.js developers who are building real APIs and background workers, not hello-world examples. By the end, you will have a mental model for structuring services, a concrete folder layout, and a set of error handling patterns that prevent silent failures without burying your code in try/catch.
What a Good Node.js Project Structure Looks Like
A good Node.js project structure separates HTTP handling, business logic, and data access into distinct layers, centralizes error handling in a single middleware, and keeps cross-cutting concerns like logging and configuration in their own modules. This layering means each file has one reason to change, new developers can predict where any given concern lives, and tests can target one layer at a time without spinning up the whole app.
This is the short answer. The rest of the post explains why these pieces fit together and where teams usually get the details wrong.
The Layered Architecture Mental Model
Most Node.js backends benefit from the same three-layer split: a transport layer (HTTP, gRPC, queue consumers), a service layer (business logic and orchestration), and a data access layer (database and external APIs). Controllers translate incoming requests into plain function calls. Services do the actual work. Repositories talk to the database. Nothing below the transport layer should know that Express, Fastify, or NestJS even exists.
The value of this split becomes obvious the first time you want to trigger the same business operation from two places. For instance, imagine your app lets users create an invoice through a REST endpoint, and six months later you add a background job that creates invoices on a schedule. If your invoice creation logic lives inside the controller, you either duplicate it or awkwardly import a controller from a cron job. When the logic lives in an invoiceService.create() function, both the controller and the cron job call the same thing.
Each layer also has a distinct error profile. The transport layer deals with request parsing errors, authentication failures, and serialization. The service layer surfaces domain errors like “insufficient funds” or “user not found”. The data layer handles connection errors, constraint violations, and timeouts. Mixing these up is the root cause of most messy error handling in Node.js apps. A database constraint error bubbling directly into a response body is a layering failure, not an error handling failure.
If you have worked with backends in other languages, this pattern is not Node.js-specific. It is the same idea behind clean architecture, hexagonal architecture, and the classic controller-service-repository split. Node.js just gives you fewer guardrails, which means you have to enforce the boundaries by convention rather than by framework.
A Recommended Folder Layout
Here is a layout that holds up for APIs from small side projects to services handling real production traffic. It assumes Express or Fastify, but the shape works for any HTTP framework.
src/
config/
index.js # loads env vars, exports typed config
database.js # db client creation
modules/
users/
users.controller.js # HTTP layer
users.service.js # business logic
users.repository.js # database access
users.routes.js # route wiring
users.schema.js # request/response validation
users.errors.js # domain-specific error classes
invoices/
... # same shape, one folder per domain
middleware/
errorHandler.js # centralized error middleware
requestLogger.js
authenticate.js
shared/
errors/
AppError.js # base error class
httpErrors.js # 4xx/5xx subclasses
logger.js
asyncHandler.js
app.js # express app composition
server.js # bootstraps, listens on port
tests/
unit/
integration/
Two details matter here. First, each domain gets its own folder under modules/, not a top-level controllers/ and services/ split. Grouping by technical layer (controllers, services, models) is tempting, but it means a single feature lives in four different folders, and every pull request touches all of them. Grouping by domain keeps related code together and makes it obvious when one module is bleeding into another. For a deeper Express-specific walkthrough of this idea, see our guide on scalable Express.js project structure.
Second, notice that error definitions live in two places: a shared/errors/ folder for generic errors used across the app, and a users.errors.js inside each module for domain-specific ones. This split keeps the base error vocabulary small and consistent while letting each domain express its own concepts like InvoiceAlreadyPaidError without polluting a global error file.
How Error Handling Fits the Structure
Once the layers are in place, error handling becomes mostly a question of where errors get thrown and where they get caught. The rule of thumb: throw early and specifically, catch late and centrally.
The service and repository layers should throw typed errors. A repository that cannot find a user throws a NotFoundError, not a null return. A service that detects a business rule violation throws a BusinessRuleError. Neither layer tries to produce HTTP responses, log to user-facing systems, or decide whether to retry. Their only job is to fail clearly.
The transport layer, on the other hand, should almost never use try/catch directly in controller functions. Instead, a single error handling middleware at the edge of the app catches everything, looks at the error type, and produces the right HTTP response. This is the opposite of the “defensive try/catch everywhere” style, and it is the biggest single improvement most Node.js codebases can make.
Why centralize? Because error handling has to be consistent to be useful. If half your controllers return { error: "..." } and the other half return { message: "..." }, your clients break every time a new route is added. A single middleware forces one shape, one logging strategy, and one place to change when the format needs to evolve.
Operational vs Programmer Errors
Joyent’s original Node.js error handling essay introduced a distinction that still matters: operational errors vs programmer errors. Operational errors are runtime failures that a correct program can encounter — a database timeout, a failed external API call, a user submitting invalid input. Programmer errors are bugs — calling a method on undefined, passing the wrong type, forgetting to await a promise.
Operational errors should be handled. Programmer errors should crash the process.
This sounds extreme, but it is the right default. If your code reached a state that violates its own assumptions, continuing to run is worse than stopping. A crashed process gets restarted by your process manager or container orchestrator in seconds, and you get a stack trace in your logs. A process that swallows programmer errors and keeps running corrupts data for hours before anyone notices.
In practice, this means your centralized error middleware should handle known error types and pass unknown ones through to a last-resort handler that logs and responds with a generic 500, while separately your process should have a top-level uncaughtException and unhandledRejection handler that logs the error and exits. Never try to “recover” from those. Recover by restarting.
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', { reason });
throw reason;
});
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { err });
process.exit(1);
});
The unhandledRejection handler rethrows so the uncaughtException handler can log and exit through the same path. Process managers like PM2, systemd, or Kubernetes will restart the process automatically.
Custom Error Classes in Node.js
A well-designed error hierarchy is the foundation of clean error handling in Node.js. Start with a base class that everything else extends.
// shared/errors/AppError.js
class AppError extends Error {
constructor(message, { statusCode = 500, code, cause, details } = {}) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.cause = cause;
this.details = details;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
A few non-obvious details here. The isOperational flag lets the error middleware distinguish handled errors from bugs. The code field is a machine-readable string (USER_NOT_FOUND, INVOICE_ALREADY_PAID) that clients can branch on without parsing messages. The cause field preserves the original error when you wrap a lower-level exception, which is essential for debugging. Calling Error.captureStackTrace with the constructor hides the base class from the stack trace, so the trace starts where the error was actually thrown.
On top of this, define HTTP-shaped errors:
// shared/errors/httpErrors.js
const AppError = require('./AppError');
class BadRequestError extends AppError {
constructor(message, details) {
super(message, { statusCode: 400, code: 'BAD_REQUEST', details });
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} not found`, {
statusCode: 404,
code: 'NOT_FOUND',
details: { resource, id },
});
}
}
class ConflictError extends AppError {
constructor(message, details) {
super(message, { statusCode: 409, code: 'CONFLICT', details });
}
}
module.exports = { BadRequestError, NotFoundError, ConflictError };
Domain errors extend these:
// modules/invoices/invoices.errors.js
const { ConflictError } = require('../../shared/errors/httpErrors');
class InvoiceAlreadyPaidError extends ConflictError {
constructor(invoiceId) {
super('Invoice has already been paid', { invoiceId });
this.code = 'INVOICE_ALREADY_PAID';
}
}
module.exports = { InvoiceAlreadyPaidError };
Notice that the domain error extends ConflictError, not AppError directly. This means the error middleware only needs to know about the HTTP-level classes to produce the right status code, while each domain error carries its own specific meaning. New developers can throw new InvoiceAlreadyPaidError(id) without thinking about HTTP semantics at all — that mapping happens once, at the edge.
Centralized Error Middleware
With the error hierarchy in place, the middleware is short and obvious:
// middleware/errorHandler.js
const AppError = require('../shared/errors/AppError');
const logger = require('../shared/logger');
function errorHandler(err, req, res, next) {
const isOperational = err instanceof AppError && err.isOperational;
const statusCode = err.statusCode || 500;
if (!isOperational || statusCode >= 500) {
logger.error('Request failed', {
err,
method: req.method,
path: req.path,
requestId: req.id,
});
} else {
logger.warn('Request rejected', {
code: err.code,
statusCode,
path: req.path,
requestId: req.id,
});
}
const body = {
error: {
code: err.code || 'INTERNAL_ERROR',
message: isOperational ? err.message : 'Internal server error',
...(err.details && { details: err.details }),
},
requestId: req.id,
};
res.status(statusCode).json(body);
}
module.exports = errorHandler;
Several production-grade details are at work. Non-operational errors and 5xx responses get logged at error level with the full error object, because those indicate bugs or infrastructure problems. Expected 4xx errors get logged at warn level with just the code and path, because logging “user submitted invalid form” at error level creates noise that drowns real issues. The response message is only echoed to the client when the error is operational — for unknown errors, we return a generic message to avoid leaking stack traces or internal details. And every response includes a requestId, which is essential for the next section.
The middleware goes last in your Express app composition:
// app.js
const express = require('express');
const usersRoutes = require('./modules/users/users.routes');
const invoicesRoutes = require('./modules/invoices/invoices.routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
app.use(express.json());
app.use('/users', usersRoutes);
app.use('/invoices', invoicesRoutes);
app.use(errorHandler);
module.exports = app;
Async Error Handling Pitfalls
This is where most Node.js projects get in trouble. Express 4 does not forward errors from async route handlers to error middleware automatically. If you write this:
// BROKEN: error never reaches errorHandler
app.get('/users/:id', async (req, res) => {
const user = await userService.getById(req.params.id); // throws NotFoundError
res.json(user);
});
The thrown error becomes an unhandled promise rejection and the request hangs until the client times out. Fix this with an asyncHandler utility:
// shared/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
Then wrap every async route:
const asyncHandler = require('../../shared/asyncHandler');
router.get('/:id', asyncHandler(async (req, res) => {
const user = await userService.getById(req.params.id);
res.json(user);
}));
Express 5, when you move to it, handles this natively for async handlers that return promises. Fastify has always handled it natively. If you are on Express 4, asyncHandler is non-negotiable — forgetting it on one route is enough to cause production outages.
The same principle applies to background tasks. Any promise you create outside a request context needs a .catch() or a surrounding try/catch, or it becomes an unhandled rejection that crashes the process. A common mistake is firing off a “fire and forget” async operation without attaching a handler:
// BAD: unhandled rejection if sendWelcomeEmail throws
userService.create(data);
emailService.sendWelcomeEmail(data.email);
// GOOD: attach a handler, or await it inside a try/catch
emailService.sendWelcomeEmail(data.email).catch((err) => {
logger.error('Failed to send welcome email', { err, email: data.email });
});
For work that genuinely should not block the response, a proper queue (BullMQ, SQS, Kafka) is the right answer, not a bare promise. That way failed jobs get retried, dead-lettered, and monitored instead of silently disappearing.
Logging, Correlation, and Observability
Error handling is incomplete without logging, and logging is incomplete without correlation. Every request should get a unique ID at the edge, propagated through every log line related to that request. When a user reports an error, you want to paste one ID into your logging tool and see every step that led to the failure.
// middleware/requestContext.js
const { randomUUID } = require('crypto');
function requestContext(req, res, next) {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('x-request-id', req.id);
next();
}
module.exports = requestContext;
Honoring an incoming x-request-id header lets upstream services propagate their IDs through your service, which is critical in a microservices setup. For a deeper look at observability across services, our post on monitoring and logging microservices with Prometheus and Grafana covers the broader picture.
Pick a structured logger (pino or winston) and log JSON, never plain strings. Structured logs let your logging platform filter, aggregate, and alert on specific fields. String logs force regex parsing and inevitably lose information.
// shared/logger.js
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label }),
},
redact: ['req.headers.authorization', '*.password', '*.token'],
});
module.exports = logger;
The redact option is easy to forget and essential to include. A single logged password or token in production is an incident. Redact at the logger level so no individual log call has to remember.
Real-World Scenario: Refactoring a Tangled Service
Consider a common situation: a Node.js service built under deadline pressure has grown to around forty routes. Controllers do database queries directly, error handling is a mix of try/catch blocks and unhandled rejections, and error responses vary by route. The team has noticed three recurring issues — intermittent hangs on a few endpoints, inconsistent error shapes that break the mobile client, and a recent incident where a database constraint error leaked the full SQL query to end users.
The refactor path that usually works is incremental, not big-bang. First, introduce the error class hierarchy and the centralized middleware, but leave existing routes alone. Second, pick one module — typically the one with the most incidents — and fully refactor it into the controller/service/repository split with typed errors. Third, wrap every route with asyncHandler in one pass, which immediately fixes the hangs. Fourth, move remaining modules one at a time, prioritizing those that touch external systems or handle money.
Teams that try to refactor everything at once tend to stall, because the change touches every file and every test. Teams that pick one module first and prove the pattern end up shipping the whole refactor faster, because they have a concrete reference implementation and the pattern becomes obvious. The trade-off is that the codebase lives in two styles for a few weeks, which requires discipline in code review to avoid drift.
Observability changes usually come last, because they depend on the error shape being stable. Once centralized error handling is in place, adding request IDs, structured logging, and error codes to dashboards becomes a small follow-up rather than part of the main refactor.
When to Use This Node.js Project Structure
- Your service has more than a handful of routes and is expected to grow
- Multiple developers are working on the same codebase
- You need consistent error responses for a client or downstream service
- The service talks to a database and external APIs (most non-trivial services)
- You are building an API that needs to be maintained for longer than a quarter
When NOT to Use This Structure
- You are building a quick prototype or spike that will be thrown away
- The service is a single-purpose Lambda or function with one or two handlers
- You are already using an opinionated framework like NestJS that provides its own structure, in which case follow the framework’s conventions instead of inventing a parallel one
- The project is a CLI tool or a one-off script where HTTP concerns do not apply
For a serverless-specific take, error handling patterns change meaningfully when your unit of deployment is a single function. Our guide on serverless Node.js on AWS Lambda patterns and pitfalls walks through the differences.
Common Mistakes with Node.js Error Handling
- Catching errors in controllers and returning ad-hoc response shapes, bypassing the centralized middleware
- Swallowing errors with empty
catchblocks or catching and logging without rethrowing - Using generic
Erroreverywhere instead of typed errors, forcing the middleware to string-match messages - Forgetting
asyncHandleron a single route and only finding out when that route hangs in production - Mixing transport concerns into services — for instance, a service that calls
res.status(400).send(...)directly - Logging at error level for expected 4xx responses, drowning real errors in noise
- Returning raw database errors to clients, which leaks schema details and breaks clean API contracts
- Treating
nullreturns and thrown errors as interchangeable, forcing every caller to check both - Skipping request IDs, then trying to debug production incidents by eyeballing unstructured logs
- Running “fire and forget” async work without a
.catch()or a proper queue, leading to unhandled rejections
Errors that escape typed handling tend to be the same few patterns repeated across the codebase. A single half-day cleanup pass that grep’s for catch (e) {}, raw Error throws, and un-wrapped async handlers usually finds most of them.
Conclusion
A maintainable Node.js project structure is not about picking the perfect folder layout. It is about drawing clear lines between transport, business logic, and data access, then handling errors at the layer that actually owns them. Typed errors, a centralized error middleware, and wrapping every async handler together eliminate the most common sources of silent failures and inconsistent responses.
If you are starting fresh, set up the AppError hierarchy and the error middleware before your second route. If you are retrofitting an existing service, pick the noisiest module, refactor it end to end, and use it as the template for the rest. Next, consider pairing this structure with TypeORM for database access or a production auth flow using Passport and JWT in Express, and watch your memory profile over time with our guide on Node.js memory leak detection and prevention.