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

LetterComponentResponsibility
MModelData logic, database interaction, business rules, validation
VViewPresentation layer: what the user sees (HTML, templates, or JSON)
CControllerRequest 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

  1. Models own the data rules. If "email must be unique" is a rule, it lives in the Model.
  2. Models are reusable. A Model should work whether called from a Controller, a script, or a test.
  3. Models never touch req or res. 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

  1. Views contain no business logic. They only format and display data.
  2. Views receive data; they do not fetch it. The Controller passes data to the View.
  3. 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

  1. Controllers are thin. They coordinate; they do not contain business logic.
  2. Controllers handle HTTP concerns. They know about req, res, status codes, and headers.
  3. 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:

LayerChanges When...Does NOT Change When...
RoutesURL structure changesBusiness logic changes
ControllerHTTP handling changes (headers, status codes)Database schema changes
ModelData structure or validation rules changeURL structure changes
ViewResponse format changesBusiness 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

BenefitExplanation
TestabilityTest Models without HTTP, test Controllers with mocked Models
MaintainabilityBug in data logic? Check Model. Bug in response? Check Controller.
Team divisionOne dev works on Models, another on Controllers, no conflicts
ReusabilityModels can be used in API routes, scripts, background jobs
OnboardingNew developer immediately understands where code lives

Drawbacks

DrawbackExplanation
Overkill for small appsA 50-line script does not need MVC
Fat controller problemBusiness logic creeps into Controllers if not disciplined
More filesSimple CRUD requires touching 3-4 files minimum
Learning curveBeginners 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

ConventionExampleUsed By
Feature-based naminguserController.js, userModel.jsMost Express projects
PascalCase modelsUser.js, Product.jsMongoose convention
camelCase controllersuserController.jsNode.js convention
Plural routesuserRoutes.js or users.jsREST convention

Key Takeaways

  1. MVC separates your code into three concerns: data (Model), presentation (View), and coordination (Controller).
  2. Models own data and business rules. They validate, transform, and persist data. They know nothing about HTTP.
  3. Views format output. In REST APIs, this means structured JSON responses.
  4. Controllers are traffic officers. They receive requests, call Models, and send responses. They should be thin.
  5. The "fat controller" is the most common MVC mistake. When controllers contain business logic, you lose testability and reusability.
  6. Each MVC layer is independently testable. This is one of the pattern's greatest strengths.
  7. 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:

  1. Which parts belong in the Model?
  2. Which parts belong in the Controller?
  3. Why should email sending be extracted into its own module?
  4. How would this refactoring make their code easier to test?
  5. Draw (or describe) the folder structure you would create.

Home | Prev: 3.3.a — Introduction | Next: 3.3.c — MVC with REST APIs