Organizing a growing Express.js application can become challenging without proper structure. Today, I'll show you two proven approaches: traditional MVC architecture and modern feature-based structure. Both have their strengths, and choosing the right one can make or break your project's maintainability.
Why Structure Matters
A well-organized codebase provides:
- Easy navigation and code discovery
- Clear separation of concerns
- Simplified testing and debugging
- Better team collaboration
- Easier onboarding for new developers
MVC Architecture Pattern
The Model-View-Controller pattern separates your application by technical responsibility. Here's a production-ready structure:
express-mvc-app/
├── src/
│ ├── controllers/ # Request handlers
│ │ ├── authController.js
│ │ ├── userController.js
│ │ ├── productController.js
│ │ └── orderController.js
│ ├── models/ # Database schemas
│ │ ├── User.js
│ │ ├── Product.js
│ │ └── Order.js
│ ├── routes/ # Route definitions
│ │ ├── index.js
│ │ ├── auth.js
│ │ ├── users.js
│ │ ├── products.js
│ │ └── orders.js
│ ├── middleware/ # Custom middleware
│ │ ├── auth.js
│ │ ├── validation.js
│ │ ├── errorHandler.js
│ │ └── rateLimiter.js
│ ├── services/ # Business logic
│ │ ├── authService.js
│ │ ├── userService.js
│ │ ├── emailService.js
│ │ └── paymentService.js
│ ├── utils/ # Helper functions
│ │ ├── logger.js
│ │ ├── database.js
│ │ ├── validation.js
│ │ └── constants.js
│ ├── config/ # Configuration files
│ │ ├── database.js
│ │ ├── keys.js
│ │ └── passport.js
│ └── app.js # Express app setup
├── tests/ # Test files
├── docs/ # API documentation
├── .env # Environment variables
├── .gitignore
├── package.json
└── server.js # Entry point
MVC Implementation Example
Let's see how this structure works in practice:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
}
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
module.exports = mongoose.model('User', userSchema);
// services/userService.js
const User = require('../models/User');
const bcrypt = require('bcryptjs');
class UserService {
async createUser(userData) {
try {
const user = new User(userData);
await user.save();
return { success: true, user: user.toObject({ versionKey: false }) };
} catch (error) {
return { success: false, error: error.message };
}
}
async getUserById(id) {
try {
const user = await User.findById(id).select('-password');
return { success: true, user };
} catch (error) {
return { success: false, error: error.message };
}
}
async validatePassword(email, password) {
try {
const user = await User.findOne({ email });
if (!user) return { success: false, error: 'User not found' };
const isValid = await bcrypt.compare(password, user.password);
return { success: isValid, user: isValid ? user : null };
} catch (error) {
return { success: false, error: error.message };
}
}
}
module.exports = new UserService();
// controllers/userController.js
const userService = require('../services/userService');
class UserController {
async getProfile(req, res) {
try {
const { success, user, error } = await userService.getUserById(req.user.id);
if (!success) {
return res.status(404).json({ message: error });
}
res.json({ user });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
}
async updateProfile(req, res) {
try {
const { username, email } = req.body;
const { success, user, error } = await userService.updateUser(req.user.id, { username, email });
if (!success) {
return res.status(400).json({ message: error });
}
res.json({ message: 'Profile updated successfully', user });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
}
}
module.exports = new UserController();
Feature-Based Architecture
Feature-based structure organizes code around business features rather than technical layers. This approach scales better for large applications:
express-feature-app/
├── src/
│ ├── features/
│ │ ├── auth/
│ │ │ ├── models/
│ │ │ │ └── User.js
│ │ │ ├── controllers/
│ │ │ │ └── authController.js
│ │ │ ├── routes/
│ │ │ │ └── authRoutes.js
│ │ │ ├── services/
│ │ │ │ └── authService.js
│ │ │ ├── middleware/
│ │ │ │ └── authMiddleware.js
│ │ │ └── validators/
│ │ │ └── authValidators.js
│ │ ├── products/
│ │ │ ├── models/
│ │ │ │ └── Product.js
│ │ │ ├── controllers/
│ │ │ │ └── productController.js
│ │ │ ├── routes/
│ │ │ │ └── productRoutes.js
│ │ │ ├── services/
│ │ │ │ └── productService.js
│ │ │ └── validators/
│ │ │ └── productValidators.js
│ │ └── orders/
│ │ ├── models/
│ │ │ └── Order.js
│ │ ├── controllers/
│ │ │ └── orderController.js
│ │ ├── routes/
│ │ │ └── orderRoutes.js
│ │ └── services/
│ │ └── orderService.js
│ ├── shared/ # Shared utilities
│ │ ├── middleware/
│ │ │ ├── errorHandler.js
│ │ │ └── rateLimiter.js
│ │ ├── utils/
│ │ │ ├── logger.js
│ │ │ ├── database.js
│ │ │ └── helpers.js
│ │ ├── config/
│ │ │ ├── database.js
│ │ │ └── keys.js
│ │ └── constants/
│ │ └── index.js
│ ├── routes.js # Main route aggregator
│ └── app.js # Express app setup
├── tests/
└── server.js
Feature-Based Implementation
// features/auth/services/authService.js
const User = require('../models/User');
const jwt = require('jsonwebtoken');
class AuthService {
generateToken(userId) {
return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '7d' });
}
async register(userData) {
try {
const existingUser = await User.findOne({
$or: [{ email: userData.email }, { username: userData.username }]
});
if (existingUser) {
return { success: false, error: 'User already exists' };
}
const user = new User(userData);
await user.save();
const token = this.generateToken(user._id);
return {
success: true,
user: user.toObject({ versionKey: false }),
token
};
} catch (error) {
return { success: false, error: error.message };
}
}
async login(email, password) {
try {
const user = await User.findOne({ email });
if (!user) {
return { success: false, error: 'Invalid credentials' };
}
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return { success: false, error: 'Invalid credentials' };
}
const token = this.generateToken(user._id);
return {
success: true,
user: user.toObject({ versionKey: false }),
token
};
} catch (error) {
return { success: false, error: error.message };
}
}
}
module.exports = new AuthService();
// routes.js - Main route aggregator
const express = require('express');
const authRoutes = require('./features/auth/routes/authRoutes');
const productRoutes = require('./features/products/routes/productRoutes');
const orderRoutes = require('./features/orders/routes/orderRoutes');
const router = express.Router();
// Feature routes
router.use('/api/auth', authRoutes);
router.use('/api/products', productRoutes);
router.use('/api/orders', orderRoutes);
// Health check
router.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
module.exports = router;
Shared Database Configuration
Both structures benefit from centralized database setup:
// shared/utils/database.js (or utils/database.js in MVC)
const mongoose = require('mongoose');
const logger = require('./logger');
class Database {
async connect() {
try {
const options = {
useNewUrlParser: true,
useUnifiedTopology: true,
maxPoolSize: 10, // Connection pooling
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
};
await mongoose.connect(process.env.MONGODB_URI, options);
logger.info('MongoDB connected successfully');
// Connection event listeners
mongoose.connection.on('error', (err) => {
logger.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
logger.warn('MongoDB disconnected');
});
// Graceful shutdown
process.on('SIGINT', async () => {
await mongoose.connection.close();
logger.info('MongoDB connection closed due to app termination');
process.exit(0);
});
} catch (error) {
logger.error('Database connection failed:', error);
process.exit(1);
}
}
}
module.exports = new Database();
When to Choose Each Approach
Choose MVC When:
- Small to medium projects (< 50 endpoints)
- Simple business logic with clear technical separation
- Team familiar with traditional patterns
- Rapid prototyping and getting started quickly
Choose Feature-Based When:
- Large applications with complex business domains
- Multiple teams working on different features
- Microservices preparation - easy to extract features
- Domain-driven design approach
Hybrid Approach
For medium-sized applications, consider a hybrid structure:
express-hybrid-app/
├── src/
│ ├── core/ # Core business features
│ │ ├── auth/
│ │ ├── users/
│ │ └── products/
│ ├── shared/ # Shared components
│ │ ├── models/
│ │ ├── middleware/
│ │ ├── utils/
│ │ └── config/
│ ├── controllers/ # Thin controllers
│ ├── routes/ # Route definitions
│ └── app.js
└── server.js
Best Practices for Both Approaches
1. Environment Configuration
// config/keys.js
module.exports = {
mongoURI: process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp',
jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development'
};
2. Error Handling Middleware
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
// Mongoose validation error
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map(val => val.message).join(', ');
error = { message, statusCode: 400 };
}
// Mongoose duplicate key
if (err.code === 11000) {
const message = 'Duplicate field value entered';
error = { message, statusCode: 400 };
}
res.status(error.statusCode || 500).json({
success: false,
message: error.message || 'Server Error'
});
};
module.exports = errorHandler;
3. Input Validation
// validators/userValidators.js
const Joi = require('joi');
const registerValidator = Joi.object({
username: Joi.string().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required()
});
const validateRegister = (req, res, next) => {
const { error } = registerValidator.validate(req.body);
if (error) {
return res.status(400).json({ message: error.details[0].message });
}
next();
};
module.exports = { validateRegister };
Wrapping Up
Both MVC and feature-based architectures have their place in Express.js development:
- ✅ MVC - Great for smaller apps, familiar pattern, quick setup
- ✅ Feature-based - Scales better, easier team collaboration, domain-focused
- ✅ Hybrid - Best of both worlds for medium applications
The key is consistency within your chosen approach. Both structures benefit from proper separation of concerns, error handling, and validation. Start with the structure that fits your team size and project complexity, then evolve as your needs grow.
Remember: the best architecture is the one your team can understand and maintain effectively. Choose based on your current needs, not hypothetical future requirements.
