Module 16: JavaScript Backend

Controller Pattern Implementation in Express.js

Introduction to the Controller Pattern

The Controller Pattern is a design pattern that separates route definitions (URLs and HTTP methods) from their implementation logic. This separation helps create a more maintainable and testable application architecture.

Analogy: Think of an Express application like a restaurant. Routes are like the menu that customers see, showing what's available. Controllers are like the chefs who know how to prepare each dish. The menu (routes) simply lists the dishes, while the detailed recipes and cooking techniques (business logic) belong to the chefs (controllers). This separation allows the menu to change without affecting how dishes are prepared, and lets chefs refine their recipes without requiring menu updates.

graph LR A[Client Request] --> B[Routes] B -->|Pass request| C[Controllers] C -->|Process request| D[Models/Services] D -->|Return data| C C -->|Format response| E[HTTP Response] E --> F[Client] style B fill:#e3f2fd,stroke:#1976d2 style C fill:#e8f5e9,stroke:#388e3c style D fill:#fff3e0,stroke:#f57c00

In this pattern:

Benefits of the Controller Pattern

Separating routes from controllers offers several significant advantages:

Separation of Concerns

The controller pattern divides responsibilities clearly:

Code Organization

With controllers, related functionality is grouped together regardless of URL structure, making it easier to locate and maintain specific features.

Testability

Controllers can be tested independently of the HTTP layer, allowing for more focused unit tests.

Reusability

The same controller functions can be used by different routes, reducing code duplication.

Maintainability

Changes to business logic don't require changes to route definitions, and vice versa.

Scalability

As an application grows, the controller pattern helps manage complexity by maintaining a clear structure.

Express MVC Architecture Routes URL mapping GET /users GET /users/:id POST /users PUT /users/:id DELETE /users/:id Controllers Request processing getAllUsers() getUserById() createUser() updateUser() deleteUser() Models Data access User.find() User.findById() User.create() User.update() User.delete()

Real-world example: The e-commerce platform Shopify uses the controller pattern in their backend architecture. Their routes define the API endpoints for products, customers, orders, etc., while separate controller modules handle the complex logic of inventory management, payment processing, and order fulfillment. This separation allows their development teams to work on specific features without interfering with each other.

Basic Controller Implementation

Let's start with a simple controller implementation for a user resource:

Project Structure


project/
├── app.js                 # Main application file
├── routes/
│   └── user.routes.js     # User routes
├── controllers/
│   └── user.controller.js # User controller
├── models/
│   └── user.model.js      # User model
└── middleware/
    └── auth.js            # Authentication middleware
            

User Controller


// controllers/user.controller.js
const User = require('../models/user.model');

// Controller object with methods for each route handler
const userController = {
  // Get all users
  getAllUsers: async (req, res) => {
    try {
      const users = await User.find();
      res.status(200).json({ data: users });
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  },
  
  // Get a single user by ID
  getUserById: async (req, res) => {
    try {
      const user = await User.findById(req.params.id);
      
      if (!user) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      res.status(200).json({ data: user });
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  },
  
  // Create a new user
  createUser: async (req, res) => {
    try {
      const newUser = await User.create(req.body);
      res.status(201).json({ data: newUser });
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  },
  
  // Update a user
  updateUser: async (req, res) => {
    try {
      const updatedUser = await User.findByIdAndUpdate(
        req.params.id,
        req.body,
        { new: true, runValidators: true }
      );
      
      if (!updatedUser) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      res.status(200).json({ data: updatedUser });
    } catch (error) {
      res.status(400).json({ error: error.message });
    }
  },
  
  // Delete a user
  deleteUser: async (req, res) => {
    try {
      const deletedUser = await User.findByIdAndDelete(req.params.id);
      
      if (!deletedUser) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      res.status(200).json({ message: 'User deleted successfully' });
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
};

module.exports = userController;
            

User Routes


// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');

// Map routes to controller methods
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);

module.exports = router;
            

Main Application


// app.js
const express = require('express');
const userRoutes = require('./routes/user.routes');

const app = express();

// Middleware
app.use(express.json());

// Routes
app.use('/api/users', userRoutes);

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
            

This basic implementation demonstrates the separation of routes and controllers. The routes define the URL structure, while the controllers contain the logic for handling requests.

Analogy: The controller methods are like specialized chefs in a kitchen, each with a specific dish they know how to prepare. When a customer (client) places an order (makes an HTTP request), the waiter (route) knows which chef (controller method) is responsible for preparing that dish and delivers the order to them.

Controller Organization Strategies

There are several ways to organize controllers in an Express application:

Resource-Based Controllers

Organize controllers by resource type, with one controller per resource:


// controllers/user.controller.js
// controllers/product.controller.js
// controllers/order.controller.js
            

Feature-Based Controllers

Organize controllers by application feature or domain:


// controllers/auth.controller.js
// controllers/admin.controller.js
// controllers/public.controller.js
            

Action-Based Controllers

Organize controllers by action type:


// controllers/create.controller.js
// controllers/read.controller.js
// controllers/update.controller.js
// controllers/delete.controller.js
            

Choosing an Organization Strategy

The resource-based approach is most common in RESTful APIs and provides the clearest organization for most applications. However, the best strategy depends on your specific application needs:

Strategy Best For Considerations
Resource-based RESTful APIs, CRUD operations Clear mapping to database models
Feature-based Complex applications with distinct feature sets May involve multiple resources per feature
Action-based Applications with complex workflows Less common, can be harder to navigate

Real-world example: The popular Node.js CMS Strapi uses resource-based controllers for their content types. Each content type (article, product, category, etc.) has its own controller with standard CRUD methods, making it easy for developers to understand how to interact with different types of content through the API.

Controller Method Naming Conventions

Consistent naming of controller methods helps maintain a clear, understandable codebase. Here are common naming conventions for RESTful controllers:

HTTP Method URL Pattern Controller Method Purpose
GET /resources getAll[Resources] List all resources
GET /resources/:id get[Resource] Get a single resource
POST /resources create[Resource] Create a new resource
PUT /resources/:id update[Resource] Replace a resource
PATCH /resources/:id update[Resource] Partially update a resource
DELETE /resources/:id delete[Resource] Delete a resource

Additional Method Naming Patterns

For non-standard operations, consistent naming is still important:


// Search functionality
searchUsers: async (req, res) => {
  // Implementation...
}

// Complex actions
activateUser: async (req, res) => {
  // Implementation...
}

deactivateUser: async (req, res) => {
  // Implementation...
}

// Nested resources
getUserPosts: async (req, res) => {
  // Implementation...
}

createUserPost: async (req, res) => {
  // Implementation...
}
            
classDiagram class UserController { +getAllUsers() +getUser() +createUser() +updateUser() +deleteUser() +searchUsers() +activateUser() +deactivateUser() +getUserPosts() } class ProductController { +getAllProducts() +getProduct() +createProduct() +updateProduct() +deleteProduct() +searchProducts() +featureProduct() +unfeatureProduct() +getProductReviews() } class OrderController { +getAllOrders() +getOrder() +createOrder() +updateOrder() +deleteOrder() +searchOrders() +processOrder() +cancelOrder() +getOrderItems() }

Error Handling in Controllers

Proper error handling is crucial in controllers to ensure robust application behavior and meaningful error responses.

Try/Catch Blocks

Since controllers often contain asynchronous operations, try/catch blocks are essential:


// Basic error handling with try/catch
const getUser = async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    
    res.status(200).json({ data: user });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};
            

Centralized Error Handling

For more advanced applications, consider using a centralized error handling approach:


// Create custom error classes
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    
    Error.captureStackTrace(this, this.constructor);
  }
}

// Controller with centralized error handling
const getUser = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    
    if (!user) {
      // Create a custom error and pass it to the error handler
      return next(new AppError('User not found', 404));
    }
    
    res.status(200).json({ data: user });
  } catch (error) {
    next(error);
  }
};

// Global error handler middleware
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  
  res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
});
            

Async Wrapper Function

To reduce repetitive try/catch blocks, you can create an async wrapper function:


// Async wrapper utility
const catchAsync = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

// Simplified controller method using the wrapper
const getUser = catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return next(new AppError('User not found', 404));
  }
  
  res.status(200).json({ data: user });
});

// Apply to all controller methods
const getAllUsers = catchAsync(async (req, res) => {
  const users = await User.find();
  res.status(200).json({ data: users });
});

const createUser = catchAsync(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json({ data: user });
});
            

Analogy: Error handling in controllers is like a safety system in a factory. When a machine (database operation) malfunctions, the safety system detects the problem, shuts down the affected area (aborts the operation), and sends a clear alert to the control center (client) with information about what went wrong and how serious it is. This prevents cascading failures and helps engineers diagnose and fix the issue quickly.

Service Layer Pattern

For complex applications, it's often beneficial to add a service layer between controllers and models. This layer encapsulates business logic, making controllers focused solely on HTTP request/response handling.

Project Structure with Services


project/
├── app.js
├── routes/
│   └── user.routes.js
├── controllers/
│   └── user.controller.js
├── services/
│   └── user.service.js
├── models/
│   └── user.model.js
└── middleware/
    └── auth.js
            

User Service


// services/user.service.js
const User = require('../models/user.model');

const userService = {
  getAllUsers: async () => {
    return await User.find();
  },
  
  getUserById: async (userId) => {
    return await User.findById(userId);
  },
  
  createUser: async (userData) => {
    return await User.create(userData);
  },
  
  updateUser: async (userId, userData) => {
    return await User.findByIdAndUpdate(userId, userData, {
      new: true,
      runValidators: true
    });
  },
  
  deleteUser: async (userId) => {
    return await User.findByIdAndDelete(userId);
  },
  
  // Business logic methods
  activateUser: async (userId) => {
    const user = await User.findById(userId);
    
    if (!user) {
      throw new Error('User not found');
    }
    
    user.active = true;
    user.activatedAt = new Date();
    
    return await user.save();
  },
  
  deactivateUser: async (userId) => {
    const user = await User.findById(userId);
    
    if (!user) {
      throw new Error('User not found');
    }
    
    user.active = false;
    user.deactivatedAt = new Date();
    
    return await user.save();
  }
};

module.exports = userService;
            

Controller Using Service Layer


// controllers/user.controller.js
const userService = require('../services/user.service');

const userController = {
  getAllUsers: async (req, res, next) => {
    try {
      const users = await userService.getAllUsers();
      res.status(200).json({ data: users });
    } catch (error) {
      next(error);
    }
  },
  
  getUserById: async (req, res, next) => {
    try {
      const user = await userService.getUserById(req.params.id);
      
      if (!user) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      res.status(200).json({ data: user });
    } catch (error) {
      next(error);
    }
  },
  
  // Other CRUD methods...
  
  // Controller for activation
  activateUser: async (req, res, next) => {
    try {
      const user = await userService.activateUser(req.params.id);
      res.status(200).json({ 
        message: 'User activated successfully',
        data: user
      });
    } catch (error) {
      next(error);
    }
  },
  
  // Controller for deactivation
  deactivateUser: async (req, res, next) => {
    try {
      const user = await userService.deactivateUser(req.params.id);
      res.status(200).json({ 
        message: 'User deactivated successfully',
        data: user
      });
    } catch (error) {
      next(error);
    }
  }
};

module.exports = userController;
            
graph TB A[Client Request] --> B[Routes] B --> C[Controllers] C --> D[Services] D --> E[Models] E --> F[Database] F --> E E --> D D --> C C --> G[Client Response] style B fill:#e3f2fd,stroke:#1976d2 style C fill:#e8f5e9,stroke:#388e3c style D fill:#fff3e0,stroke:#f57c00 style E fill:#f3e5f5,stroke:#7b1fa2 style F fill:#e1f5fe,stroke:#0288d1

Real-world example: Enterprise applications like Salesforce use a service layer pattern in their architecture. Their CRM system separates API controllers (which handle HTTP requests) from service classes that implement complex business logic like opportunity tracking, lead scoring, and account management. This allows them to reuse the same business logic across multiple interfaces (web UI, mobile app, API) while maintaining consistent behavior.

Controller Testing

The controller pattern facilitates effective testing by isolating HTTP handling from business logic.

Unit Testing Controllers

When using a service layer, controllers can be unit tested by mocking the service:


// Test file: controllers/user.controller.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const userController = require('../controllers/user.controller');
const userService = require('../services/user.service');

describe('User Controller', () => {
  describe('getAllUsers', () => {
    it('should return all users with status 200', async () => {
      // Arrange
      const req = {};
      const res = {
        status: sinon.stub().returnsThis(),
        json: sinon.spy()
      };
      const next = sinon.spy();
      
      const users = [{ id: 1, name: 'Test User' }];
      sinon.stub(userService, 'getAllUsers').resolves(users);
      
      // Act
      await userController.getAllUsers(req, res, next);
      
      // Assert
      expect(res.status.calledWith(200)).to.be.true;
      expect(res.json.calledWith({ data: users })).to.be.true;
      expect(next.called).to.be.false;
      
      // Cleanup
      userService.getAllUsers.restore();
    });
    
    it('should call next with error if service throws', async () => {
      // Arrange
      const req = {};
      const res = {
        status: sinon.stub().returnsThis(),
        json: sinon.spy()
      };
      const next = sinon.spy();
      
      const error = new Error('Service error');
      sinon.stub(userService, 'getAllUsers').rejects(error);
      
      // Act
      await userController.getAllUsers(req, res, next);
      
      // Assert
      expect(next.calledWith(error)).to.be.true;
      expect(res.status.called).to.be.false;
      
      // Cleanup
      userService.getAllUsers.restore();
    });
  });
  
  // More test cases for other controller methods...
});
            

Integration Testing

Integration tests verify that controllers work correctly with actual HTTP requests:


// Test file: integration/user.routes.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../app');
const User = require('../models/user.model');

describe('User Routes', () => {
  beforeEach(async () => {
    // Clear users collection before each test
    await User.deleteMany({});
  });
  
  describe('GET /api/users', () => {
    it('should return all users', async () => {
      // Arrange
      await User.create([
        { name: 'User 1', email: 'user1@example.com' },
        { name: 'User 2', email: 'user2@example.com' }
      ]);
      
      // Act
      const res = await request(app)
        .get('/api/users')
        .expect('Content-Type', /json/)
        .expect(200);
      
      // Assert
      expect(res.body.data).to.be.an('array');
      expect(res.body.data).to.have.lengthOf(2);
      expect(res.body.data[0]).to.have.property('name', 'User 1');
    });
    
    // More integration tests...
  });
});
            

Analogy: Testing controllers is like quality control in a factory production line. Unit tests check that each machine (controller method) functions correctly in isolation, with simulated inputs and outputs. Integration tests verify that the entire production line works together, from raw materials (HTTP requests) to finished products (HTTP responses).

Advanced Controller Patterns

Controller Factory Pattern

For resources with similar CRUD operations, a controller factory can reduce code duplication:


// utils/controllerFactory.js
const AppError = require('./appError');

const createController = (Model) => {
  return {
    getAll: async (req, res, next) => {
      try {
        const documents = await Model.find();
        res.status(200).json({ data: documents });
      } catch (error) {
        next(error);
      }
    },
    
    getOne: async (req, res, next) => {
      try {
        const document = await Model.findById(req.params.id);
        
        if (!document) {
          return next(new AppError('Document not found', 404));
        }
        
        res.status(200).json({ data: document });
      } catch (error) {
        next(error);
      }
    },
    
    create: async (req, res, next) => {
      try {
        const document = await Model.create(req.body);
        res.status(201).json({ data: document });
      } catch (error) {
        next(error);
      }
    },
    
    update: async (req, res, next) => {
      try {
        const document = await Model.findByIdAndUpdate(
          req.params.id,
          req.body,
          { new: true, runValidators: true }
        );
        
        if (!document) {
          return next(new AppError('Document not found', 404));
        }
        
        res.status(200).json({ data: document });
      } catch (error) {
        next(error);
      }
    },
    
    delete: async (req, res, next) => {
      try {
        const document = await Model.findByIdAndDelete(req.params.id);
        
        if (!document) {
          return next(new AppError('Document not found', 404));
        }
        
        res.status(204).json(null);
      } catch (error) {
        next(error);
      }
    }
  };
};

module.exports = createController;
            

Using the Factory


// controllers/user.controller.js
const User = require('../models/user.model');
const createController = require('../utils/controllerFactory');

// Create base controller methods
const userController = createController(User);

// Add custom methods
userController.search = async (req, res, next) => {
  try {
    const { query } = req.query;
    const users = await User.find({
      $or: [
        { name: { $regex: query, $options: 'i' } },
        { email: { $regex: query, $options: 'i' } }
      ]
    });
    
    res.status(200).json({ data: users });
  } catch (error) {
    next(error);
  }
};

module.exports = userController;
            

Class-Based Controllers

For an object-oriented approach, controllers can be implemented as classes:


// controllers/BaseController.js
class BaseController {
  constructor(Model) {
    this.Model = Model;
  }
  
  getAll = async (req, res, next) => {
    try {
      const documents = await this.Model.find();
      res.status(200).json({ data: documents });
    } catch (error) {
      next(error);
    }
  }
  
  getOne = async (req, res, next) => {
    try {
      const document = await this.Model.findById(req.params.id);
      
      if (!document) {
        return res.status(404).json({ error: 'Document not found' });
      }
      
      res.status(200).json({ data: document });
    } catch (error) {
      next(error);
    }
  }
  
  // Other CRUD methods...
}

module.exports = BaseController;

// controllers/UserController.js
const BaseController = require('./BaseController');
const User = require('../models/user.model');

class UserController extends BaseController {
  constructor() {
    super(User);
  }
  
  // Custom methods specific to users
  search = async (req, res, next) => {
    // Implementation...
  }
}

module.exports = new UserController();
            

Real-world example: The NestJS framework (built on Express) uses class-based controllers as a core architectural pattern. Their controllers are decorated classes with methods that handle specific routes, allowing developers to leverage inheritance, dependency injection, and other OOP features while maintaining a clear separation between routes and business logic.

Authentication and Authorization in Controllers

Controllers often need to handle authentication and authorization concerns:

Authentication Middleware

Authentication is typically implemented as middleware that runs before controller methods:


// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/user.model');

const authenticate = async (req, res, next) => {
  try {
    // Get token from header
    const token = req.headers.authorization?.split(' ')[1];
    
    if (!token) {
      return res.status(401).json({ error: 'Authentication required' });
    }
    
    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    // Find user
    const user = await User.findById(decoded.id);
    
    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }
    
    // Attach user to request
    req.user = user;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

module.exports = { authenticate };
            

Authorization Middleware

Authorization middleware checks user permissions:


// middleware/auth.js
// ... authentication middleware

const authorize = (...roles) => {
  return (req, res, next) => {
    // Check if user exists (should be set by authenticate middleware)
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }
    
    // Check if user has required role
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Permission denied' });
    }
    
    next();
  };
};

module.exports = { authenticate, authorize };
            

Applying Auth to Routes


// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { authenticate, authorize } = require('../middleware/auth');

// Public routes
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);

// Protected routes - require authentication
router.post('/', authenticate, userController.createUser);

// Admin-only routes - require authentication and admin role
router.put('/:id', authenticate, authorize('admin'), userController.updateUser);
router.delete('/:id', authenticate, authorize('admin'), userController.deleteUser);

module.exports = router;
            

Resource Ownership Authorization

For resource-specific authorization, use middleware or controller logic:


// middleware/auth.js
// ... other middleware

const checkOwnership = (Model) => {
  return async (req, res, next) => {
    try {
      const resource = await Model.findById(req.params.id);
      
      if (!resource) {
        return res.status(404).json({ error: 'Resource not found' });
      }
      
      // Check if the authenticated user is the owner
      if (resource.user.toString() !== req.user.id) {
        return res.status(403).json({ error: 'Permission denied' });
      }
      
      // Resource exists and user is owner
      req.resource = resource;
      next();
    } catch (error) {
      next(error);
    }
  };
};

// routes/post.routes.js
router.put('/:id', 
  authenticate, 
  checkOwnership(Post),
  postController.updatePost
);
            
sequenceDiagram participant Client participant Router participant Auth as Authentication Middleware participant AuthZ as Authorization Middleware participant Controller participant Service participant Model Client->>Router: PUT /posts/123 Router->>Auth: authenticate alt Authentication Success Auth->>AuthZ: checkOwnership alt Ownership Verified AuthZ->>Controller: updatePost Controller->>Service: updatePost Service->>Model: update Model-->>Service: Updated post Service-->>Controller: Updated post Controller-->>Client: 200 OK else Not Owner AuthZ-->>Client: 403 Forbidden end else Authentication Failure Auth-->>Client: 401 Unauthorized end

Response Formatting

Consistent response formatting is important for API usability. Controllers should follow a standardized response structure.

Response Format Utility


// utils/response.js
const formatResponse = (data, message = null, meta = {}) => {
  const response = {};
  
  if (data !== undefined) {
    response.data = data;
  }
  
  if (message) {
    response.message = message;
  }
  
  if (Object.keys(meta).length > 0) {
    response.meta = meta;
  }
  
  return response;
};

const formatError = (message, code = 'INTERNAL_ERROR', details = null) => {
  const error = {
    message,
    code
  };
  
  if (details) {
    error.details = details;
  }
  
  return { error };
};

module.exports = { formatResponse, formatError };
            

Using Response Formatters in Controllers


// controllers/user.controller.js
const { formatResponse, formatError } = require('../utils/response');
const userService = require('../services/user.service');

const userController = {
  getAllUsers: async (req, res, next) => {
    try {
      const { page = 1, limit = 20 } = req.query;
      const skip = (page - 1) * limit;
      
      const [users, total] = await Promise.all([
        userService.getAllUsers(skip, limit),
        userService.countUsers()
      ]);
      
      const totalPages = Math.ceil(total / limit);
      
      res.status(200).json(formatResponse(users, null, {
        pagination: {
          page: parseInt(page),
          limit: parseInt(limit),
          total,
          totalPages
        }
      }));
    } catch (error) {
      next(error);
    }
  },
  
  getUserById: async (req, res, next) => {
    try {
      const user = await userService.getUserById(req.params.id);
      
      if (!user) {
        return res.status(404).json(formatError(
          'User not found', 
          'NOT_FOUND'
        ));
      }
      
      res.status(200).json(formatResponse(user));
    } catch (error) {
      next(error);
    }
  },
  
  // Other methods...
};

module.exports = userController;
            

Response Middleware Approach

Alternatively, you can standardize responses using middleware that wraps res.json:


// middleware/response.js
const responseFormatter = (req, res, next) => {
  // Store the original res.json method
  const originalJson = res.json;
  
  // Override res.json method
  res.json = function(body) {
    // Skip if already formatted
    if (body && (body.data || body.error || body.message)) {
      return originalJson.call(this, body);
    }
    
    // Format the response
    const formatted = {
      data: body,
      meta: {
        timestamp: new Date().toISOString(),
        requestId: req.id || undefined
      }
    };
    
    // Call the original json method with the formatted response
    return originalJson.call(this, formatted);
  };
  
  // Continue to the next middleware
  next();
};

// Apply to all routes
app.use(responseFormatter);
            

Real-world example: Financial API providers like Plaid use consistent response formatting in their controllers to ensure that all endpoints return data in a predictable structure. Their responses always include standardized status indicators, request identifiers, and well-structured data objects, which makes it easier for developers to integrate with their services.

Practice Activities

Activity 1: Basic Controller Implementation

Implement a complete controller for a resource of your choice:

  • Create a User model with basic fields (name, email, password, role)
  • Implement a UserController with CRUD operations
  • Create routes that map to controller methods
  • Add proper error handling to all controller methods
  • Implement basic validation for inputs

Test your controller using Postman or curl to ensure it works correctly.

Activity 2: Service Layer Implementation

Enhance your controller implementation with a service layer:

  • Create a UserService that encapsulates database operations
  • Refactor your UserController to use the service
  • Add complex business logic in the service (e.g., user activation, search)
  • Implement unit tests for both the controller and service
  • Add meaningful error messages and proper status codes

Compare the maintainability and testability of this approach with the basic controller implementation.

Activity 3: Controller Factory Pattern

Implement a controller factory for multiple resources:

  • Create a controller factory that generates CRUD methods
  • Implement at least three different resource controllers (e.g., users, products, orders)
  • Add custom methods to each controller beyond the generated ones
  • Implement consistent response formatting across all controllers
  • Add authentication and authorization to protected routes

Create an API documentation file explaining the endpoints and their functionality.

Key Takeaways

With a solid understanding of the Controller Pattern, you can build Express applications that are well-organized, maintainable, and testable.