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
| Aspect | Traditional MVC | REST API MVC |
|---|---|---|
| View layer | HTML templates | JSON serialization |
| Controller returns | res.render('page', data) | res.json(data) |
| Who renders UI | Server | Client (React, mobile) |
| State management | Server sessions | JWT tokens, client state |
| Response format | HTML string | JSON 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
- In REST APIs, the "View" is JSON serialization. Consistent response formatting is your API contract with frontend developers.
- The Service layer is essential for real applications. It is where business logic lives, keeping both Controllers and Models clean.
- The flow is Route -> Controller -> Service -> Model -> Database. Each layer has exactly one job.
- Thin controllers translate HTTP to function calls and back. If your controller is more than 10 lines, business logic is leaking in.
- Errors should bubble up through layers to a centralized error handler middleware. Never handle errors inconsistently across controllers.
- Each layer is testable in isolation. Mock the layer below to test the layer above. This is the payoff of good architecture.
- A custom
AppErrorclass with astatusCodeproperty 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):
- What code goes in
orderService.js?- What code goes in
orderController.js?- What code goes in
Ordermodel?- How would you test the service without a real database or email server?
- Where does the email sending logic live, and why?
Home | Prev: 3.3.b — MVC Architecture | Next: 3.3.d — SOA and Other Architectures