DEV Community

Cover image for Designing a Production-Ready Backend Folder Structure Using Node.js (2026 Edition)
Akshay Kurve
Akshay Kurve

Posted on

Designing a Production-Ready Backend Folder Structure Using Node.js (2026 Edition)

Table of Contents


Introduction

When you start building backend projects, everything feels simple.

You might begin with:

server.js
routes.js
Enter fullscreen mode Exit fullscreen mode

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.

Back to Table of Contents


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.

Back to Table of Contents


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
  }
}
Enter fullscreen mode Exit fullscreen mode

All code examples in this guide use ES Modules (import/export) — the 2026 standard.

Back to Table of Contents


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.

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

Now let's break this down in detail.

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

Why?

  • Root folder stays minimal
  • Build tools can target src/ easily
  • Clear boundary between "code" and "config"

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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",
  });
});
Enter fullscreen mode Exit fullscreen mode

❌ 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);
};
Enter fullscreen mode Exit fullscreen mode

✅ 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 });
});
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: If you see Model.find() in a controller, something is wrong.

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

This gives you clean, versioned URLs: GET /api/v1/users/:id

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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();
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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"),
  }),
});
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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;
Enter fullscreen mode Exit fullscreen mode

That's it — 3 lines. All setup happens in loaders.

Back to Table of Contents


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();
Enter fullscreen mode Exit fullscreen mode

Run with Node.js 24 native .env support:

node --env-file=.env server.js

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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 }
      );
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Run tests with Node.js 24:

node --test tests/

Back to Table of Contents


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"]
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


🔄 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.
└──────────────┘
Enter fullscreen mode Exit fullscreen mode

Response flows back the same way. Each layer only talks to the one below it.

Back to Table of Contents


📝 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);
Enter fullscreen mode Exit fullscreen mode

Step 2: Middleware runs

// auth.middleware.js → verifies JWT
// validate.middleware.js → validates :id is a valid ObjectId
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Repository queries DB

// src/repositories/user.repository.js
async findById(id) {
  return this.model.findById(id).select("-password").lean();
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


🏛️ 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.

Back to Table of Contents


✅ 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 });
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

9. Health Check Endpoint

app.get("/health", (req, res) => {
  res.status(200).json({ status: "OK", uptime: process.uptime() });
});
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Back to Table of Contents


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.

Back to Table of Contents


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
Enter fullscreen mode Exit fullscreen mode

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.

Back to Table of Contents


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

Back to Table of Contents


📌 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)