Skip to main content

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):

javascript
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):

javascript
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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

javascript
// 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

  1. Modularize your code: Split code into logical modules based on functionality.
  2. Separate concerns: Keep routes, controllers, models, and business logic separate.
  3. Use middleware effectively: Create reusable middleware for common tasks.
  4. Implement error handling: Use consistent error handling throughout your application.
  5. Manage configuration: Keep environment-specific configuration separate.
  6. Follow naming conventions: Use consistent and descriptive naming for files and variables.
  7. Consider adding a service layer: For complex applications, add a service layer to isolate business logic.
  8. Organize by feature: For larger applications, consider organizing by feature rather than technical role.

Exercises

  1. Refactor Challenge: Take a simple Express app with all code in one file and refactor it according to the structure outlined in this guide.

  2. Add a Feature: Add a new feature to the blog API example (like user profiles or tags for posts) following the organization patterns.

  3. Create Environment Configurations: Set up development, testing, and production configurations for an Express application.

Additional Resources

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! :)