Express Code Organization
When you begin building Express applications, you might start with a single file containing all your routes, middleware, and business logic. While this approach works for simple applications, it quickly becomes unmanageable as your project grows. Good code organization leads to more maintainable, testable, and collaborative code.
Why Organize Your Express Code?
Before diving into specific patterns, let's understand why code organization matters:
- Maintainability: Well-organized code is easier to update and debug
- Scalability: A good structure allows your application to grow without becoming chaotic
- Collaboration: Clear organization helps team members understand where to find and add code
- Testability: Properly structured code is easier to test in isolation
Basic Project Structure
Let's start with a basic Express application structure:
project-root/
│
├── node_modules/
├── public/
│ ├── css/
│ ├── js/
│ └── images/
│
├── src/
│ ├── routes/
│ ├── controllers/
│ ├── models/
│ ├── middleware/
│ ├── utils/
│ └── config/
│
├── views/
├── app.js
├── server.js
├── package.json
└── README.md
Key Directories Explained
- public/: Contains static files like CSS, client-side JavaScript, and images
- src/: Contains your application logic code
- routes/: Express route definitions
- controllers/: Functions that handle requests and responses
- models/: Data models and database interaction code
- middleware/: Custom Express middleware functions
- utils/: Helper functions and utilities
- config/: Configuration files for different environments
- views/: Template files if using server-side rendering
- app.js: Express application setup
- server.js: Server startup code
Separation of Concerns
One of the most important principles in code organization is separating different concerns. Let's look at a typical breakdown:
1. Routes
Routes define the endpoints of your API and what should happen when they're accessed. Each route file should handle a specific resource or group of related endpoints.
For example, a file for user routes (src/routes/userRoutes.js
):
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { authenticate } = require('../middleware/auth');
// GET all users
router.get('/', userController.getAllUsers);
// GET a specific user
router.get('/:id', userController.getUserById);
// POST new user
router.post('/', userController.createUser);
// PUT update user
router.put('/:id', authenticate, userController.updateUser);
// DELETE user
router.delete('/:id', authenticate, userController.deleteUser);
module.exports = router;
2. Controllers
Controllers handle the business logic for your routes. They receive the request, process it, and send a response.
A matching user controller (src/controllers/userController.js
):
const User = require('../models/User');
exports.getAllUsers = async (req, res) => {
try {
const users = await User.findAll();
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: 'Error retrieving users', error });
}
};
exports.getUserById = async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json(user);
} catch (error) {
res.status(500).json({ message: 'Error retrieving user', error });
}
};
exports.createUser = async (req, res) => {
try {
const newUser = await User.create(req.body);
res.status(201).json(newUser);
} catch (error) {
res.status(400).json({ message: 'Error creating user', error });
}
};
// Other controller methods...
3. Models
Models represent your data structures and handle database interactions:
// src/models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true
},
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('User', userSchema);
4. Middleware
Middleware functions can be organized by their functionality:
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config/keys');
exports.authenticate = (req, res, next) => {
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
req.userData = decoded;
next();
} catch (error) {
return res.status(401).json({
message: 'Authentication failed'
});
}
};
Setting Up the Main App
Your main app file ties everything together:
// app.js
const express = require('express');
const morgan = require('morgan');
const helmet = require('helmet');
const userRoutes = require('./src/routes/userRoutes');
const productRoutes = require('./src/routes/productRoutes');
const { errorHandler } = require('./src/middleware/errorHandler');
const app = express();
// Middleware
app.use(helmet());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));
// Routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// Default route
app.get('/', (req, res) => {
res.send('API is running');
});
// Error handling middleware (should be last)
app.use(errorHandler);
module.exports = app;
And your server file:
// server.js
const app = require('./app');
const mongoose = require('mongoose');
const { MONGODB_URI, PORT } = require('./src/config/keys');
// Connect to MongoDB
mongoose.connect(MONGODB_URI)
.then(() => {
console.log('Connected to MongoDB');
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
})
.catch((error) => {
console.error('Could not connect to MongoDB', error);
});
Advanced Organization Patterns
As your application grows, consider these advanced organization patterns:
Feature-Based Structure
For larger applications, consider organizing by feature rather than technical role:
src/
├── features/
│ ├── users/
│ │ ├── userRoutes.js
│ │ ├── userController.js
│ │ ├── userModel.js
│ │ └── userService.js
│ │
│ ├── products/
│ │ ├── productRoutes.js
│ │ ├── productController.js
│ │ ├── productModel.js
│ │ └── productService.js
│ │
│ └── auth/
│ ├── authRoutes.js
│ ├── authController.js
│ └── authMiddleware.js
│
├── common/
│ ├── middleware/
│ ├── utils/
│ └── config/
│
└── app.js
Service Layer
Adding a service layer between controllers and models helps isolate business logic:
// src/services/userService.js
const User = require('../models/User');
const bcrypt = require('bcrypt');
exports.getAllUsers = async () => {
return await User.find().select('-password');
};
exports.createUser = async (userData) => {
// Hash password before saving
const salt = await bcrypt.genSalt(10);
userData.password = await bcrypt.hash(userData.password, salt);
return await User.create(userData);
};
// More service methods...
Then update your controller:
// src/controllers/userController.js
const userService = require('../services/userService');
exports.getAllUsers = async (req, res) => {
try {
const users = await userService.getAllUsers();
res.status(200).json(users);
} catch (error) {
res.status(500).json({ message: 'Error retrieving users', error });
}
};
exports.createUser = async (req, res) => {
try {
const newUser = await userService.createUser(req.body);
res.status(201).json(newUser);
} catch (error) {
res.status(400).json({ message: 'Error creating user', error });
}
};
// Other controller methods...
Configuration Management
Different environments (development, testing, production) often need different configurations. Create a config structure like this:
// src/config/keys.js
if (process.env.NODE_ENV === 'production') {
module.exports = require('./prod');
} else if (process.env.NODE_ENV === 'test') {
module.exports = require('./test');
} else {
module.exports = require('./dev');
}
With environment-specific files:
// src/config/dev.js
module.exports = {
PORT: process.env.PORT || 3000,
MONGODB_URI: 'mongodb://localhost/myapp_dev',
JWT_SECRET: 'dev_secret_key'
};
Real-World Example: Building a Blog API
Let's put everything together in a real-world example of a blog API:
Project Structure
blog-api/
├── src/
│ ├── routes/
│ │ ├── authRoutes.js
│ │ ├── postRoutes.js
│ │ └── commentRoutes.js
│ │
│ ├── controllers/
│ │ ├── authController.js
│ │ ├── postController.js
│ │ └── commentController.js
│ │
│ ├── models/
│ │ ├── User.js
│ │ ├── Post.js
│ │ └── Comment.js
│ │
│ ├── middleware/
│ │ ├── auth.js
│ │ └── errorHandler.js
│ │
│ ├── services/
│ │ ├── authService.js
│ │ ├── postService.js
│ │ └── commentService.js
│ │
│ └── utils/
│ └── validation.js
│
├── app.js
├── server.js
└── package.json
Setting Up Routes
// src/routes/postRoutes.js
const express = require('express');
const router = express.Router();
const postController = require('../controllers/postController');
const { authenticate } = require('../middleware/auth');
router.get('/', postController.getAllPosts);
router.get('/:id', postController.getPostById);
router.post('/', authenticate, postController.createPost);
router.put('/:id', authenticate, postController.updatePost);
router.delete('/:id', authenticate, postController.deletePost);
module.exports = router;
Controller Implementation
// src/controllers/postController.js
const postService = require('../services/postService');
exports.getAllPosts = async (req, res) => {
try {
const posts = await postService.getAllPosts();
res.status(200).json(posts);
} catch (error) {
res.status(500).json({ message: 'Failed to fetch posts', error: error.message });
}
};
exports.createPost = async (req, res) => {
try {
const { title, content } = req.body;
const userId = req.userData.userId;
const post = await postService.createPost({ title, content, author: userId });
res.status(201).json(post);
} catch (error) {
res.status(400).json({ message: 'Failed to create post', error: error.message });
}
};
// Other controller methods...
Service Implementation
// src/services/postService.js
const Post = require('../models/Post');
exports.getAllPosts = async () => {
return await Post.find()
.populate('author', 'username email')
.sort({ createdAt: -1 });
};
exports.getPostById = async (id) => {
const post = await Post.findById(id)
.populate('author', 'username email')
.populate({
path: 'comments',
populate: { path: 'author', select: 'username' }
});
if (!post) {
throw new Error('Post not found');
}
return post;
};
exports.createPost = async (postData) => {
return await Post.create(postData);
};
// Other service methods...
Model Definition
// src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
content: {
type: String,
required: true
},
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
comments: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Comment'
}],
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('Post', postSchema);
Main Application Setup
// app.js
const express = require('express');
const helmet = require('helmet');
const morgan = require('morgan');
const cors = require('cors');
const authRoutes = require('./src/routes/authRoutes');
const postRoutes = require('./src/routes/postRoutes');
const commentRoutes = require('./src/routes/commentRoutes');
const { errorHandler } = require('./src/middleware/errorHandler');
const app = express();
// Middleware
app.use(helmet());
app.use(morgan('dev'));
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/posts', postRoutes);
app.use('/api/comments', commentRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({ message: 'Resource not found' });
});
// Error handler
app.use(errorHandler);
module.exports = app;
Best Practices Summary
- Modularize your code: Split code into logical modules based on functionality.
- Separate concerns: Keep routes, controllers, models, and business logic separate.
- Use middleware effectively: Create reusable middleware for common tasks.
- Implement error handling: Use consistent error handling throughout your application.
- Manage configuration: Keep environment-specific configuration separate.
- Follow naming conventions: Use consistent and descriptive naming for files and variables.
- Consider adding a service layer: For complex applications, add a service layer to isolate business logic.
- Organize by feature: For larger applications, consider organizing by feature rather than technical role.
Exercises
-
Refactor Challenge: Take a simple Express app with all code in one file and refactor it according to the structure outlined in this guide.
-
Add a Feature: Add a new feature to the blog API example (like user profiles or tags for posts) following the organization patterns.
-
Create Environment Configurations: Set up development, testing, and production configurations for an Express application.
Additional Resources
- Express.js Official Documentation
- Node.js Design Patterns
- Clean Code in JavaScript
- Express Structure Examples on GitHub
- 12 Factor App Methodology for building scalable web applications
By following these code organization principles, you'll create Express applications that are easier to maintain, scale, and collaborate on. Remember that the best structure depends on your specific project requirements, so adapt these patterns to meet your needs.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)