Skip to content

Project Structure

A well-organized project structure is crucial for maintainability, scalability, and team collaboration. This guide covers the standard Express.js project structure used in our projects and best practices for configuration management.

Our standard Express.js project follows a modular structure that separates concerns and promotes code reusability:

  • Directoryproject-root/
    • app.js
    • server.js
    • package.json
    • .env
    • .env.example
    • .gitignore
    • README.md
    • Directoryconfig/
      • database.js
      • cloudinary.js
      • multer.js
      • redis.js
    • Directorycontrollers/
      • userController.js
      • authController.js
      • postController.js
    • Directoryservices/
      • userService.js
      • authService.js
      • postService.js
    • Directorymodels/
      • User.js
      • Post.js
      • index.js
    • Directoryroutes/
      • index.js
      • userRoutes.js
      • authRoutes.js
      • postRoutes.js
    • Directorymiddleware/
      • auth.js
      • validation.js
      • errorHandler.js
      • logger.js
    • Directoryutils/
      • validators.js
      • helpers.js
      • constants.js
    • Directorytests/
      • Directoryunit/
      • Directoryintegration/
      • Directoryhelpers/
// app.js - Application configuration
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const compression = require('compression');
require('express-async-errors');
// Import routes
const routes = require('./routes');
const { errorHandler, notFound } = require('./middleware/errorHandler');
const app = express();
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use(limiter);
// Compression and parsing
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('combined'));
}
// Routes
app.use('/api/v1', routes);
// Error handling
app.use(notFound);
app.use(errorHandler);
module.exports = app;

Understanding how a request flows through our Express.js application:

  1. Route Layer: Receives the HTTP request and determines which controller to call
  2. Middleware Layer: Applies authentication, validation, file uploads, etc.
  3. Controller Layer: Handles the request/response cycle, delegates business logic to services
  4. Service Layer: Contains business logic, interacts with models and external services
  5. Model Layer: Handles database operations and data structure definitions

Controllers are thin layers that:

  • Extract data from HTTP requests (body, params, query, files)
  • Call appropriate service methods with the extracted data
  • Format and send HTTP responses
  • Handle HTTP-specific concerns (status codes, headers)
  • Never contain business logic - this belongs in services

Services contain the core business logic:

  • Validate business rules and constraints
  • Orchestrate multiple model operations
  • Handle external API integrations
  • Manage file uploads and processing
  • Throw meaningful errors for business logic violations
  • Independent of HTTP concerns - can be reused in different contexts

Routes define the API structure and apply middleware:

  • Map HTTP methods and paths to controller methods
  • Apply middleware in the correct order
  • Group related endpoints together
  • Define parameter validation and authentication requirements

The main router aggregates all route modules:

// routes/index.js
const express = require('express');
const userRoutes = require('./userRoutes');
const authRoutes = require('./authRoutes');
const postRoutes = require('./postRoutes');
const router = express.Router();
// API routes
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/posts', postRoutes);
module.exports = router;
Terminal window
# .env.example - Template for environment variables
# Server Configuration
NODE_ENV=development
PORT=5000
# Database
MONGODB_URI=mongodb://localhost:27017/your-app-name
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRES_IN=7d
# Cloudinary Configuration
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secret
# Email Configuration
EMAIL_FROM=noreply@yourapp.com
SENDGRID_API_KEY=your-sendgrid-key
# Redis Configuration (optional)
REDIS_URL=redis://localhost:6379
# Frontend URL
FRONTEND_URL=http://localhost:3000

Consider migrating to NestJS when your Express.js project reaches these indicators:

Team Size & Skills:

  • Team has 5+ developers
  • Team is comfortable with TypeScript
  • Need for standardized architecture patterns
  • Multiple junior developers requiring structure

Project Complexity:

  • Complex business logic with multiple services
  • Need for advanced dependency injection
  • Microservices architecture requirements
  • GraphQL integration needs
  1. Single Responsibility: Each file should have one clear purpose
  2. Dependency Direction: Controllers → Services → Models
  3. Error Handling: Use express-async-errors for cleaner async error handling
  4. Validation: Validate input at route level, not in controllers
  5. Configuration: Centralize all environment-dependent configuration
  6. HTTP Status Codes: Always use the http-status-codes package for consistent status code management

Always use the http-status-codes package instead of hardcoded numbers for better maintainability and readability:

Terminal window
npm install http-status-codes
// ✅ Good - Using http-status-codes package
const { StatusCodes } = require('http-status-codes');
// In controllers
res.status(StatusCodes.OK).json({ data: users });
res.status(StatusCodes.CREATED).json({ data: newUser });
res.status(StatusCodes.BAD_REQUEST).json({ message: 'Invalid input' });
res.status(StatusCodes.NOT_FOUND).json({ message: 'User not found' });
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: 'Server error' });
// In error classes
throw new CustomError(StatusCodes.UNAUTHORIZED, 'Invalid credentials');
throw new CustomError(StatusCodes.FORBIDDEN, 'Access denied');
// ❌ Bad - Hardcoded status codes
res.status(StatusCodes.OK).json({ data: users });
res.status(StatusCodes.NOT_FOUND).json({ message: 'User not found' });

Benefits of using http-status-codes:

  • Readability: StatusCodes.NOT_FOUND is clearer than 404
  • Maintainability: Single source of truth for status codes
  • IDE Support: Better autocomplete and type checking
  • Consistency: Ensures team uses the same status codes across the project
  1. Environment Variables: Never commit .env files
  2. Input Validation: Validate and sanitize all inputs
  3. Rate Limiting: Implement appropriate rate limiting
  4. CORS: Configure CORS properly for your frontend
  5. Helmet: Use helmet for security headers
  1. Database Indexing: Index frequently queried fields
  2. Caching: Implement Redis for frequently accessed data
  3. Compression: Use compression middleware
  4. File Uploads: Use cloud storage instead of local storage
  5. Connection Pooling: Configure appropriate database connection pools