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.
Standard Express.js Project Structure
Section titled “Standard Express.js Project Structure”Directory Organization
Section titled “Directory Organization”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/
- …
Core Files Explanation
Section titled “Core Files Explanation”Application Entry Point
Section titled “Application Entry Point”// app.js - Application configurationconst 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 routesconst routes = require('./routes');const { errorHandler, notFound } = require('./middleware/errorHandler');
const app = express();
// Security middlewareapp.use(helmet());app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true}));
// Rate limitingconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100 // limit each IP to 100 requests per windowMs});app.use(limiter);
// Compression and parsingapp.use(compression());app.use(express.json({ limit: '10mb' }));app.use(express.urlencoded({ extended: true }));
// Loggingif (process.env.NODE_ENV !== 'test') { app.use(morgan('combined'));}
// Routesapp.use('/api/v1', routes);
// Error handlingapp.use(notFound);app.use(errorHandler);
module.exports = app;
// server.js - Server startupconst app = require('./app');const connectDB = require('./db/connection');
const PORT = process.env.PORT || 5000;
const startServer = async () => { try { // Connect to database await connectDB(); console.log('Database connected successfully');
// Start server const server = app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV}`); });
// Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); server.close(() => { console.log('Process terminated'); }); });
} catch (error) { console.error('Failed to start server:', error); process.exit(1); }};
startServer();
Application Architecture
Section titled “Application Architecture”Request Flow Workflow
Section titled “Request Flow Workflow”Understanding how a request flows through our Express.js application:
- Route Layer: Receives the HTTP request and determines which controller to call
- Middleware Layer: Applies authentication, validation, file uploads, etc.
- Controller Layer: Handles the request/response cycle, delegates business logic to services
- Service Layer: Contains business logic, interacts with models and external services
- Model Layer: Handles database operations and data structure definitions
Layer Responsibilities
Section titled “Layer Responsibilities”Controllers Layer
Section titled “Controllers Layer”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 Layer
Section titled “Services Layer”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 Layer
Section titled “Routes Layer”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
Routes Organization
Section titled “Routes Organization”The main router aggregates all route modules:
// routes/index.jsconst express = require('express');const userRoutes = require('./userRoutes');const authRoutes = require('./authRoutes');const postRoutes = require('./postRoutes');
const router = express.Router();
// API routesrouter.use('/auth', authRoutes);router.use('/users', userRoutes);router.use('/posts', postRoutes);
module.exports = router;
Configuration Management
Section titled “Configuration Management”Environment Variables
Section titled “Environment Variables”# .env.example - Template for environment variables
# Server ConfigurationNODE_ENV=developmentPORT=5000
# DatabaseMONGODB_URI=mongodb://localhost:27017/your-app-name
# JWT ConfigurationJWT_SECRET=your-super-secret-jwt-keyJWT_EXPIRES_IN=7d
# Cloudinary ConfigurationCLOUDINARY_CLOUD_NAME=your-cloud-nameCLOUDINARY_API_KEY=your-api-keyCLOUDINARY_API_SECRET=your-api-secret
# Email ConfigurationEMAIL_FROM=noreply@yourapp.comSENDGRID_API_KEY=your-sendgrid-key
# Redis Configuration (optional)REDIS_URL=redis://localhost:6379
# Frontend URLFRONTEND_URL=http://localhost:3000
// config/index.js - Centralized configurationrequire('dotenv').config();
const config = { env: process.env.NODE_ENV || 'development', port: process.env.PORT || 5000,
database: { uri: process.env.MONGODB_URI, },
jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
cloudinary: { cloudName: process.env.CLOUDINARY_CLOUD_NAME, apiKey: process.env.CLOUDINARY_API_KEY, apiSecret: process.env.CLOUDINARY_API_SECRET },
email: { from: process.env.EMAIL_FROM, sendgridApiKey: process.env.SENDGRID_API_KEY },
redis: { url: process.env.REDIS_URL },};
// Validate required environment variablesconst requiredEnvVars = ['JWT_SECRET', 'MONGODB_URI'];
requiredEnvVars.forEach(envVar => { if (!process.env[envVar]) { throw new Error(`Missing required environment variable: ${envVar}`); }});
module.exports = config;
When to Transition to NestJS
Section titled “When to Transition to NestJS”Project Scale Indicators
Section titled “Project Scale Indicators”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
Technical Needs:
- Strong typing requirements (TypeScript-first)
- Advanced testing frameworks and mocking
- Built-in validation and transformation pipes
- Decorator-based programming preferred
- WebSocket or GraphQL subscriptions
- Complex authentication strategies
Maintenance Concerns:
- Difficulty maintaining code consistency
- Repeated boilerplate code across modules
- Need for better code organization
- Testing complexity growing significantly
Best Practices
Section titled “Best Practices”Code Organization
Section titled “Code Organization”- Single Responsibility: Each file should have one clear purpose
- Dependency Direction: Controllers → Services → Models
- Error Handling: Use express-async-errors for cleaner async error handling
- Validation: Validate input at route level, not in controllers
- Configuration: Centralize all environment-dependent configuration
- HTTP Status Codes: Always use the
http-status-codes
package for consistent status code management
HTTP Status Code Management
Section titled “HTTP Status Code Management”Always use the http-status-codes
package instead of hardcoded numbers for better maintainability and readability:
npm install http-status-codes
// ✅ Good - Using http-status-codes packageconst { StatusCodes } = require('http-status-codes');
// In controllersres.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 classesthrow new CustomError(StatusCodes.UNAUTHORIZED, 'Invalid credentials');throw new CustomError(StatusCodes.FORBIDDEN, 'Access denied');
// ❌ Bad - Hardcoded status codesres.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 than404
- 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
Security Practices
Section titled “Security Practices”- Environment Variables: Never commit .env files
- Input Validation: Validate and sanitize all inputs
- Rate Limiting: Implement appropriate rate limiting
- CORS: Configure CORS properly for your frontend
- Helmet: Use helmet for security headers
Performance Optimization
Section titled “Performance Optimization”- Database Indexing: Index frequently queried fields
- Caching: Implement Redis for frequently accessed data
- Compression: Use compression middleware
- File Uploads: Use cloud storage instead of local storage
- Connection Pooling: Configure appropriate database connection pools