Node.js

How to Structure a Scalable Express.js Project

20250409 1437 Express.js Project Structure Simple Compose 01jrd8w7xbf9macmm65m6ap1e3 1024x683

Introduction

When building a Node.js backend with Express, it’s tempting to keep everything in a single file—especially for small apps or quick prototypes. But as your project grows beyond a few endpoints, you’ll quickly discover that monolithic files become impossible to navigate, test, and maintain. A clean, modular structure separates concerns, enables team collaboration, and makes your codebase scale gracefully from MVP to enterprise. Companies like PayPal, Netflix, and LinkedIn have built massive platforms on Node.js precisely because it scales well when properly architected. In this comprehensive guide, you’ll learn how to structure a scalable Express.js project using industry-proven patterns, covering the layered architecture, dependency injection, error handling, validation, and testing strategies that professional teams rely on.

Why Project Structure Matters

Poor structure creates compounding problems. What starts as “just one more function in app.js” becomes thousands of lines where finding anything requires searching, bugs hide in unexpected places, and new developers take weeks to become productive.

Good structure provides: easier debugging since each layer has a single responsibility, testability where business logic exists independent of HTTP concerns, team scalability where multiple developers work without constant merge conflicts, onboarding speed where new team members understand the codebase quickly, and refactoring confidence where changes in one area don’t cascade unexpectedly.

Here’s a production-ready Express.js folder layout that scales from startup to enterprise:

project-name/
├── src/
│   ├── config/              # Configuration and environment
│   │   ├── index.js         # Central config export
│   │   ├── database.js      # Database connection
│   │   └── logger.js        # Logging configuration
│   ├── api/
│   │   ├── controllers/     # Request handlers
│   │   ├── routes/          # Route definitions
│   │   ├── middlewares/     # Express middlewares
│   │   └── validators/      # Request validation schemas
│   ├── services/            # Business logic layer
│   ├── repositories/        # Data access layer
│   ├── models/              # Database models/schemas
│   ├── utils/               # Helper functions
│   ├── errors/              # Custom error classes
│   ├── app.js               # Express app configuration
│   └── server.js            # Entry point
├── tests/
│   ├── unit/
│   ├── integration/
│   └── fixtures/
├── .env.example
├── .env
├── package.json
└── README.md

The Layered Architecture Pattern

The key to scalable Express apps is separating concerns into distinct layers. Each layer has a single responsibility and only communicates with adjacent layers.

// The flow: Request -> Route -> Controller -> Service -> Repository -> Database
//                                    ↓
//                              Response ← (reverse flow)

Routes Layer

Routes define endpoints and wire them to controllers. They contain no business logic.

// src/api/routes/users.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/users.controller');
const { validateRequest } = require('../middlewares/validate');
const { authenticate } = require('../middlewares/auth');
const { createUserSchema, updateUserSchema } = require('../validators/users.validator');

// Public routes
router.post('/register', validateRequest(createUserSchema), userController.register);
router.post('/login', userController.login);

// Protected routes
router.use(authenticate); // All routes below require authentication
router.get('/me', userController.getCurrentUser);
router.patch('/me', validateRequest(updateUserSchema), userController.updateCurrentUser);
router.get('/:id', userController.getUserById);

module.exports = router;

// src/api/routes/index.js
const express = require('express');
const router = express.Router();

router.use('/users', require('./users.routes'));
router.use('/posts', require('./posts.routes'));
router.use('/auth', require('./auth.routes'));

module.exports = router;

Controllers Layer

Controllers handle HTTP concerns: parsing requests, calling services, and formatting responses.

// src/api/controllers/users.controller.js
const userService = require('../../services/users.service');
const { ApiError } = require('../../errors');
const httpStatus = require('http-status');

class UsersController {
  async register(req, res, next) {
    try {
      const user = await userService.createUser(req.body);
      res.status(httpStatus.CREATED).json({
        success: true,
        data: user,
      });
    } catch (error) {
      next(error);
    }
  }

  async login(req, res, next) {
    try {
      const { email, password } = req.body;
      const { user, tokens } = await userService.loginWithEmailAndPassword(email, password);
      res.json({
        success: true,
        data: { user, tokens },
      });
    } catch (error) {
      next(error);
    }
  }

  async getCurrentUser(req, res, next) {
    try {
      const user = await userService.getUserById(req.user.id);
      res.json({
        success: true,
        data: user,
      });
    } catch (error) {
      next(error);
    }
  }

  async getUserById(req, res, next) {
    try {
      const user = await userService.getUserById(req.params.id);
      if (!user) {
        throw new ApiError(httpStatus.NOT_FOUND, 'User not found');
      }
      res.json({
        success: true,
        data: user,
      });
    } catch (error) {
      next(error);
    }
  }

  async updateCurrentUser(req, res, next) {
    try {
      const user = await userService.updateUser(req.user.id, req.body);
      res.json({
        success: true,
        data: user,
      });
    } catch (error) {
      next(error);
    }
  }
}

module.exports = new UsersController();

Services Layer

Services contain business logic. They’re framework-agnostic and can be tested independently.

// src/services/users.service.js
const bcrypt = require('bcryptjs');
const userRepository = require('../repositories/users.repository');
const tokenService = require('./token.service');
const { ApiError } = require('../errors');
const httpStatus = require('http-status');

class UserService {
  async createUser(userData) {
    // Check if email already exists
    const existingUser = await userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new ApiError(httpStatus.CONFLICT, 'Email already registered');
    }

    // Hash password
    const hashedPassword = await bcrypt.hash(userData.password, 12);

    // Create user
    const user = await userRepository.create({
      ...userData,
      password: hashedPassword,
    });

    // Remove password from response
    return this.sanitizeUser(user);
  }

  async loginWithEmailAndPassword(email, password) {
    const user = await userRepository.findByEmail(email);
    if (!user) {
      throw new ApiError(httpStatus.UNAUTHORIZED, 'Invalid email or password');
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);
    if (!isPasswordValid) {
      throw new ApiError(httpStatus.UNAUTHORIZED, 'Invalid email or password');
    }

    const tokens = await tokenService.generateAuthTokens(user);

    return {
      user: this.sanitizeUser(user),
      tokens,
    };
  }

  async getUserById(id) {
    const user = await userRepository.findById(id);
    return user ? this.sanitizeUser(user) : null;
  }

  async updateUser(id, updateData) {
    // Prevent password update through this method
    delete updateData.password;

    const user = await userRepository.update(id, updateData);
    if (!user) {
      throw new ApiError(httpStatus.NOT_FOUND, 'User not found');
    }

    return this.sanitizeUser(user);
  }

  sanitizeUser(user) {
    const { password, ...sanitized } = user.toObject ? user.toObject() : user;
    return sanitized;
  }
}

module.exports = new UserService();

Repository Layer

Repositories handle data persistence. They abstract database operations from business logic.

// src/repositories/users.repository.js
const User = require('../models/user.model');

class UserRepository {
  async create(userData) {
    const user = new User(userData);
    return user.save();
  }

  async findById(id) {
    return User.findById(id);
  }

  async findByEmail(email) {
    return User.findOne({ email: email.toLowerCase() });
  }

  async update(id, updateData) {
    return User.findByIdAndUpdate(id, updateData, { new: true, runValidators: true });
  }

  async delete(id) {
    return User.findByIdAndDelete(id);
  }

  async findAll(filter = {}, options = {}) {
    const { page = 1, limit = 10, sort = '-createdAt' } = options;
    const skip = (page - 1) * limit;

    const [users, total] = await Promise.all([
      User.find(filter).sort(sort).skip(skip).limit(limit),
      User.countDocuments(filter),
    ]);

    return {
      users,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
      },
    };
  }
}

module.exports = new UserRepository();

Centralized Error Handling

A single error handler catches all errors and formats responses consistently:

// src/errors/ApiError.js
class ApiError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}

module.exports = { ApiError };

// src/api/middlewares/errorHandler.js
const { ApiError } = require('../../errors');
const config = require('../../config');
const logger = require('../../config/logger');

const errorHandler = (err, req, res, next) => {
  let { statusCode, message } = err;

  // Handle Mongoose validation errors
  if (err.name === 'ValidationError') {
    statusCode = 400;
    message = Object.values(err.errors).map(e => e.message).join(', ');
  }

  // Handle Mongoose duplicate key errors
  if (err.code === 11000) {
    statusCode = 409;
    message = 'Duplicate field value entered';
  }

  // Handle JWT errors
  if (err.name === 'JsonWebTokenError') {
    statusCode = 401;
    message = 'Invalid token';
  }

  // Log error
  logger.error(err);

  // Send response
  res.status(statusCode || 500).json({
    success: false,
    message: statusCode ? message : 'Internal server error',
    ...(config.env === 'development' && { stack: err.stack }),
  });
};

module.exports = errorHandler;

Request Validation

Validate all incoming requests using a schema validation library:

// src/api/validators/users.validator.js
const Joi = require('joi');

const createUserSchema = Joi.object({
  body: Joi.object({
    name: Joi.string().required().min(2).max(100),
    email: Joi.string().required().email(),
    password: Joi.string().required().min(8)
      .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
      .message('Password must contain uppercase, lowercase, and number'),
  }),
});

const updateUserSchema = Joi.object({
  body: Joi.object({
    name: Joi.string().min(2).max(100),
    email: Joi.string().email(),
  }),
});

module.exports = { createUserSchema, updateUserSchema };

// src/api/middlewares/validate.js
const Joi = require('joi');
const { ApiError } = require('../../errors');

const validateRequest = (schema) => (req, res, next) => {
  const { error } = schema.validate(
    { body: req.body, query: req.query, params: req.params },
    { abortEarly: false }
  );

  if (error) {
    const message = error.details.map(d => d.message).join(', ');
    return next(new ApiError(400, message));
  }

  next();
};

module.exports = { validateRequest };

App Configuration

Wire everything together in your app configuration:

// src/app.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const routes = require('./api/routes');
const errorHandler = require('./api/middlewares/errorHandler');
const { ApiError } = require('./errors');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors());

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per window
  message: 'Too many requests, please try again later',
});
app.use('/api', limiter);

// Body parsing
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));

// Compression
app.use(compression());

// API routes
app.use('/api/v1', routes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 404 handler
app.use((req, res, next) => {
  next(new ApiError(404, 'Not found'));
});

// Error handler
app.use(errorHandler);

module.exports = app;

Common Mistakes to Avoid

Business logic in controllers: Controllers should only handle HTTP concerns. Move validation, data transformation, and business rules to services.

Direct database access in controllers: Always go through repositories. This makes switching databases or adding caching much easier.

Inconsistent error responses: Use a centralized error handler and custom error classes. Every error should have the same response structure.

Missing input validation: Validate every request at the route level. Never trust client input, even from authenticated users.

Circular dependencies: Avoid importing services into each other. Use dependency injection or event emitters for cross-service communication.

Conclusion

A scalable Express.js project is built on clean separation between layers: routes handle HTTP, controllers parse requests, services contain business logic, and repositories manage data. This architecture makes your code testable, maintainable, and ready for growth. Add centralized error handling, request validation, and security middleware from day one—retrofitting these later is painful. The structure presented here has been battle-tested in production systems handling millions of requests. Start with this foundation and adjust as your specific needs emerge. For more on building robust APIs, check out our guide on REST vs GraphQL vs gRPC. For advanced patterns and security considerations, explore the Node.js Best Practices repository.

Leave a Comment