Best Practices for Structuring a Node.js Project

Node.js, Architecture|SEPTEMBER 12, 2025|0 VIEWS
Master Node.js project organization from small applications to enterprise-scale architectures

Introduction

A well-structured Node.js project is the foundation of maintainable, scalable, and efficient applications. As your codebase grows from a simple script to a complex enterprise application, proper organization becomes crucial for team productivity, code maintainability, and long-term success.

This comprehensive guide explores proven patterns and best practices for structuring Node.js projects, from small applications to enterprise-scale architectures. You'll learn how to organize your code, manage dependencies, implement proper separation of concerns, and create a project structure that scales with your team and requirements.

Understanding Project Structure Fundamentals

Why Project Structure Matters

Good project structure provides:

  • Maintainability: Easy to find and modify code
  • Scalability: Structure that grows with your application
  • Team Collaboration: Clear conventions for multiple developers
  • Testing: Organized code is easier to test
  • Deployment: Simplified build and deployment processes

Common Anti-Patterns to Avoid

// ❌ Bad: Everything in one file
const express = require('express');
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const app = express();

// Database connection
mongoose.connect('mongodb://localhost/myapp');

// User model
const UserSchema = new mongoose.Schema({
  email: String,
  password: String,
});
const User = mongoose.model('User', UserSchema);

// Authentication middleware
const authenticateToken = (req, res, next) => {
  // Authentication logic
};

// Routes
app.post('/register', async (req, res) => {
  // Registration logic
});

app.post('/login', async (req, res) => {
  // Login logic
});

app.listen(3000);

Basic Project Structure

Small to Medium Applications

For applications with moderate complexity, this structure provides a solid foundation:

my-node-app/
├── src/
│   ├── config/
│   │   ├── database.js
│   │   ├── environment.js
│   │   └── index.js
│   ├── controllers/
│   │   ├── authController.js
│   │   ├── userController.js
│   │   └── index.js
│   ├── middleware/
│   │   ├── auth.js
│   │   ├── errorHandler.js
│   │   ├── validation.js
│   │   └── index.js
│   ├── models/
│   │   ├── User.js
│   │   ├── Product.js
│   │   └── index.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── users.js
│   │   ├── products.js
│   │   └── index.js
│   ├── services/
│   │   ├── authService.js
│   │   ├── emailService.js
│   │   └── index.js
│   ├── utils/
│   │   ├── logger.js
│   │   ├── helpers.js
│   │   └── constants.js
│   └── app.js
├── tests/
│   ├── unit/
│   ├── integration/
│   └── fixtures/
├── docs/
├── scripts/
├── .env.example
├── .gitignore
├── package.json
├── README.md
└── server.js

Implementation Example

Entry Point (server.js)

// server.js - Application entry point
require('dotenv').config();
const app = require('./src/app');
const config = require('./src/config');
const logger = require('./src/utils/logger');

const PORT = config.port || 3000;

app.listen(PORT, () => {
  logger.info(`Server running on port ${PORT}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  logger.info('SIGTERM received. Shutting down gracefully');
  process.exit(0);
});

Application Setup (src/app.js)

// src/app.js - Application configuration
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

const config = require('./config');
const routes = require('./routes');
const { errorHandler, notFound } = require('./middleware');
const logger = require('./utils/logger');

const app = express();

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

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
});
app.use(limiter);

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

// Logging middleware
app.use((req, res, next) => {
  logger.info(`${req.method} ${req.path}`);
  next();
});

// Routes
app.use('/api', routes);

// Error handling
app.use(notFound);
app.use(errorHandler);

module.exports = app;

Advanced Project Structures

Feature-Based Structure

For larger applications, organize by features rather than technical layers:

my-enterprise-app/
├── src/
│   ├── features/
│   │   ├── authentication/
│   │   │   ├── controllers/
│   │   │   │   └── authController.js
│   │   │   ├── middleware/
│   │   │   │   └── authMiddleware.js
│   │   │   ├── models/
│   │   │   │   └── User.js
│   │   │   ├── routes/
│   │   │   │   └── authRoutes.js
│   │   │   ├── services/
│   │   │   │   ├── authService.js
│   │   │   │   └── tokenService.js
│   │   │   ├── validators/
│   │   │   │   └── authValidators.js
│   │   │   └── index.js
│   │   ├── users/
│   │   │   ├── controllers/
│   │   │   ├── models/
│   │   │   ├── routes/
│   │   │   ├── services/
│   │   │   └── index.js
│   │   └── products/
│   │       ├── controllers/
│   │       ├── models/
│   │       ├── routes/
│   │       ├── services/
│   │       └── index.js
│   ├── shared/
│   │   ├── config/
│   │   ├── database/
│   │   ├── middleware/
│   │   ├── utils/
│   │   └── types/
│   └── app.js
├── tests/
└── ...

Feature Module Example

// src/features/authentication/index.js
const authRoutes = require('./routes/authRoutes');
const authMiddleware = require('./middleware/authMiddleware');
const authService = require('./services/authService');

module.exports = {
  routes: authRoutes,
  middleware: authMiddleware,
  services: authService,
};
// src/features/authentication/controllers/authController.js
const authService = require('../services/authService');
const { validationResult } = require('express-validator');
const logger = require('../../../shared/utils/logger');

class AuthController {
  async register(req, res, next) {
    try {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({
          status: 'error',
          message: 'Validation failed',
          errors: errors.array(),
        });
      }

      const { email, password, name } = req.body;
      const result = await authService.register({ email, password, name });

      logger.info(`User registered: ${email}`);

      res.status(201).json({
        status: 'success',
        data: result,
      });
    } catch (error) {
      next(error);
    }
  }

  async login(req, res, next) {
    try {
      const { email, password } = req.body;
      const result = await authService.login(email, password);

      res.json({
        status: 'success',
        data: result,
      });
    } catch (error) {
      next(error);
    }
  }
}

module.exports = new AuthController();

Domain-Driven Design (DDD) Structure

For complex business applications, consider a DDD approach:

my-ddd-app/
├── src/
│   ├── domains/
│   │   ├── user/
│   │   │   ├── entities/
│   │   │   │   └── User.js
│   │   │   ├── repositories/
│   │   │   │   ├── UserRepository.js
│   │   │   │   └── UserRepositoryImpl.js
│   │   │   ├── services/
│   │   │   │   └── UserDomainService.js
│   │   │   ├── value-objects/
│   │   │   │   ├── Email.js
│   │   │   │   └── UserId.js
│   │   │   └── index.js
│   │   ├── order/
│   │   └── product/
│   ├── application/
│   │   ├── use-cases/
│   │   │   ├── user/
│   │   │   │   ├── CreateUserUseCase.js
│   │   │   │   └── GetUserUseCase.js
│   │   │   └── order/
│   │   ├── services/
│   │   └── dto/
│   ├── infrastructure/
│   │   ├── database/
│   │   │   ├── mongodb/
│   │   │   └── migrations/
│   │   ├── external-services/
│   │   ├── repositories/
│   │   └── web/
│   │       ├── controllers/
│   │       ├── routes/
│   │       └── middleware/
│   └── shared/
│       ├── errors/
│       ├── events/
│       ├── types/
│       └── utils/

Entity Example

// src/domains/user/entities/User.js
const { Email } = require('../value-objects/Email');
const { UserId } = require('../value-objects/UserId');

class User {
  constructor({ id, email, name, createdAt, updatedAt }) {
    this.id = new UserId(id);
    this.email = new Email(email);
    this.name = name;
    this.createdAt = createdAt || new Date();
    this.updatedAt = updatedAt || new Date();
  }

  updateEmail(newEmail) {
    this.email = new Email(newEmail);
    this.updatedAt = new Date();
  }

  updateName(newName) {
    if (!newName || newName.trim().length === 0) {
      throw new Error('Name cannot be empty');
    }
    this.name = newName.trim();
    this.updatedAt = new Date();
  }

  toJSON() {
    return {
      id: this.id.value,
      email: this.email.value,
      name: this.name,
      createdAt: this.createdAt,
      updatedAt: this.updatedAt,
    };
  }
}

module.exports = { User };

Configuration Management

Environment-Based Configuration

// src/config/index.js
const development = require('./development');
const production = require('./production');
const test = require('./test');

const configs = {
  development,
  production,
  test,
};

const env = process.env.NODE_ENV || 'development';

module.exports = {
  ...configs[env],
  env,
};
// src/config/development.js
module.exports = {
  port: process.env.PORT || 3000,
  database: {
    url: process.env.DATABASE_URL || 'mongodb://localhost:27017/myapp_dev',
    options: {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    },
  },
  jwt: {
    secret: process.env.JWT_SECRET || 'dev-secret',
    expiresIn: '1d',
  },
  redis: {
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  },
  email: {
    service: 'development',
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS,
    },
  },
  logging: {
    level: 'debug',
    file: false,
  },
};

Service Layer Architecture

Service Implementation

// src/services/userService.js
const { User } = require('../models');
const { NotFoundError, ValidationError } = require('../shared/errors');
const logger = require('../utils/logger');
const emailService = require('./emailService');

class UserService {
  async createUser(userData) {
    try {
      const existingUser = await User.findOne({ email: userData.email });
      if (existingUser) {
        throw new ValidationError('User with this email already exists');
      }

      const user = new User(userData);
      await user.save();

      // Send welcome email
      await emailService.sendWelcomeEmail(user.email, user.name);

      logger.info(`User created: ${user.id}`);
      return user.toJSON();
    } catch (error) {
      logger.error(`Error creating user: ${error.message}`);
      throw error;
    }
  }

  async getUserById(userId) {
    const user = await User.findById(userId);
    if (!user) {
      throw new NotFoundError('User not found');
    }
    return user.toJSON();
  }

  async updateUser(userId, updateData) {
    const user = await User.findById(userId);
    if (!user) {
      throw new NotFoundError('User not found');
    }

    Object.assign(user, updateData);
    await user.save();

    logger.info(`User updated: ${userId}`);
    return user.toJSON();
  }

  async deleteUser(userId) {
    const result = await User.findByIdAndDelete(userId);
    if (!result) {
      throw new NotFoundError('User not found');
    }

    logger.info(`User deleted: ${userId}`);
    return { message: 'User deleted successfully' };
  }
}

module.exports = new UserService();

Error Handling Strategy

Custom Error Classes

// src/shared/errors/index.js
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(message, field = null) {
    super(message, 400);
    this.field = field;
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') {
    super(message, 403);
  }
}

module.exports = {
  AppError,
  ValidationError,
  NotFoundError,
  UnauthorizedError,
  ForbiddenError,
};

Global Error Handler

// src/middleware/errorHandler.js
const logger = require('../utils/logger');
const { AppError } = require('../shared/errors');

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

  // Log error
  logger.error(err);

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    const message = 'Resource not found';
    error = new AppError(message, 404);
  }

  // Mongoose duplicate key
  if (err.code === 11000) {
    const message = 'Duplicate field value entered';
    error = new AppError(message, 400);
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const message = Object.values(err.errors).map((val) => val.message);
    error = new AppError(message, 400);
  }

  res.status(error.statusCode || 500).json({
    status: 'error',
    message: error.message || 'Server Error',
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
};

module.exports = errorHandler;

Testing Structure

Test Organization

tests/
├── unit/
│   ├── services/
│   │   ├── userService.test.js
│   │   └── authService.test.js
│   ├── controllers/
│   ├── models/
│   └── utils/
├── integration/
│   ├── auth.test.js
│   ├── users.test.js
│   └── database.test.js
├── e2e/
│   ├── user-journey.test.js
│   └── api.test.js
├── fixtures/
│   ├── users.json
│   └── products.json
└── helpers/
    ├── testDb.js
    └── testServer.js

Test Example

// tests/unit/services/userService.test.js
const userService = require('../../../src/services/userService');
const { User } = require('../../../src/models');
const {
  ValidationError,
  NotFoundError,
} = require('../../../src/shared/errors');

// Mock the User model
jest.mock('../../../src/models');

describe('UserService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('createUser', () => {
    const userData = {
      email: 'test@example.com',
      name: 'Test User',
      password: 'password123',
    };

    it('should create a new user successfully', async () => {
      User.findOne.mockResolvedValue(null);
      const mockUser = {
        id: '123',
        ...userData,
        save: jest.fn().mockResolvedValue(),
        toJSON: jest.fn().mockReturnValue({ id: '123', ...userData }),
      };
      User.mockImplementation(() => mockUser);

      const result = await userService.createUser(userData);

      expect(User.findOne).toHaveBeenCalledWith({ email: userData.email });
      expect(mockUser.save).toHaveBeenCalled();
      expect(result).toEqual({ id: '123', ...userData });
    });

    it('should throw ValidationError if user already exists', async () => {
      User.findOne.mockResolvedValue({ id: '456' });

      await expect(userService.createUser(userData)).rejects.toThrow(
        ValidationError
      );
    });
  });

  describe('getUserById', () => {
    it('should return user if found', async () => {
      const mockUser = {
        id: '123',
        email: 'test@example.com',
        toJSON: jest
          .fn()
          .mockReturnValue({ id: '123', email: 'test@example.com' }),
      };
      User.findById.mockResolvedValue(mockUser);

      const result = await userService.getUserById('123');

      expect(User.findById).toHaveBeenCalledWith('123');
      expect(result).toEqual({ id: '123', email: 'test@example.com' });
    });

    it('should throw NotFoundError if user not found', async () => {
      User.findById.mockResolvedValue(null);

      await expect(userService.getUserById('123')).rejects.toThrow(
        NotFoundError
      );
    });
  });
});

Production Considerations

Monitoring and Health Checks

// src/routes/health.js
const express = require('express');
const mongoose = require('mongoose');
const redis = require('../config/redis');
const router = express.Router();

router.get('/health', async (req, res) => {
  const health = {
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    memory: process.memoryUsage(),
    services: {},
  };

  try {
    // Check database connection
    if (mongoose.connection.readyState === 1) {
      health.services.database = 'OK';
    } else {
      health.services.database = 'ERROR';
      health.status = 'ERROR';
    }

    // Check Redis connection
    try {
      await redis.ping();
      health.services.redis = 'OK';
    } catch (error) {
      health.services.redis = 'ERROR';
      health.status = 'ERROR';
    }

    const statusCode = health.status === 'OK' ? 200 : 503;
    res.status(statusCode).json(health);
  } catch (error) {
    res.status(503).json({
      status: 'ERROR',
      message: error.message,
    });
  }
});

module.exports = router;

Performance Monitoring

// src/middleware/performance.js
const logger = require('../utils/logger');

const performanceMiddleware = (req, res, next) => {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    const { method, originalUrl, ip } = req;
    const { statusCode } = res;

    logger.info({
      method,
      url: originalUrl,
      statusCode,
      duration,
      ip,
      userAgent: req.get('User-Agent'),
    });

    // Alert on slow requests
    if (duration > 5000) {
      logger.warn(`Slow request: ${method} ${originalUrl} - ${duration}ms`);
    }
  });

  next();
};

module.exports = performanceMiddleware;

Package.json Best Practices

{
  "name": "my-node-app",
  "version": "1.0.0",
  "description": "A well-structured Node.js application",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "lint": "eslint . --ext .js",
    "lint:fix": "eslint . --ext .js --fix",
    "format": "prettier --write .",
    "build": "npm run lint && npm run test",
    "migration:create": "node scripts/create-migration.js",
    "migration:run": "node scripts/run-migrations.js",
    "seed": "node scripts/seed-database.js"
  },
  "keywords": ["node", "express", "api"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "express": "^4.18.2",
    "mongoose": "^7.5.0",
    "dotenv": "^16.3.1",
    "helmet": "^7.0.0",
    "cors": "^2.8.5",
    "express-rate-limit": "^6.10.0",
    "jsonwebtoken": "^9.0.2",
    "bcryptjs": "^2.4.3",
    "express-validator": "^7.0.1",
    "winston": "^3.10.0"
  },
  "devDependencies": {
    "jest": "^29.6.4",
    "supertest": "^6.3.3",
    "nodemon": "^3.0.1",
    "eslint": "^8.47.0",
    "prettier": "^3.0.2",
    "@types/node": "^20.5.0"
  },
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=8.0.0"
  }
}

Docker Configuration

Dockerfile

# Dockerfile
FROM node:18-alpine AS base

WORKDIR /app
COPY package*.json ./

# Development stage
FROM base AS development
RUN npm ci --include=dev
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# Production dependencies
FROM base AS dependencies
RUN npm ci --only=production && npm cache clean --force

# Production stage
FROM node:18-alpine AS production
WORKDIR /app

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodeuser -u 1001

# Copy dependencies
COPY --from=dependencies /app/node_modules ./node_modules
COPY --chown=nodeuser:nodejs . .

USER nodeuser
EXPOSE 3000
CMD ["npm", "start"]

Docker Compose

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      target: development
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=development
      - DATABASE_URL=mongodb://mongo:27017/myapp
      - REDIS_URL=redis://redis:6379
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      - mongo
      - redis

  mongo:
    image: mongo:5
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

volumes:
  mongo_data:

Documentation Structure

README Template

# My Node.js Application

Brief description of what your application does.

## Prerequisites

- Node.js >= 18.0.0
- MongoDB >= 5.0
- Redis >= 6.0

## Installation

```bash
# Clone the repository
git clone <repository-url>
cd my-node-app

# Install dependencies
npm install

# Copy environment variables
cp .env.example .env

# Start services (with Docker)
docker-compose up -d

# Run database migrations
npm run migration:run

# Seed the database (optional)
npm run seed
```

Available Scripts

  • npm start - Start production server
  • npm run dev - Start development server with hot reload
  • npm test - Run tests
  • npm run lint - Run ESLint
  • npm run build - Build for production

Project Structure

src/
├── config/         # Configuration files
├── controllers/    # Route controllers
├── middleware/     # Express middleware
├── models/        # Database models
├── routes/        # Route definitions
├── services/      # Business logic
└── utils/         # Utility functions

API Documentation

Authentication

POST /api/auth/register

POST /api/auth/login

Users

GET /api/users

GET /api/users/:id

PUT /api/users/:id

DELETE /api/users/:id

Contributing

  1. Fork the repository
  2. Create your feature branch
  3. Commit your changes
  4. Push to the branch
  5. Create a Pull Request

# Conclusion

A well-structured Node.js project is essential for building maintainable, scalable applications. The key principles to remember are:

## Core Principles

1. **Separation of Concerns**: Keep different aspects of your application separate
2. **Single Responsibility**: Each module should have one clear purpose
3. **Dependency Injection**: Make dependencies explicit and testable
4. **Configuration Management**: Externalize configuration from code
5. **Error Handling**: Implement consistent error handling patterns

## Scaling Strategies

- Start simple with layered architecture
- Move to feature-based structure as you grow
- Consider DDD for complex business domains
- Implement proper monitoring and logging
- Use containers for consistent deployments

## Final Tips

- **Consistency**: Maintain consistent naming and structure
- **Documentation**: Keep your README and API docs updated
- **Testing**: Write tests as you develop, not after
- **Security**: Implement security best practices from the start
- **Performance**: Monitor and optimize continuously

Remember, there's no one-size-fits-all solution. Choose the structure that best fits your team size, project complexity, and business requirements. Start simple and evolve your structure as your application grows.

The investment in proper project structure pays dividends in reduced bugs, faster development, easier onboarding, and improved maintainability. Your future self and your team will thank you for taking the time to organize your code well from the beginning.