Episode 3 — NodeJS MongoDB Backend Architecture / 3.3 — Backend Architectures
3.3.b — MVC Architecture
MVC (Model-View-Controller) is the most widely used architectural pattern in web development. It splits your application into three interconnected components so that each piece has one job and one job only. Master MVC and you will write code that is organized, testable, and ready for team collaboration.
Home | Prev: 3.3.a — Introduction | Next: 3.3.c — MVC with REST APIs
1. What MVC Stands For
| Letter | Component | Responsibility |
|---|---|---|
| M | Model | Data logic, database interaction, business rules, validation |
| V | View | Presentation layer: what the user sees (HTML, templates, or JSON) |
| C | Controller | Request handler: receives input, coordinates Model and View |
The core idea is separation of concerns: each component handles one category of responsibility and delegates everything else.
2. Deep Dive: The Model
The Model is the brain of your application. It knows about data and the rules that govern it.
What the Model Does
- Defines data structure (schema)
- Validates data before it reaches the database
- Encapsulates business rules
- Performs CRUD operations on the database
- Knows nothing about HTTP, requests, or responses
Example: User Model with Mongoose
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, 'Please enter a valid email']
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false // Never return password in queries by default
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true // adds createdAt and updatedAt
});
// Business rule: 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();
});
// Business rule: compare password for login
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Business rule: never expose password in JSON
userSchema.methods.toJSON = function() {
const obj = this.toObject();
delete obj.password;
return obj;
};
module.exports = mongoose.model('User', userSchema);
Key Principles for Models
- Models own the data rules. If "email must be unique" is a rule, it lives in the Model.
- Models are reusable. A Model should work whether called from a Controller, a script, or a test.
- Models never touch
reqorres. They have no idea they are being used in a web application.
3. Deep Dive: The View
The View is the face of your application. It controls what the user sees.
In Traditional Web Apps (Server-Side Rendering)
View = HTML templates (EJS, Pug, Handlebars)
// views/users/profile.ejs
<html>
<body>
<h1>Welcome, <%= user.name %></h1>
<p>Email: <%= user.email %></p>
<p>Member since: <%= user.createdAt.toLocaleDateString() %></p>
</body>
</html>
In REST APIs (Modern Approach)
View = JSON response (the serialized data you send back)
// The "view" is the JSON structure returned to the client
{
"status": "success",
"data": {
"user": {
"id": "64a1b2c3d4e5f6a7b8c9d0e1",
"name": "Alice",
"email": "alice@example.com",
"role": "user",
"createdAt": "2025-01-15T10:30:00.000Z"
}
}
}
Key Principles for Views
- Views contain no business logic. They only format and display data.
- Views receive data; they do not fetch it. The Controller passes data to the View.
- Views are replaceable. You should be able to swap from EJS to JSON without touching Models or Controllers.
4. Deep Dive: The Controller
The Controller is the traffic officer. It receives requests, asks the Model for data, and sends it to the View.
What the Controller Does
- Receives the HTTP request
- Extracts and validates input from the request
- Calls the Model (or Service) to perform business logic
- Selects the appropriate View and passes data to it
- Sends the HTTP response
Example: User Controller
// controllers/userController.js
const User = require('../models/User');
// GET /api/users
exports.getAllUsers = async (req, res) => {
try {
const users = await User.find({ isActive: true });
res.status(200).json({
status: 'success',
results: users.length,
data: { users }
});
} catch (error) {
res.status(500).json({
status: 'error',
message: 'Failed to retrieve users'
});
}
};
// GET /api/users/:id
exports.getUserById = async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
status: 'fail',
message: 'No user found with that ID'
});
}
res.status(200).json({
status: 'success',
data: { user }
});
} catch (error) {
res.status(500).json({
status: 'error',
message: 'Failed to retrieve user'
});
}
};
// POST /api/users
exports.createUser = async (req, res) => {
try {
const { name, email, password } = req.body;
const newUser = await User.create({ name, email, password });
res.status(201).json({
status: 'success',
data: { user: newUser }
});
} catch (error) {
if (error.code === 11000) {
return res.status(400).json({
status: 'fail',
message: 'Email already exists'
});
}
res.status(400).json({
status: 'fail',
message: error.message
});
}
};
// PATCH /api/users/:id
exports.updateUser = async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!user) {
return res.status(404).json({
status: 'fail',
message: 'No user found with that ID'
});
}
res.status(200).json({
status: 'success',
data: { user }
});
} catch (error) {
res.status(400).json({
status: 'fail',
message: error.message
});
}
};
// DELETE /api/users/:id
exports.deleteUser = async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return res.status(404).json({
status: 'fail',
message: 'No user found with that ID'
});
}
res.status(204).json({
status: 'success',
data: null
});
} catch (error) {
res.status(500).json({
status: 'error',
message: 'Failed to delete user'
});
}
};
Key Principles for Controllers
- Controllers are thin. They coordinate; they do not contain business logic.
- Controllers handle HTTP concerns. They know about
req,res, status codes, and headers. - Controllers delegate. They call Models or Services for actual work.
5. Request Flow Through MVC
CLIENT (Browser / Postman / Frontend App)
|
| HTTP Request: POST /api/users { name: "Alice", email: "alice@example.com" }
|
v
+---------------------------------------------------+
| ROUTES |
| router.post('/api/users', userController.create) |
+---------------------------------------------------+
|
| Matched route calls the controller function
|
v
+---------------------------------------------------+
| CONTROLLER |
| 1. Extract data from req.body |
| 2. Call User.create(data) <-- delegates to Model |
| 3. Format and send response |
+---------------------------------------------------+
|
| Calls the Model
|
v
+---------------------------------------------------+
| MODEL |
| 1. Validate data against schema |
| 2. Hash password (pre-save hook) |
| 3. Insert document into MongoDB |
| 4. Return the created user object |
+---------------------------------------------------+
|
| Data flows back up
|
v
+---------------------------------------------------+
| CONTROLLER |
| Receives user object, sends JSON response |
+---------------------------------------------------+
|
| HTTP Response: 201 { status: "success", data: { user: {...} } }
|
v
CLIENT
6. MVC in Node.js/Express: Folder Structure
project/
src/
models/
User.js # User schema, validation, business rules
Product.js # Product schema
Order.js # Order schema
views/
(empty for REST APIs, or EJS/Pug templates)
controllers/
userController.js # Handles user-related HTTP requests
productController.js # Handles product-related HTTP requests
orderController.js # Handles order-related HTTP requests
routes/
userRoutes.js # Maps URLs to user controller functions
productRoutes.js # Maps URLs to product controller functions
orderRoutes.js # Maps URLs to order controller functions
middlewares/
auth.js # Authentication middleware
errorHandler.js # Global error handling
app.js # Express app configuration
server.js # Server startup
package.json
.env
Routes File (The Glue)
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { protect, restrictTo } = require('../middlewares/auth');
// Public routes
router.post('/signup', userController.createUser);
// Protected routes (require login)
router.use(protect); // middleware applies to all routes below
router.get('/', restrictTo('admin'), userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.patch('/:id', userController.updateUser);
router.delete('/:id', restrictTo('admin'), userController.deleteUser);
module.exports = router;
App Setup
// app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const productRoutes = require('./routes/productRoutes');
const errorHandler = require('./middlewares/errorHandler');
const app = express();
// Middleware
app.use(express.json());
// Routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
// Global error handler (must be last)
app.use(errorHandler);
module.exports = app;
7. Separation of Concerns Principle
Each layer has one reason to change:
| Layer | Changes When... | Does NOT Change When... |
|---|---|---|
| Routes | URL structure changes | Business logic changes |
| Controller | HTTP handling changes (headers, status codes) | Database schema changes |
| Model | Data structure or validation rules change | URL structure changes |
| View | Response format changes | Business rules change |
Violation example (everything in one function):
// BAD: route handler does everything
app.post('/api/users', async (req, res) => {
// Validation (should be in Model)
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Bad email' });
}
// Database logic (should be in Model)
const hashedPassword = await bcrypt.hash(req.body.password, 12);
const user = await db.collection('users').insertOne({
...req.body,
password: hashedPassword
});
// Email sending (should be in a Service)
await transporter.sendMail({
to: req.body.email,
subject: 'Welcome!'
});
res.status(201).json(user);
});
8. Benefits and Drawbacks of MVC
Benefits
| Benefit | Explanation |
|---|---|
| Testability | Test Models without HTTP, test Controllers with mocked Models |
| Maintainability | Bug in data logic? Check Model. Bug in response? Check Controller. |
| Team division | One dev works on Models, another on Controllers, no conflicts |
| Reusability | Models can be used in API routes, scripts, background jobs |
| Onboarding | New developer immediately understands where code lives |
Drawbacks
| Drawback | Explanation |
|---|---|
| Overkill for small apps | A 50-line script does not need MVC |
| Fat controller problem | Business logic creeps into Controllers if not disciplined |
| More files | Simple CRUD requires touching 3-4 files minimum |
| Learning curve | Beginners may not understand why separation matters initially |
The "Fat Controller" Anti-Pattern
// BAD: Controller doing too much
exports.createOrder = async (req, res) => {
// Input validation (fine here)
const { userId, items } = req.body;
// Business logic (should NOT be here)
let total = 0;
for (const item of items) {
const product = await Product.findById(item.productId);
if (product.stock < item.quantity) {
return res.status(400).json({ error: `${product.name} out of stock` });
}
total += product.price * item.quantity;
}
// More business logic in the controller...
const discount = total > 100 ? total * 0.1 : 0;
const finalTotal = total - discount;
// Updating inventory (definitely should NOT be here)
for (const item of items) {
await Product.findByIdAndUpdate(item.productId, {
$inc: { stock: -item.quantity }
});
}
const order = await Order.create({
userId, items, total: finalTotal, discount
});
res.status(201).json(order);
};
Solution: Move business logic to a Service layer (covered in 3.3.c).
9. Real Example: Building a User Feature with MVC
Let us build a complete "user profile update" feature step by step.
Step 1: Model (User.js)
// models/User.js (add this method to the existing model)
userSchema.statics.findByIdAndUpdateProfile = async function(userId, updates) {
// Business rule: only these fields can be updated via profile
const allowedFields = ['name', 'email', 'bio', 'avatar'];
const filteredUpdates = {};
Object.keys(updates).forEach(key => {
if (allowedFields.includes(key)) {
filteredUpdates[key] = updates[key];
}
});
if (Object.keys(filteredUpdates).length === 0) {
throw new Error('No valid fields to update');
}
return this.findByIdAndUpdate(userId, filteredUpdates, {
new: true,
runValidators: true
});
};
Step 2: Controller (userController.js)
// controllers/userController.js
exports.updateProfile = async (req, res) => {
try {
const user = await User.findByIdAndUpdateProfile(
req.user.id, // from auth middleware
req.body
);
if (!user) {
return res.status(404).json({
status: 'fail',
message: 'User not found'
});
}
res.status(200).json({
status: 'success',
data: { user }
});
} catch (error) {
res.status(400).json({
status: 'fail',
message: error.message
});
}
};
Step 3: Route (userRoutes.js)
// routes/userRoutes.js
router.patch('/profile', protect, userController.updateProfile);
Step 4: Test
// tests/userController.test.js
const request = require('supertest');
const app = require('../src/app');
describe('PATCH /api/users/profile', () => {
it('should update allowed fields only', async () => {
const res = await request(app)
.patch('/api/users/profile')
.set('Authorization', `Bearer ${testToken}`)
.send({ name: 'New Name', role: 'admin' }); // role should be ignored
expect(res.status).toBe(200);
expect(res.body.data.user.name).toBe('New Name');
expect(res.body.data.user.role).toBe('user'); // unchanged
});
});
10. MVC File Naming Conventions
| Convention | Example | Used By |
|---|---|---|
| Feature-based naming | userController.js, userModel.js | Most Express projects |
| PascalCase models | User.js, Product.js | Mongoose convention |
| camelCase controllers | userController.js | Node.js convention |
| Plural routes | userRoutes.js or users.js | REST convention |
Key Takeaways
- MVC separates your code into three concerns: data (Model), presentation (View), and coordination (Controller).
- Models own data and business rules. They validate, transform, and persist data. They know nothing about HTTP.
- Views format output. In REST APIs, this means structured JSON responses.
- Controllers are traffic officers. They receive requests, call Models, and send responses. They should be thin.
- The "fat controller" is the most common MVC mistake. When controllers contain business logic, you lose testability and reusability.
- Each MVC layer is independently testable. This is one of the pattern's greatest strengths.
- MVC is not just a pattern; it is a communication tool. When someone says "check the controller," every developer knows exactly where to look.
Explain-It Challenge
Scenario: A junior developer on your team writes an Express route handler that:
- Validates the request body
- Queries the database directly
- Calculates a discount based on business rules
- Sends an email to the customer
- Returns the JSON response
All in one single function, 80 lines long.
Explain to them:
- Which parts belong in the Model?
- Which parts belong in the Controller?
- Why should email sending be extracted into its own module?
- How would this refactoring make their code easier to test?
- Draw (or describe) the folder structure you would create.
Home | Prev: 3.3.a — Introduction | Next: 3.3.c — MVC with REST APIs