Episode 3 — NodeJS MongoDB Backend Architecture / 3.3 — Backend Architectures

3.3.c — MVC with REST APIs

When building REST APIs, there is no traditional "View" rendering HTML pages. Instead, the View becomes the JSON response, and a new layer emerges: the Service layer. This file covers how MVC adapts for API development, how to keep controllers thin, and how to structure a production-ready CRUD API.


Home | Prev: 3.3.b — MVC Architecture | Next: 3.3.d — SOA and Other Architectures


1. How MVC Adapts When There Is No Traditional View

In classic MVC (server-side rendering), the View was an HTML template (EJS, Pug, Handlebars). The Controller would render a page and send it to the browser.

In a REST API, the client (React, mobile app, another service) handles all rendering. The backend only sends data.

TRADITIONAL MVC                        REST API MVC
+------------+                         +------------+
| Controller |                         | Controller |
+-----+------+                         +-----+------+
      |                                      |
      v                                      v
+------------+                         +------------------+
|   View     |  (EJS/Pug template)     |  JSON Response   | (the "view")
| render HTML|                         |  { data: {...} } |
+------------+                         +------------------+
      |                                      |
      v                                      v
  Browser renders HTML               Client renders UI

What Changes

AspectTraditional MVCREST API MVC
View layerHTML templatesJSON serialization
Controller returnsres.render('page', data)res.json(data)
Who renders UIServerClient (React, mobile)
State managementServer sessionsJWT tokens, client state
Response formatHTML stringJSON object

2. The View in REST APIs: JSON Serialization

The "View" in a REST API is the shape of the JSON response. This is more important than it sounds, because it defines the API contract that frontend developers depend on.

Consistent Response Format

// utils/responseFormatter.js

// Success response
exports.success = (res, statusCode, data, meta = {}) => {
  const response = {
    status: 'success',
    ...meta,
    data
  };
  return res.status(statusCode).json(response);
};

// Fail response (client error)
exports.fail = (res, statusCode, message, errors = null) => {
  const response = {
    status: 'fail',
    message
  };
  if (errors) response.errors = errors;
  return res.status(statusCode).json(response);
};

// Error response (server error)
exports.error = (res, message = 'Internal server error') => {
  return res.status(500).json({
    status: 'error',
    message
  });
};

Usage in Controllers

const { success, fail } = require('../utils/responseFormatter');

exports.getUser = async (req, res) => {
  const user = await User.findById(req.params.id);
  
  if (!user) return fail(res, 404, 'User not found');
  
  return success(res, 200, { user });
};

Response Shape Examples

// List response (with pagination metadata)
{
  "status": "success",
  "results": 25,
  "totalPages": 5,
  "currentPage": 1,
  "data": {
    "users": [ { "id": "...", "name": "Alice" }, ... ]
  }
}

// Single resource response
{
  "status": "success",
  "data": {
    "user": { "id": "...", "name": "Alice", "email": "alice@example.com" }
  }
}

// Error response
{
  "status": "fail",
  "message": "Validation failed",
  "errors": [
    { "field": "email", "message": "Email is required" },
    { "field": "password", "message": "Password must be at least 8 characters" }
  ]
}

3. The Service Layer: Extracting Business Logic

The biggest evolution when using MVC for REST APIs is the Service layer. It sits between the Controller and the Model.

Why We Need It

Without a Service layer, business logic ends up in one of two bad places:

BAD: Logic in Controller              BAD: Logic in Model
+------------------+                  +------------------+
| Controller       |                  | Model            |
|  - HTTP handling |                  |  - Schema        |
|  - Validation    |                  |  - Validation    |
|  - Biz logic  X  |  <-- wrong      |  - Biz logic  X  | <-- also wrong
|  - Error format  |                  |  - DB queries    |
+------------------+                  +------------------+

Controller with business logic = cannot reuse logic in background jobs, CLI scripts, or other controllers.

Model with business logic = Model becomes a "god object" that does everything, hard to test, hard to understand.

The Correct Flow

Route --> Controller --> Service --> Model --> Database

+------------------+
| Route            |  URL mapping only
+--------+---------+
         |
+--------v---------+
| Controller       |  HTTP concerns only:
|  - Parse req     |  extract input, call service,
|  - Call service  |  format response
|  - Send res      |
+--------+---------+
         |
+--------v---------+
| Service          |  Business logic:
|  - Validate biz  |  rules, calculations,
|    rules         |  orchestrate multiple models,
|  - Orchestrate   |  send emails, etc.
|  - Transform     |
+--------+---------+
         |
+--------v---------+
| Model            |  Data access only:
|  - Schema        |  validate data shape,
|  - DB operations |  interact with database
+------------------+

4. Complete CRUD Example with Service Layer

Let us build a complete Product feature with all layers.

4.1 Model

// models/Product.js
const mongoose = require('mongoose');

const productSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],
    trim: true,
    maxlength: [100, 'Name cannot exceed 100 characters']
  },
  description: {
    type: String,
    maxlength: [2000, 'Description cannot exceed 2000 characters']
  },
  price: {
    type: Number,
    required: [true, 'Price is required'],
    min: [0, 'Price cannot be negative']
  },
  category: {
    type: String,
    required: [true, 'Category is required'],
    enum: ['electronics', 'clothing', 'books', 'food', 'other']
  },
  stock: {
    type: Number,
    required: true,
    min: [0, 'Stock cannot be negative'],
    default: 0
  },
  seller: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  isActive: {
    type: Boolean,
    default: true
  }
}, {
  timestamps: true
});

// Index for common queries
productSchema.index({ category: 1, price: 1 });
productSchema.index({ name: 'text', description: 'text' });

module.exports = mongoose.model('Product', productSchema);

4.2 Service

// services/productService.js
const Product = require('../models/Product');

class ProductService {
  
  // Get all products with filtering, sorting, and pagination
  async getProducts(queryParams) {
    const {
      category,
      minPrice,
      maxPrice,
      search,
      sort = '-createdAt',
      page = 1,
      limit = 10
    } = queryParams;
    
    // Build filter object
    const filter = { isActive: true };
    
    if (category) filter.category = category;
    if (minPrice || maxPrice) {
      filter.price = {};
      if (minPrice) filter.price.$gte = Number(minPrice);
      if (maxPrice) filter.price.$lte = Number(maxPrice);
    }
    if (search) {
      filter.$text = { $search: search };
    }
    
    // Execute query with pagination
    const skip = (Number(page) - 1) * Number(limit);
    
    const [products, total] = await Promise.all([
      Product.find(filter)
        .sort(sort)
        .skip(skip)
        .limit(Number(limit))
        .populate('seller', 'name email'),
      Product.countDocuments(filter)
    ]);
    
    return {
      products,
      total,
      page: Number(page),
      totalPages: Math.ceil(total / Number(limit))
    };
  }
  
  // Get a single product by ID
  async getProductById(productId) {
    const product = await Product.findById(productId)
      .populate('seller', 'name email');
    
    if (!product || !product.isActive) {
      const error = new Error('Product not found');
      error.statusCode = 404;
      throw error;
    }
    
    return product;
  }
  
  // Create a new product
  async createProduct(productData, sellerId) {
    // Business rule: sellers cannot create products priced at 0
    if (productData.price === 0) {
      const error = new Error('Free products must be approved by admin');
      error.statusCode = 400;
      throw error;
    }
    
    const product = await Product.create({
      ...productData,
      seller: sellerId
    });
    
    return product;
  }
  
  // Update a product (only by its seller)
  async updateProduct(productId, updates, userId) {
    const product = await Product.findById(productId);
    
    if (!product) {
      const error = new Error('Product not found');
      error.statusCode = 404;
      throw error;
    }
    
    // Business rule: only the seller can update their product
    if (product.seller.toString() !== userId) {
      const error = new Error('You can only update your own products');
      error.statusCode = 403;
      throw error;
    }
    
    // Business rule: cannot change seller
    delete updates.seller;
    
    Object.assign(product, updates);
    await product.save();
    
    return product;
  }
  
  // Soft delete a product
  async deleteProduct(productId, userId, userRole) {
    const product = await Product.findById(productId);
    
    if (!product) {
      const error = new Error('Product not found');
      error.statusCode = 404;
      throw error;
    }
    
    // Business rule: seller or admin can delete
    if (product.seller.toString() !== userId && userRole !== 'admin') {
      const error = new Error('Not authorized to delete this product');
      error.statusCode = 403;
      throw error;
    }
    
    // Soft delete: mark as inactive rather than removing
    product.isActive = false;
    await product.save();
    
    return product;
  }
  
  // Reduce stock when an order is placed
  async reduceStock(productId, quantity) {
    const product = await Product.findById(productId);
    
    if (!product) {
      throw new Error('Product not found');
    }
    
    if (product.stock < quantity) {
      const error = new Error(
        `Insufficient stock. Available: ${product.stock}, Requested: ${quantity}`
      );
      error.statusCode = 400;
      throw error;
    }
    
    product.stock -= quantity;
    await product.save();
    
    return product;
  }
}

module.exports = new ProductService();

4.3 Controller

// controllers/productController.js
const productService = require('../services/productService');
const { success, fail } = require('../utils/responseFormatter');

exports.getProducts = async (req, res, next) => {
  try {
    const result = await productService.getProducts(req.query);
    
    return success(res, 200, { products: result.products }, {
      results: result.products.length,
      total: result.total,
      page: result.page,
      totalPages: result.totalPages
    });
  } catch (error) {
    next(error);
  }
};

exports.getProduct = async (req, res, next) => {
  try {
    const product = await productService.getProductById(req.params.id);
    return success(res, 200, { product });
  } catch (error) {
    next(error);
  }
};

exports.createProduct = async (req, res, next) => {
  try {
    const product = await productService.createProduct(
      req.body,
      req.user.id  // from auth middleware
    );
    return success(res, 201, { product });
  } catch (error) {
    next(error);
  }
};

exports.updateProduct = async (req, res, next) => {
  try {
    const product = await productService.updateProduct(
      req.params.id,
      req.body,
      req.user.id
    );
    return success(res, 200, { product });
  } catch (error) {
    next(error);
  }
};

exports.deleteProduct = async (req, res, next) => {
  try {
    await productService.deleteProduct(
      req.params.id,
      req.user.id,
      req.user.role
    );
    return success(res, 204, null);
  } catch (error) {
    next(error);
  }
};

4.4 Routes

// routes/productRoutes.js
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const { protect, restrictTo } = require('../middlewares/auth');

// Public routes
router.get('/', productController.getProducts);
router.get('/:id', productController.getProduct);

// Protected routes
router.use(protect);
router.post('/', productController.createProduct);
router.patch('/:id', productController.updateProduct);
router.delete('/:id', productController.deleteProduct);

module.exports = router;

5. Keeping Controllers Thin

The golden rule: Controllers should only handle HTTP translation. They translate HTTP into function calls and translate results back into HTTP.

What Belongs in a Controller

// YES - these are HTTP concerns
req.body          // extracting input
req.params.id     // extracting URL parameters
req.query         // extracting query strings
req.user          // data from auth middleware
res.status(200)   // setting HTTP status
res.json({...})   // sending HTTP response
next(error)       // passing to error middleware

What Does NOT Belong in a Controller

// NO - these are business logic
bcrypt.hash(password, 12)          // --> Service or Model
product.stock < quantity           // --> Service
totalPrice * 0.1                   // --> Service
sendEmail(user.email, 'Welcome')   // --> Service
User.find({ role: 'admin' })      // --> Service or Model

Thin Controller Pattern

// GOOD: thin controller (5-10 lines per method)
exports.createOrder = async (req, res, next) => {
  try {
    const order = await orderService.createOrder(req.body, req.user.id);
    return success(res, 201, { order });
  } catch (error) {
    next(error);
  }
};

// BAD: fat controller (30+ lines per method)
exports.createOrder = async (req, res) => {
  try {
    const { items } = req.body;
    let total = 0;
    for (const item of items) {
      const product = await Product.findById(item.productId);
      if (!product) return res.status(404).json({ error: 'Product not found' });
      if (product.stock < item.quantity) return res.status(400).json({ error: 'Out of stock' });
      total += product.price * item.quantity;
      product.stock -= item.quantity;
      await product.save();
    }
    const discount = total > 100 ? total * 0.1 : 0;
    const order = await Order.create({
      user: req.user.id,
      items,
      total: total - discount,
      discount
    });
    await sendEmail(req.user.email, 'Order confirmed', { orderId: order._id });
    res.status(201).json({ status: 'success', data: { order } });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

6. Error Handling Across Layers

Each layer handles errors differently. The key is to let errors bubble up to a centralized error handler.

Layer-by-Layer Error Strategy

Model Layer:
  - Throws Mongoose ValidationError (automatic)
  - Throws custom errors for business rules

Service Layer:
  - Catches model errors, may re-throw with context
  - Throws errors with statusCode property
  - Handles "not found" cases

Controller Layer:
  - Catches service errors via try/catch
  - Passes errors to next() for centralized handling
  - Does NOT handle errors directly (no res.status in catch)

Error Middleware:
  - Catches ALL errors
  - Maps error types to HTTP status codes
  - Formats error response consistently
  - Logs errors for debugging

Global Error Handler Middleware

// middlewares/errorHandler.js
module.exports = (err, req, res, next) => {
  // Default values
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  
  // Log the error (in production, use a proper logger)
  if (err.statusCode === 500) {
    console.error('SERVER ERROR:', err);
  }
  
  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message
    }));
    return res.status(400).json({
      status: 'fail',
      message: 'Validation failed',
      errors
    });
  }
  
  // Mongoose duplicate key error
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return res.status(400).json({
      status: 'fail',
      message: `Duplicate value for ${field}. Please use a different value.`
    });
  }
  
  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    return res.status(400).json({
      status: 'fail',
      message: `Invalid ${err.path}: ${err.value}`
    });
  }
  
  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      status: 'fail',
      message: 'Invalid token. Please log in again.'
    });
  }
  
  // Custom application errors (from services)
  if (err.statusCode && err.statusCode !== 500) {
    return res.status(err.statusCode).json({
      status: 'fail',
      message: err.message
    });
  }
  
  // Unknown server error (hide details in production)
  const message = process.env.NODE_ENV === 'production'
    ? 'Something went wrong'
    : err.message;
  
  return res.status(500).json({
    status: 'error',
    message
  });
};

7. Testing Each Layer Independently

One of MVC's greatest strengths is that each layer can be tested in isolation.

Testing the Model

// tests/models/Product.test.js
const Product = require('../../src/models/Product');

describe('Product Model', () => {
  test('should require name', async () => {
    const product = new Product({ price: 10, category: 'books' });
    
    await expect(product.validate()).rejects.toThrow('Product name is required');
  });
  
  test('should reject negative price', async () => {
    const product = new Product({
      name: 'Test',
      price: -5,
      category: 'books'
    });
    
    await expect(product.validate()).rejects.toThrow('Price cannot be negative');
  });
  
  test('should reject invalid category', async () => {
    const product = new Product({
      name: 'Test',
      price: 10,
      category: 'invalid'
    });
    
    await expect(product.validate()).rejects.toThrow();
  });
});

Testing the Service

// tests/services/productService.test.js
const productService = require('../../src/services/productService');
const Product = require('../../src/models/Product');

// Mock the entire Model
jest.mock('../../src/models/Product');

describe('ProductService', () => {
  afterEach(() => jest.clearAllMocks());
  
  describe('createProduct', () => {
    test('should throw error for free products', async () => {
      await expect(
        productService.createProduct({ name: 'Free item', price: 0 }, 'seller123')
      ).rejects.toThrow('Free products must be approved by admin');
    });
    
    test('should create product with seller ID', async () => {
      const mockProduct = { _id: 'prod1', name: 'Book', price: 20 };
      Product.create.mockResolvedValue(mockProduct);
      
      const result = await productService.createProduct(
        { name: 'Book', price: 20, category: 'books' },
        'seller123'
      );
      
      expect(Product.create).toHaveBeenCalledWith(
        expect.objectContaining({ seller: 'seller123' })
      );
      expect(result).toEqual(mockProduct);
    });
  });
  
  describe('updateProduct', () => {
    test('should throw 403 if user is not the seller', async () => {
      Product.findById.mockResolvedValue({
        _id: 'prod1',
        seller: { toString: () => 'seller123' }
      });
      
      await expect(
        productService.updateProduct('prod1', { name: 'New' }, 'otherUser')
      ).rejects.toThrow('You can only update your own products');
    });
  });
});

Testing the Controller (Integration)

// tests/controllers/productController.test.js
const request = require('supertest');
const app = require('../../src/app');
const Product = require('../../src/models/Product');

describe('Product API Endpoints', () => {
  describe('GET /api/products', () => {
    test('should return paginated products', async () => {
      const res = await request(app)
        .get('/api/products')
        .query({ page: 1, limit: 5 });
      
      expect(res.status).toBe(200);
      expect(res.body.status).toBe('success');
      expect(res.body.data.products).toBeInstanceOf(Array);
      expect(res.body).toHaveProperty('totalPages');
    });
    
    test('should filter by category', async () => {
      const res = await request(app)
        .get('/api/products')
        .query({ category: 'books' });
      
      expect(res.status).toBe(200);
      res.body.data.products.forEach(product => {
        expect(product.category).toBe('books');
      });
    });
  });
  
  describe('POST /api/products', () => {
    test('should return 401 without auth token', async () => {
      const res = await request(app)
        .post('/api/products')
        .send({ name: 'Test', price: 10 });
      
      expect(res.status).toBe(401);
    });
  });
});

8. Complete Project Structure

project/
  src/
    config/
      database.js          # MongoDB connection setup
      environment.js       # Environment variable validation
    models/
      User.js              # User schema and data methods
      Product.js           # Product schema and data methods
      Order.js             # Order schema and data methods
    services/
      userService.js       # User business logic
      productService.js    # Product business logic
      orderService.js      # Order business logic
      emailService.js      # Email sending logic
    controllers/
      userController.js    # User HTTP handlers
      productController.js # Product HTTP handlers
      orderController.js   # Order HTTP handlers
    routes/
      index.js             # Combines all route files
      userRoutes.js        # /api/users routes
      productRoutes.js     # /api/products routes
      orderRoutes.js       # /api/orders routes
    middlewares/
      auth.js              # JWT authentication
      validate.js          # Request validation
      errorHandler.js      # Global error handler
    utils/
      responseFormatter.js # Consistent JSON responses
      appError.js          # Custom error class
    app.js                 # Express setup (middleware, routes)
    server.js              # Start server, connect DB
  tests/
    models/                # Model unit tests
    services/              # Service unit tests
    controllers/           # Integration tests (API tests)
    setup.js               # Test database setup/teardown
  .env                     # Environment variables
  .env.example             # Template for environment variables
  package.json

9. Custom Error Class

A reusable error class makes the service layer cleaner.

// utils/appError.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;  // distinguishes from programming errors
    
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;
// Using AppError in services
const AppError = require('../utils/appError');

async getProductById(productId) {
  const product = await Product.findById(productId);
  if (!product) throw new AppError('Product not found', 404);
  return product;
}

Key Takeaways

  1. In REST APIs, the "View" is JSON serialization. Consistent response formatting is your API contract with frontend developers.
  2. The Service layer is essential for real applications. It is where business logic lives, keeping both Controllers and Models clean.
  3. The flow is Route -> Controller -> Service -> Model -> Database. Each layer has exactly one job.
  4. Thin controllers translate HTTP to function calls and back. If your controller is more than 10 lines, business logic is leaking in.
  5. Errors should bubble up through layers to a centralized error handler middleware. Never handle errors inconsistently across controllers.
  6. Each layer is testable in isolation. Mock the layer below to test the layer above. This is the payoff of good architecture.
  7. A custom AppError class with a statusCode property makes error handling clean and consistent across the entire application.

Explain-It Challenge

Scenario: You are asked to add a "place order" feature to the product API above. The feature must:

  • Accept a list of product IDs and quantities
  • Check that all products exist and are in stock
  • Calculate the total price with a 10% discount for orders over $100
  • Reduce stock for each product
  • Create an order record in the database
  • Send a confirmation email to the user

Write out (or describe in detail):

  1. What code goes in orderService.js?
  2. What code goes in orderController.js?
  3. What code goes in Order model?
  4. How would you test the service without a real database or email server?
  5. Where does the email sending logic live, and why?

Home | Prev: 3.3.b — MVC Architecture | Next: 3.3.d — SOA and Other Architectures