Table of Contents
- Introduction
- Why Folder Structure Matters in 2026
- Node.js in 2026 — What's Changed?
- Choosing Your Framework in 2026
-
The Production-Ready Folder Structure
- 1.
src/— The Heart of Your Application - 2.
config/— Configuration Files - 3.
controllers/— Handling Requests - 4.
services/— Business Logic - 5.
repositories/— Database Layer - 6.
models/— Data Models - 7.
routes/— API Endpoints - 8.
middlewares/— Request Interceptors - 9.
utils/— Helper Functions - 10.
validations/— Input Validation - 11.
jobs/— Background Jobs - 12.
loaders/— App Initialization - 13.
types/— TypeScript Definitions - 14.
events/— Event Handlers (Pub/Sub) - 15.
app.js— Express/Fastify App Setup - 16.
server.js— Entry Point - 17.
tests/— Testing - 18.
docker/— Containerization
- 1.
- Real Flow — How Everything Connects
- Example Flow with Full Code
- Design Patterns for Production in 2026
- Best Practices for Production
- Common Mistakes Beginners Make
- When Should You Use This Structure?
- My Thoughts
Introduction
When you start building backend projects, everything feels simple.
You might begin with:
server.js
routes.js
And it works.
But as your project grows, things quickly become messy:
- Files are hard to find
- Logic is mixed everywhere
- Scaling becomes painful
- Onboarding new team members takes forever
- Testing becomes nearly impossible
That's when you realize:
Folder structure matters more than you think.
The folder structure of a software project plays a significant role in enforcing the separation of concerns, which refers to the practice of organizing code and components in a way such that each module or component has a clear and distinct responsibility.
In this comprehensive guide, I'll show you a production-ready Node.js backend folder structure updated for 2026, explain why it works, and how you can use it in your own projects — whether you're using Express 5, Fastify, or NestJS.
Why Folder Structure Matters in 2026
A good structure helps you:
| Benefit | Why It Matters |
|---|---|
| Scalability | Grow from MVP to enterprise without rewriting |
| Maintainability | Find and fix bugs in minutes, not hours |
| Team Collaboration | Multiple developers can work without conflicts |
| Testability | Isolated layers are easy to unit test |
| Onboarding | New devs understand the codebase immediately |
| Security | Separation prevents accidental exposure of secrets |
Having a good starting point when it comes to our project architecture is crucial for the longevity of your project and for effectively addressing future changing needs. A poorly designed project architecture often leads to unreadable and disorganized code, resulting in prolonged development processes and making the product itself more challenging to test.
Think of it like organizing your room.
If everything has a place, you waste less time searching.
Organizing your Node.js backend project is not just about adhering to a convention; it's about establishing a foundation for success. A well-structured project improves collaboration, scalability, and maintainability.
Node.js in 2026 — What's Changed?
Before diving into folder structure, let's understand the current Node.js landscape.
Node.js 24 LTS "Krypton" — The Current Standard
In 2026, teams should target an Active LTS version for production systems. Node 24 LTS is the current recommended version.
Node.js 24.x is in Active LTS as "Krypton", initially released on 2025-05-06, entering LTS on 2025-10-28, with maintenance starting 2026-10-20, and end-of-life on 2028-04-30.
Key Features of Node.js 24 LTS:
Node.js 24 LTS matures essential features for production: a stable permission model, faster context tracking, powerful Undici 7 (including WebSocketStream), global URLPattern, native .env support, and modern JavaScript via V8 13.6.
V8 Engine Upgrade: Node.js 24 ships with the V8 JavaScript engine version 13.6, bringing performance enhancements and new JavaScript features such as Float16Array and Error.isError. Global URLPattern API: simpler URL routing and matching without the need for external libraries.
Native ESM is Now Mainstream
The Node.js ecosystem has shifted significantly. Native ESM is mainstream, edge deployments are common, and AI-driven traffic means APIs face more unpredictable bursts than ever.
Node.js has evolved from a simple JavaScript runtime into a comprehensive, enterprise-grade development platform. The shift from CommonJS to ES Modules, the integration of web standards, the elimination of external dependencies for core functionality, and the introduction of professional developer tools have fundamentally changed how we build server-side JavaScript applications.
What This Means for Your Project Structure:
// package.json (2026 standard)
{
"name": "my-production-app",
"type": "module", // ← Native ESM by default
"engines": {
"node": ">=24.0.0" // ← Target Node.js 24 LTS
}
}
All code examples in this guide use ES Modules (import/export) — the 2026 standard.
Choosing Your Framework in 2026
The folder structure in this guide works with any framework, but here's the 2026 landscape:
| Framework | Best For | Weekly Downloads |
|---|---|---|
| Express 5 | Battle-tested stability, largest ecosystem | ~20M+ |
| Fastify | Raw performance, modern APIs | ~1.5M |
| NestJS | Enterprise, TypeScript-first, opinionated | ~3M |
| Hono | Edge computing, ultra-lightweight | Growing |
Performance benchmarks reveal Fastify consistently leads in raw throughput, handling 30-40% more requests per second than Express.js in standard REST API scenarios, with NestJS performing similarly to Express due to its underlying Express/Fastify adapter layer.
The choice between Express, Fastify, and NestJS ultimately hinges on your project's specific needs, your team's expertise, and the long-term vision for your application. Express remains the Swiss Army knife, offering unparalleled flexibility for projects valuing simplicity and direct control. Fastify shines where raw performance is paramount. NestJS is the architect's dream, providing a highly structured and scalable foundation for complex applications, especially those embracing TypeScript and object-oriented principles.
** Recommendation for 2026:**
- For enterprise-grade, structured, TypeScript-heavy projects: Go with NestJS.
- For maximum speed and modern tooling (greenfield): Use Fastify/Hono on Bun.
- For battle-tested stability and ecosystem: Stick with Express.js on Node.js.
This guide uses Express/Fastify examples since the folder structure applies universally.
The Production-Ready Folder Structure
Here's the complete, production-grade structure for 2026:
project-root/
│
├── src/
│ ├── config/ # App configuration & env management
│ │ ├── database.js
│ │ ├── env.js
│ │ ├── logger.js
│ │ └── index.js
│ │
│ ├── controllers/ # Request handlers (thin layer)
│ │ ├── user.controller.js
│ │ ├── auth.controller.js
│ │ └── index.js
│ │
│ ├── services/ # Business logic
│ │ ├── user.service.js
│ │ ├── auth.service.js
│ │ └── index.js
│ │
│ ├── repositories/ # Database operations
│ │ ├── user.repository.js
│ │ ├── auth.repository.js
│ │ └── index.js
│ │
│ ├── models/ # Database schemas/entities
│ │ ├── user.model.js
│ │ ├── post.model.js
│ │ └── index.js
│ │
│ ├── routes/ # API route definitions
│ │ ├── v1/
│ │ │ ├── user.routes.js
│ │ │ ├── auth.routes.js
│ │ │ └── index.js
│ │ ├── v2/
│ │ │ └── ...
│ │ └── index.js
│ │
│ ├── middlewares/ # Custom middleware functions
│ │ ├── auth.middleware.js
│ │ ├── rateLimiter.middleware.js
│ │ ├── errorHandler.middleware.js
│ │ ├── validate.middleware.js
│ │ └── index.js
│ │
│ ├── utils/ # Reusable helper functions
│ │ ├── apiResponse.js
│ │ ├── asyncHandler.js
│ │ ├── tokenHelper.js
│ │ └── index.js
│ │
│ ├── validations/ # Request validation schemas
│ │ ├── user.validation.js
│ │ ├── auth.validation.js
│ │ └── index.js
│ │
│ ├── jobs/ # Background & scheduled tasks
│ │ ├── emailQueue.job.js
│ │ ├── cleanupCron.job.js
│ │ └── index.js
│ │
│ ├── loaders/ # App initialization modules
│ │ ├── express.loader.js
│ │ ├── database.loader.js
│ │ ├── redis.loader.js
│ │ └── index.js
│ │
│ ├── events/ # Event-driven handlers (Pub/Sub)
│ │ ├── user.events.js
│ │ ├── order.events.js
│ │ └── index.js
│ │
│ ├── types/ # TypeScript type definitions
│ │ ├── user.types.ts
│ │ ├── express.d.ts
│ │ └── index.ts
│ │
│ └── app.js # Express/Fastify app setup
│
├── tests/
│ ├── unit/
│ │ ├── services/
│ │ └── utils/
│ ├── integration/
│ │ ├── routes/
│ │ └── repositories/
│ ├── e2e/
│ └── setup.js
│
├── docker/
│ ├── Dockerfile
│ ├── Dockerfile.dev
│ └── docker-compose.yml
│
├── scripts/
│ ├── seed.js
│ ├── migrate.js
│ └── healthcheck.js
│
├── docs/
│ └── api.md
│
├── .env
├── .env.example
├── .env.test
├── .gitignore
├── .eslintrc.json
├── .prettierrc
├── package.json
├── tsconfig.json # If using TypeScript
└── server.js # Entry point
Now let's break this down in detail.
1. src/ — The Heart of Your Application
To keep your project organised, create a folder named src at the root level. This folder will hold all your source code files and modules, enabling a clean, modular project structure.
All your actual application code lives inside src/. This keeps your root folder clean — only configuration files, Docker setup, and the entry point live at the root.
project-root/
├── src/ ← All application code
├── tests/ ← All test files
├── docker/ ← Container config
├── package.json ← Project metadata
└── server.js ← Entry point only
Why?
- Root folder stays minimal
- Build tools can target
src/easily - Clear boundary between "code" and "config"
2. config/ — Configuration Files
This folder centralizes all configuration logic.
In the src folder, create a folder named configs for storing various configuration files (e.g., database configuration, Firebase configuration, etc.).
Files:
src/config/
├── database.js # DB connection config
├── env.js # Environment variable validation
├── logger.js # Winston/Pino logger setup
├── redis.js # Redis connection config
├── cors.js # CORS settings
└── index.js # Barrel export
Example — env.js (with Node.js 24 native .env support):
// src/config/env.js
// Node.js 24 supports native .env loading!
// Start with: node --env-file=.env server.js
const env = {
PORT: process.env.PORT || 3000,
NODE_ENV: process.env.NODE_ENV || "development",
DB_URI: process.env.DB_URI,
JWT_SECRET: process.env.JWT_SECRET,
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || "7d",
REDIS_URL: process.env.REDIS_URL,
LOG_LEVEL: process.env.LOG_LEVEL || "info",
};
// Validate required vars
const required = ["DB_URI", "JWT_SECRET"];
for (const key of required) {
if (!env[key]) {
throw new Error(`❌ Missing required env variable: ${key}`);
}
}
export default Object.freeze(env);
Example — logger.js (using Pino — the 2026 standard):
// src/config/logger.js
import pino from "pino";
import env from "./env.js";
const logger = pino({
level: env.LOG_LEVEL,
transport:
env.NODE_ENV === "development"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
// Structured logging for production (JSON output)
// Works great with ELK stack, Datadog, etc.
});
export default logger;
Example — database.js:
// src/config/database.js
import mongoose from "mongoose";
import env from "./env.js";
import logger from "./logger.js";
const connectDB = async () => {
try {
const conn = await mongoose.connect(env.DB_URI, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
});
logger.info(`✅ MongoDB connected: ${conn.connection.host}`);
} catch (error) {
logger.error("❌ MongoDB connection failed:", error.message);
process.exit(1);
}
};
export default connectDB;
Example — Barrel Export index.js:
// src/config/index.js
export { default as env } from "./env.js";
export { default as logger } from "./logger.js";
export { default as connectDB } from "./database.js";
Key takeaway: Production requires proper logging (structured, not console.log), observability (tracing, metrics), process management (using a tool like PM2 to restart on failures), and environment configuration (using secure environment variables, not hard-coded secrets).
3. controllers/ — Handling Requests
Controllers handle incoming HTTP requests and send responses. They should be thin — no business logic here.
Controllers will be responsible to handle all the incoming requests to your application which will either render a page in response, may send a JSON payload or will handle other critical API related actions like POST, PUT, DELETE etc.
Files:
src/controllers/
├── user.controller.js
├── auth.controller.js
├── post.controller.js
└── index.js
Example — user.controller.js:
// src/controllers/user.controller.js
import { UserService } from "../services/index.js";
import { asyncHandler } from "../utils/index.js";
/**
* @desc Get user by ID
* @route GET /api/v1/users/:id
* @access Private
*/
export const getUser = asyncHandler(async (req, res) => {
const user = await UserService.getUserById(req.params.id);
res.status(200).json({
success: true,
data: user,
});
});
/**
* @desc Create new user
* @route POST /api/v1/users
* @access Private/Admin
*/
export const createUser = asyncHandler(async (req, res) => {
const user = await UserService.createUser(req.body);
res.status(201).json({
success: true,
data: user,
message: "User created successfully",
});
});
/**
* @desc Update user
* @route PUT /api/v1/users/:id
* @access Private
*/
export const updateUser = asyncHandler(async (req, res) => {
const user = await UserService.updateUser(req.params.id, req.body);
res.status(200).json({
success: true,
data: user,
message: "User updated successfully",
});
});
/**
* @desc Delete user
* @route DELETE /api/v1/users/:id
* @access Private/Admin
*/
export const deleteUser = asyncHandler(async (req, res) => {
await UserService.deleteUser(req.params.id);
res.status(200).json({
success: true,
message: "User deleted successfully",
});
});
❌ What NOT to do in controllers:
// ❌ BAD — database logic directly in controller
export const getUser = async (req, res) => {
const user = await User.findById(req.params.id) // DB call in controller!
.select("-password")
.populate("posts");
if (!user) {
return res.status(404).json({ error: "Not found" });
}
res.json(user);
};
✅ What TO do:
// ✅ GOOD — delegate to service
export const getUser = asyncHandler(async (req, res) => {
const user = await UserService.getUserById(req.params.id);
res.status(200).json({ success: true, data: user });
});
Rule of thumb: If you see Model.find() in a controller, something is wrong.
4. services/ — Business Logic
This is where the real work happens. Services contain all business logic and orchestrate between controllers and repositories.
Files:
src/services/
├── user.service.js
├── auth.service.js
├── email.service.js
├── payment.service.js
└── index.js
Example — user.service.js:
// src/services/user.service.js
import { UserRepository } from "../repositories/index.js";
import { AppError } from "../utils/index.js";
import { logger } from "../config/index.js";
class UserService {
/**
* Get user by ID with validation
*/
static async getUserById(id) {
const user = await UserRepository.findById(id);
if (!user) {
throw new AppError("User not found", 404);
}
logger.info(`User fetched: ${user.email}`);
return user;
}
/**
* Create new user with business rules
*/
static async createUser(data) {
// Business rule: check for duplicate email
const existingUser = await UserRepository.findByEmail(data.email);
if (existingUser) {
throw new AppError("Email already registered", 409);
}
// Business rule: hash password before storing
const hashedPassword = await hashPassword(data.password);
const user = await UserRepository.create({
...data,
password: hashedPassword,
});
// Side effect: send welcome email (non-blocking)
EventEmitter.emit("user:created", user);
logger.info(`New user created: ${user.email}`);
return user;
}
/**
* Update user with ownership check
*/
static async updateUser(id, data) {
const user = await UserRepository.findById(id);
if (!user) {
throw new AppError("User not found", 404);
}
// Business rule: prevent email changes (or add re-verification)
if (data.email && data.email !== user.email) {
throw new AppError("Email cannot be changed directly", 400);
}
const updatedUser = await UserRepository.updateById(id, data);
return updatedUser;
}
/**
* Soft delete user
*/
static async deleteUser(id) {
const user = await UserRepository.findById(id);
if (!user) {
throw new AppError("User not found", 404);
}
await UserRepository.softDelete(id);
EventEmitter.emit("user:deleted", user);
logger.info(`User deleted: ${user.email}`);
}
}
export default UserService;
Why separate services?
| Reason | Explanation |
|---|---|
| Reusability | Same logic can be called from controllers, jobs, events |
| Testability | Mock repository, test business logic in isolation |
| Readability | Controllers stay thin, logic is centralized |
| Flexibility | Change the transport layer (REST → GraphQL) without rewriting logic |
5. repositories/ — Database Layer
Repositories handle all database operations. This is the only place where you should see ORM/ODM calls.
Files:
src/repositories/
├── user.repository.js
├── post.repository.js
├── base.repository.js # Abstract base with common methods
└── index.js
Example — base.repository.js (DRY pattern):
// src/repositories/base.repository.js
class BaseRepository {
constructor(model) {
this.model = model;
}
async findById(id, select = "") {
return this.model.findById(id).select(select).lean();
}
async findOne(filter, select = "") {
return this.model.findOne(filter).select(select).lean();
}
async findMany(filter = {}, options = {}) {
const { page = 1, limit = 20, sort = "-createdAt", select = "" } = options;
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.model.find(filter).select(select).sort(sort).skip(skip).limit(limit).lean(),
this.model.countDocuments(filter),
]);
return {
data,
pagination: {
total,
page,
limit,
pages: Math.ceil(total / limit),
},
};
}
async create(data) {
return this.model.create(data);
}
async updateById(id, data) {
return this.model
.findByIdAndUpdate(id, data, { new: true, runValidators: true })
.lean();
}
async softDelete(id) {
return this.model.findByIdAndUpdate(id, {
isDeleted: true,
deletedAt: new Date(),
});
}
async hardDelete(id) {
return this.model.findByIdAndDelete(id);
}
}
export default BaseRepository;
Example — user.repository.js (extends base):
// src/repositories/user.repository.js
import BaseRepository from "./base.repository.js";
import User from "../models/user.model.js";
class UserRepository extends BaseRepository {
constructor() {
super(User);
}
async findByEmail(email) {
return this.model.findOne({ email, isDeleted: false }).lean();
}
async findActiveUsers(options) {
return this.findMany({ isActive: true, isDeleted: false }, options);
}
async updateLastLogin(id) {
return this.model.findByIdAndUpdate(id, {
lastLoginAt: new Date(),
});
}
}
// Export singleton
export default new UserRepository();
Benefits of the Repository Pattern:
- Switch databases easily — Change MongoDB to PostgreSQL by only modifying repositories
- Mock-friendly — Perfect for unit testing services
- Single responsibility — Only database logic lives here
- Query optimization — Centralize indexes, projections, and aggregations
6. models/ — Data Models
Defines your database schemas and entities.
Files:
src/models/
├── user.model.js
├── post.model.js
├── comment.model.js
├── plugins/
│ ├── softDelete.plugin.js
│ └── paginate.plugin.js
└── index.js
Example — user.model.js (MongoDB with Mongoose):
// src/models/user.model.js
import { Schema, model } from "mongoose";
import softDeletePlugin from "./plugins/softDelete.plugin.js";
const userSchema = new Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
minlength: [2, "Name must be at least 2 characters"],
maxlength: [50, "Name cannot exceed 50 characters"],
},
email: {
type: String,
required: [true, "Email is required"],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"],
},
password: {
type: String,
required: [true, "Password is required"],
minlength: 8,
select: false, // Never return password by default
},
role: {
type: String,
enum: ["user", "admin", "moderator"],
default: "user",
},
isActive: {
type: Boolean,
default: true,
},
lastLoginAt: Date,
avatar: String,
},
{
timestamps: true, // auto createdAt & updatedAt
toJSON: {
transform(doc, ret) {
delete ret.password;
delete ret.__v;
return ret;
},
},
}
);
// Indexes for performance
userSchema.index({ email: 1 });
userSchema.index({ role: 1, isActive: 1 });
userSchema.index({ createdAt: -1 });
// Apply plugins
userSchema.plugin(softDeletePlugin);
const User = model("User", userSchema);
export default User;
Every Collection (if MongoDB) or a Table (if MySQL) will have a standalone model file. For example, a collection of Users will have its own User.model.js file which could be extended further for defining a Schema Structure.
7. routes/ — API Endpoints
Defines all API routes. Version your APIs from day one.
Keep route definitions in a routes directory. Organize routes based on features or entities they represent.
Files:
src/routes/
├── v1/
│ ├── user.routes.js
│ ├── auth.routes.js
│ ├── post.routes.js
│ └── index.js
├── v2/
│ └── ...
└── index.js
Example — v1/user.routes.js:
// src/routes/v1/user.routes.js
import { Router } from "express";
import * as UserController from "../../controllers/user.controller.js";
import { authenticate, authorize } from "../../middlewares/auth.middleware.js";
import { validate } from "../../middlewares/validate.middleware.js";
import { createUserSchema, updateUserSchema } from "../../validations/user.validation.js";
const router = Router();
router
.route("/")
.get(authenticate, authorize("admin"), UserController.getUsers)
.post(authenticate, authorize("admin"), validate(createUserSchema), UserController.createUser);
router
.route("/:id")
.get(authenticate, UserController.getUser)
.put(authenticate, validate(updateUserSchema), UserController.updateUser)
.delete(authenticate, authorize("admin"), UserController.deleteUser);
export default router;
Example — v1/index.js (route aggregator):
// src/routes/v1/index.js
import { Router } from "express";
import userRoutes from "./user.routes.js";
import authRoutes from "./auth.routes.js";
import postRoutes from "./post.routes.js";
const router = Router();
router.use("/auth", authRoutes);
router.use("/users", userRoutes);
router.use("/posts", postRoutes);
export default router;
Example — routes/index.js (version manager):
// src/routes/index.js
import { Router } from "express";
import v1Routes from "./v1/index.js";
// import v2Routes from "./v2/index.js"; // future versions
const router = Router();
router.use("/v1", v1Routes);
// router.use("/v2", v2Routes);
export default router;
This gives you clean, versioned URLs: GET /api/v1/users/:id
8. middlewares/ — Request Interceptors
Middlewares run before reaching controllers. They handle cross-cutting concerns.
Node.js's middleware architecture is widely used for handling requests and responses in web applications. The Middleware pattern involves a chain of functions that process a request sequentially. Each function can modify the request or response before passing it to the next function in the chain.
Files:
src/middlewares/
├── auth.middleware.js # JWT authentication
├── authorize.middleware.js # Role-based access
├── rateLimiter.middleware.js # Rate limiting
├── errorHandler.middleware.js # Global error handler
├── validate.middleware.js # Schema validation
├── requestLogger.middleware.js # Request logging
├── cors.middleware.js # CORS configuration
├── helmet.middleware.js # Security headers
└── index.js
Example — auth.middleware.js:
// src/middlewares/auth.middleware.js
import jwt from "jsonwebtoken";
import { env } from "../config/index.js";
import { AppError } from "../utils/index.js";
export const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new AppError("Authentication required", 401);
}
const token = authHeader.split(" ")[1];
try {
const decoded = jwt.verify(token, env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
throw new AppError("Invalid or expired token", 401);
}
};
export const authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
throw new AppError("Insufficient permissions", 403);
}
next();
};
};
Example — errorHandler.middleware.js:
// src/middlewares/errorHandler.middleware.js
import { logger, env } from "../config/index.js";
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
// Log error
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip,
});
// Mongoose bad ObjectId
if (err.name === "CastError") {
error = { message: "Resource not found", statusCode: 404 };
}
// Mongoose duplicate key
if (err.code === 11000) {
error = { message: "Duplicate field value entered", statusCode: 409 };
}
// Mongoose validation error
if (err.name === "ValidationError") {
const messages = Object.values(err.errors).map((val) => val.message);
error = { message: messages.join(", "), statusCode: 400 };
}
res.status(error.statusCode || 500).json({
success: false,
error: error.message || "Internal Server Error",
...(env.NODE_ENV === "development" && { stack: err.stack }),
});
};
export default errorHandler;
Example — rateLimiter.middleware.js:
// src/middlewares/rateLimiter.middleware.js
import rateLimit from "express-rate-limit";
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: "Too many requests, please try again later",
},
});
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // stricter for auth endpoints
message: {
success: false,
error: "Too many login attempts, please try again later",
},
});
9. utils/ — Helper Functions
Reusable, stateless utility functions that can be used across the application.
Unlike utility methods, helpers could be dynamic in nature and related to specific controllers when needed. Helpers may contain methods to parse some user posted payload, modify it before storing it to the Database, etc.
Files:
src/utils/
├── appError.js # Custom error class
├── asyncHandler.js # Async error wrapper
├── apiResponse.js # Standardized responses
├── tokenHelper.js # JWT generation/verification
├── dateHelper.js # Date formatting utilities
├── slugify.js # URL slug generation
├── pagination.js # Pagination helper
└── index.js
Example — appError.js:
// src/utils/appError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export default AppError;
Example — asyncHandler.js:
// src/utils/asyncHandler.js
// Wraps async route handlers to automatically catch errors
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
export default asyncHandler;
Example — apiResponse.js:
// src/utils/apiResponse.js
export const successResponse = (res, data, message = "Success", statusCode = 200) => {
return res.status(statusCode).json({
success: true,
message,
data,
});
};
export const errorResponse = (res, message = "Error", statusCode = 500) => {
return res.status(statusCode).json({
success: false,
error: message,
});
};
export const paginatedResponse = (res, data, pagination, message = "Success") => {
return res.status(200).json({
success: true,
message,
data,
pagination,
});
};
10. validations/ — Input Validation
Validates request data before it reaches the controller. Prevents invalid data and security issues.
Files:
src/validations/
├── user.validation.js
├── auth.validation.js
├── post.validation.js
├── common.validation.js # Shared rules (objectId, pagination, etc.)
└── index.js
Example — user.validation.js (using Zod — the 2026 standard):
// src/validations/user.validation.js
import { z } from "zod";
export const createUserSchema = z.object({
body: z.object({
name: z
.string({ required_error: "Name is required" })
.min(2, "Name must be at least 2 characters")
.max(50, "Name cannot exceed 50 characters")
.trim(),
email: z
.string({ required_error: "Email is required" })
.email("Invalid email format")
.toLowerCase()
.trim(),
password: z
.string({ required_error: "Password is required" })
.min(8, "Password must be at least 8 characters")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain at least one uppercase, one lowercase, and one number"
),
role: z.enum(["user", "admin", "moderator"]).optional().default("user"),
}),
});
export const updateUserSchema = z.object({
params: z.object({
id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid user ID"),
}),
body: z.object({
name: z.string().min(2).max(50).trim().optional(),
avatar: z.string().url("Invalid avatar URL").optional(),
}),
});
export const getUserSchema = z.object({
params: z.object({
id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid user ID"),
}),
});
Example — validate.middleware.js (generic Zod validator):
// src/middlewares/validate.middleware.js
import { AppError } from "../utils/index.js";
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
}));
throw new AppError(
`Validation failed: ${errors.map((e) => e.message).join(", ")}`,
400
);
}
next();
};
11. jobs/ — Background Jobs
For async tasks that shouldn't block the request-response cycle.
Tools commonly used with Node.js: Redis Pub/Sub, Kafka, RabbitMQ, Amazon SNS/SQS, NATS.
Files:
src/jobs/
├── queues/
│ ├── email.queue.js # Email sending queue
│ ├── image.queue.js # Image processing queue
│ └── index.js
├── workers/
│ ├── email.worker.js # Email queue processor
│ ├── image.worker.js # Image queue processor
│ └── index.js
├── crons/
│ ├── cleanup.cron.js # Database cleanup
│ ├── report.cron.js # Daily report generation
│ └── index.js
└── index.js
Example — email.queue.js (using BullMQ):
// src/jobs/queues/email.queue.js
import { Queue } from "bullmq";
import { env } from "../../config/index.js";
const emailQueue = new Queue("email", {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
removeOnComplete: 100,
removeOnFail: 50,
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
});
export const sendWelcomeEmail = async (user) => {
await emailQueue.add("welcome", {
to: user.email,
name: user.name,
template: "welcome",
});
};
export const sendPasswordResetEmail = async (user, resetToken) => {
await emailQueue.add("passwordReset", {
to: user.email,
name: user.name,
resetToken,
template: "password-reset",
});
};
export default emailQueue;
Example — cleanup.cron.js (using node-cron):
// src/jobs/crons/cleanup.cron.js
import cron from "node-cron";
import { logger } from "../../config/index.js";
import { UserRepository } from "../../repositories/index.js";
// Run daily at 2:00 AM — clean up soft-deleted users older than 30 days
export const cleanupDeletedUsers = cron.schedule("0 2 * * *", async () => {
try {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const result = await UserRepository.permanentlyDeleteOlderThan(thirtyDaysAgo);
logger.info(`🧹 Cleanup: removed ${result.deletedCount} old users`);
} catch (error) {
logger.error("Cleanup job failed:", error);
}
}, { scheduled: false });
12. loaders/ — App Initialization
This is a powerful pattern inspired by the bulletproof-node.js architecture. Loaders initialize different parts of your app, keeping app.js clean.
Files:
src/loaders/
├── express.loader.js # Express middleware setup
├── database.loader.js # Database connection
├── redis.loader.js # Redis connection
├── cron.loader.js # Start cron jobs
├── events.loader.js # Register event listeners
└── index.js # Orchestrate all loaders
Example — express.loader.js:
// src/loaders/express.loader.js
import express from "express";
import helmet from "helmet";
import cors from "cors";
import compression from "compression";
import routes from "../routes/index.js";
import errorHandler from "../middlewares/errorHandler.middleware.js";
import { apiLimiter } from "../middlewares/rateLimiter.middleware.js";
import requestLogger from "../middlewares/requestLogger.middleware.js";
const expressLoader = (app) => {
// Security headers
app.use(helmet());
// CORS
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") || "*" }));
// Body parsing
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Compression
app.use(compression());
// Request logging
app.use(requestLogger);
// Rate limiting
app.use("/api/", apiLimiter);
// Health check (before auth)
app.get("/health", (req, res) => {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
});
// API Routes
app.use("/api", routes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: `Route ${req.originalUrl} not found`,
});
});
// Global error handler (must be last)
app.use(errorHandler);
};
export default expressLoader;
Example — index.js (loader orchestrator):
// src/loaders/index.js
import expressLoader from "./express.loader.js";
import databaseLoader from "./database.loader.js";
import redisLoader from "./redis.loader.js";
import cronLoader from "./cron.loader.js";
import eventsLoader from "./events.loader.js";
import { logger } from "../config/index.js";
const initializeApp = async (app) => {
// 1. Connect database
await databaseLoader();
logger.info("✅ Database loaded");
// 2. Connect Redis
await redisLoader();
logger.info("✅ Redis loaded");
// 3. Setup Express
expressLoader(app);
logger.info("✅ Express loaded");
// 4. Register event listeners
eventsLoader();
logger.info("✅ Events loaded");
// 5. Start cron jobs
cronLoader();
logger.info("✅ Cron jobs loaded");
};
export default initializeApp;
13. types/ — TypeScript Definitions
Moving forward, expertise will require using updated asynchronous coding, frameworks like NestJS for structure, and combining with React/Next.js. TypeScript has become the 2026 standard for production Node.js apps.
Files:
src/types/
├── user.types.ts # User-related types
├── auth.types.ts # Auth-related types
├── express.d.ts # Express type augmentations
├── environment.d.ts # Env variable types
└── index.ts
Example — express.d.ts:
// src/types/express.d.ts
import { JwtPayload } from "jsonwebtoken";
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: "user" | "admin" | "moderator";
} & JwtPayload;
}
}
}
Example — user.types.ts:
// src/types/user.types.ts
export interface IUser {
_id: string;
name: string;
email: string;
password: string;
role: "user" | "admin" | "moderator";
isActive: boolean;
lastLoginAt?: Date;
avatar?: string;
createdAt: Date;
updatedAt: Date;
}
export interface ICreateUserDTO {
name: string;
email: string;
password: string;
role?: "user" | "admin" | "moderator";
}
export interface IUpdateUserDTO {
name?: string;
avatar?: string;
}
export interface IUserResponse {
_id: string;
name: string;
email: string;
role: string;
isActive: boolean;
createdAt: Date;
}
14. events/ — Event Handlers (Pub/Sub)
A production Node.js backend in 2026 often uses Event-Driven + Pub/Sub for asynchronous flows.
Files:
src/events/
├── user.events.js
├── order.events.js
├── emitter.js # Centralized EventEmitter
└── index.js
Example — emitter.js:
// src/events/emitter.js
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
// Increase max listeners for production
emitter.setMaxListeners(20);
export default emitter;
Example — user.events.js:
// src/events/user.events.js
import emitter from "./emitter.js";
import { sendWelcomeEmail } from "../jobs/queues/email.queue.js";
import { logger } from "../config/index.js";
// Listener: When a new user is created
emitter.on("user:created", async (user) => {
try {
await sendWelcomeEmail(user);
logger.info(`Welcome email queued for: ${user.email}`);
} catch (error) {
logger.error(`Failed to queue welcome email: ${error.message}`);
}
});
// Listener: When a user is deleted
emitter.on("user:deleted", (user) => {
logger.info(`User deleted event: ${user.email}`);
// Trigger cleanup, anonymize data, etc.
});
export default emitter;
15. app.js — Express/Fastify App Setup
This file creates the app instance. It stays clean because loaders do the heavy lifting.
// src/app.js
import express from "express";
const app = express();
export default app;
That's it — 3 lines. All setup happens in loaders.
16. server.js — Entry Point
This is where your app starts. It imports the app and initializes everything.
// server.js
import app from "./src/app.js";
import initializeApp from "./src/loaders/index.js";
import { env, logger } from "./src/config/index.js";
const startServer = async () => {
try {
// Initialize all app components
await initializeApp(app);
// Start listening
const server = app.listen(env.PORT, () => {
logger.info(`
🚀 Server running in ${env.NODE_ENV} mode
📡 Port: ${env.PORT}
🔗 URL: http://localhost:${env.PORT}
📖 Health: http://localhost:${env.PORT}/health
📦 Node.js: ${process.version}
`);
});
// Graceful shutdown
const gracefulShutdown = (signal) => {
logger.info(`${signal} received. Starting graceful shutdown...`);
server.close(() => {
logger.info("HTTP server closed");
process.exit(0);
});
// Force shutdown after 30s
setTimeout(() => {
logger.error("Forced shutdown after timeout");
process.exit(1);
}, 30000);
};
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
// Handle unhandled rejections
process.on("unhandledRejection", (err) => {
logger.error("Unhandled Rejection:", err);
gracefulShutdown("UNHANDLED_REJECTION");
});
} catch (error) {
logger.error("Failed to start server:", error);
process.exit(1);
}
};
startServer();
Run with Node.js 24 native .env support:
node --env-file=.env server.js
17. tests/ — Testing
This edition brings expanded coverage on advanced topics such as testing, which shows how to write unit, integration, and end-to-end tests using the Node.js built-in test runner and tools like Playwright.
Structure:
tests/
├── unit/
│ ├── services/
│ │ └── user.service.test.js
│ └── utils/
│ └── tokenHelper.test.js
├── integration/
│ ├── routes/
│ │ └── user.routes.test.js
│ └── repositories/
│ └── user.repository.test.js
├── e2e/
│ └── auth.flow.test.js
├── fixtures/
│ └── users.fixture.js
├── helpers/
│ └── testDb.js
└── setup.js
Example — Unit Test (using Node.js 24 built-in test runner):
// tests/unit/services/user.service.test.js
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert/strict";
import UserService from "../../../src/services/user.service.js";
describe("UserService", () => {
describe("getUserById", () => {
it("should return user when found", async () => {
const mockUser = { _id: "123", name: "John", email: "john@test.com" };
// Mock the repository
mock.method(UserRepository, "findById", () => Promise.resolve(mockUser));
const user = await UserService.getUserById("123");
assert.deepEqual(user, mockUser);
});
it("should throw 404 when user not found", async () => {
mock.method(UserRepository, "findById", () => Promise.resolve(null));
await assert.rejects(
() => UserService.getUserById("nonexistent"),
{ message: "User not found", statusCode: 404 }
);
});
});
});
Run tests with Node.js 24:
node --test tests/
18. docker/ — Containerization
Essential for 2026 production deployments.
Focus on performance & architecture. Deploy everything you create.
Example — Dockerfile:
# docker/Dockerfile
FROM node:24-alpine AS base
WORKDIR /app
# Dependencies layer (cached)
COPY package*.json ./
RUN npm ci --only=production
# Application layer
COPY src/ ./src/
COPY server.js ./
# Security: non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001
USER nodeuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "--env-file=.env", "server.js"]
Example — docker-compose.yml:
# docker/docker-compose.yml
version: "3.8"
services:
api:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "3000:3000"
env_file:
- ../.env
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
mongodb:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
healthcheck:
test: mongosh --eval 'db.runCommand("ping").ok'
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongo_data:
🔄 Real Flow — How Everything Connects
Here's how a request flows through the architecture:
Client Request
│
▼
┌─────────┐
│ Route │ ← Matches URL pattern, applies middleware
└────┬─────┘
│
▼
┌──────────────┐
│ Middleware │ ← Auth, validation, rate limiting
└────┬─────────┘
│
▼
┌──────────────┐
│ Controller │ ← Receives request, sends response (THIN)
└────┬─────────┘
│
▼
┌──────────────┐
│ Service │ ← Business logic, orchestration
└────┬─────────┘
│
▼
┌──────────────┐
│ Repository │ ← Database queries
└────┬─────────┘
│
▼
┌──────────────┐
│ Model │ ← Schema / Entity definition
└────┬─────────┘
│
▼
┌──────────────┐
│ Database │ ← MongoDB / PostgreSQL / etc.
└──────────────┘
Response flows back the same way. Each layer only talks to the one below it.
📝 Example Flow with Full Code
Let's trace a complete GET /api/v1/users/abc123 request:
Step 1: Route receives request
// src/routes/v1/user.routes.js
router.get("/:id", authenticate, validate(getUserSchema), UserController.getUser);
Step 2: Middleware runs
// auth.middleware.js → verifies JWT
// validate.middleware.js → validates :id is a valid ObjectId
Step 3: Controller handles request
// src/controllers/user.controller.js
export const getUser = asyncHandler(async (req, res) => {
const user = await UserService.getUserById(req.params.id);
res.status(200).json({ success: true, data: user });
});
Step 4: Service processes logic
// src/services/user.service.js
static async getUserById(id) {
const user = await UserRepository.findById(id);
if (!user) throw new AppError("User not found", 404);
return user;
}
Step 5: Repository queries DB
// src/repositories/user.repository.js
async findById(id) {
return this.model.findById(id).select("-password").lean();
}
Step 6: Response returns
{
"success": true,
"data": {
"_id": "abc123",
"name": "John Doe",
"email": "john@example.com",
"role": "user",
"createdAt": "2026-03-15T10:30:00.000Z"
}
}
🏛️ Design Patterns for Production in 2026
A production Node.js backend in 2026 often uses all of these patterns: Modular Pattern for code organization, Layered Architecture for structure, Middleware Pattern for request handling, Event-Driven + Pub/Sub for asynchronous flows.
| Pattern | Where It's Used | Purpose |
|---|---|---|
| Layered Architecture | controllers → services → repositories | Separation of concerns |
| Repository Pattern |
repositories/ folder |
Abstract database access |
| Singleton Pattern | DB connections, Logger, EventEmitter | Single instance resources |
| Factory Pattern | Error creation, Response formatting | Consistent object creation |
| Middleware/Chain |
middlewares/ folder |
Sequential request processing |
| Pub/Sub |
events/ folder |
Decoupled async communication |
| Strategy Pattern | Auth strategies, payment providers | Swappable implementations |
| Circuit Breaker | External API calls | Resilience to failures |
Almost every large Node.js project in 2026 uses some form of layered architecture.
✅ Best Practices for Production
1. Keep Controllers Thin (The Golden Rule)
// ❌ BAD — Fat controller
export const getUser = async (req, res) => {
try {
const user = await User.findById(req.params.id)
.select("-password")
.populate("posts");
if (!user) return res.status(404).json({ error: "Not found" });
const token = jwt.sign({ id: user._id }, process.env.SECRET);
res.json({ user, token });
} catch (err) {
res.status(500).json({ error: err.message });
}
};
// ✅ GOOD — Thin controller
export const getUser = asyncHandler(async (req, res) => {
const user = await UserService.getUserById(req.params.id);
res.status(200).json({ success: true, data: user });
});
2. Use Native ESM (2026 Standard)
// ❌ OLD — CommonJS
const express = require("express");
module.exports = router;
// ✅ NEW — ES Modules (Node.js 24 default)
import express from "express";
export default router;
3. Use Environment Variables Properly
# .env.example (commit this)
PORT=3000
NODE_ENV=development
DB_URI=mongodb://localhost:27017/myapp
JWT_SECRET=your_secret_here
REDIS_URL=redis://localhost:6379
Never commit .env. Always commit .env.example.
4. Centralized Error Handling
One place to catch and format all errors. See the errorHandler middleware.
5. Structured Logging
// ❌ BAD
console.log("User created");
// ✅ GOOD
logger.info({ userId: user._id, action: "user_created" }, "User created successfully");
6. Security First
Security is an important factor to consider with respect to Node.js architecture. The structure of its ecosystem, which includes thousands of open source dependencies and the possibility of misconfiguration, presents several security problems.
To reduce risks, regular audits should be carried out on your dependencies using tools like npm audit or yarn audit. These tools look for known vulnerabilities in the packages that your application relies on.
# Run regularly
npm audit
npm audit fix
7. Graceful Shutdown
Always handle SIGTERM and SIGINT to close connections cleanly. See the server.js example.
8. API Versioning
Version your APIs from the start. Changing is expensive later.
/api/v1/users ← current
/api/v2/users ← future breaking changes
9. Health Check Endpoint
app.get("/health", (req, res) => {
res.status(200).json({ status: "OK", uptime: process.uptime() });
});
10. Use Barrel Exports (index.js)
init.js files will require rest of the files and export them to other files of your application, so that you need not have to require multiple files every time if you wish to consume them all.
// src/services/index.js
export { default as UserService } from "./user.service.js";
export { default as AuthService } from "./auth.service.js";
export { default as PostService } from "./post.service.js";
Common Mistakes Beginners Make
| Mistake | Problem | Solution |
|---|---|---|
| Mixing everything in one file | Hard to scale, impossible to test | Use layered architecture |
| Skipping the service layer | Fat controllers, duplicated logic | Always separate business logic |
| No input validation | Security vulnerabilities, bad data | Use Zod/Joi on every endpoint |
Using console.log |
No log levels, no structure | Use Pino or Winston |
| Hardcoding secrets | Security disaster | Use .env files |
| No error handling | App crashes on first error | Global error middleware |
| Not versioning APIs | Breaking changes affect all clients | Version from day one |
| Using CommonJS in 2026 | Missing tree-shaking, modern features | Use native ESM |
| No graceful shutdown | Data corruption, dropped requests | Handle SIGTERM/SIGINT |
| Skipping tests | Regressions on every change | Write unit + integration tests |
When you start a new Node.js project, it's tempting to throw everything into a single file. That works fine for prototypes, but production applications need structure. Without it, you'll end up with a tangled mess that's impossible to debug or extend.
When Should You Use This Structure?
✅ Use this when:
- Building real-world production applications
- Working in teams of 2+ developers
- Scaling beyond small prototypes
- Building APIs that will grow over time
- You need testability and maintainability
📦 For smaller projects, start simpler:
project/
├── src/
│ ├── routes/
│ ├── controllers/
│ ├── models/
│ ├── middlewares/
│ └── app.js
├── .env
├── package.json
└── server.js
Then gradually add services/, repositories/, validations/, etc. as complexity grows.
The key insight is that structure isn't about following rules - it's about making the right thing easy. When adding a new feature is as simple as creating a service, controller, and route, your team moves faster and makes fewer mistakes.
💭 My Thoughts
A good folder structure won't make your app faster.
But it will make your life as a developer much easier.
A proper Node.js folder structure helps organize files, manage code easily, and makes the project scalable and maintainable.
As your project grows, this structure helps you:
- ✅ Stay organized
- ✅ Scale confidently
- ✅ Collaborate smoothly
- ✅ Test effectively
- ✅ Debug faster
- ✅ Deploy with confidence
The Modular Pattern solves 90% of problems developers face in messy Node.js projects: giant files, mixed responsibilities, tangled dependencies, and code duplication.
The 2026 Production Checklist:
Before you ship any Node.js API to production, make sure you can check all of these: ✅ Layered architecture (controllers / services / repositories).
- [ ] ✅ Layered architecture (controllers → services → repositories)
- [ ] ✅ Native ESM modules
- [ ] ✅ Node.js 24 LTS
- [ ] ✅ Input validation (Zod)
- [ ] ✅ Structured logging (Pino)
- [ ] ✅ Centralized error handling
- [ ] ✅ Environment variable management
- [ ] ✅ API versioning
- [ ] ✅ Rate limiting
- [ ] ✅ Security headers (Helmet)
- [ ] ✅ Graceful shutdown
- [ ] ✅ Health check endpoint
- [ ] ✅ Docker containerization
- [ ] ✅ Unit + Integration tests
- [ ] ✅ CI/CD pipeline
Start simple, and gradually move toward this structure as your application grows. That's how most production systems evolve.
📌 Bookmark this guide and share it with other developers building backend systems in 2026. The Node.js ecosystem has matured — your project structure should too.
Top comments (0)