Skip to main content

🎛️ Controller Pattern Implementation

Once your routes are modular, the next question is where the logic lives. Stuffing database calls and business rules directly into route callbacks makes them long, duplicated, and painful to test. The Controller Pattern splits the two: routes say what the API exposes, controllers own how each request is handled. This lesson builds a clean layered architecture — routes, controllers, and an optional service layer — with modern async error handling.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the Controller Pattern and how it separates routing from logic
  • Implement a resource controller with full CRUD methods
  • Handle async errors cleanly with a catchAsync wrapper and a central error handler
  • Introduce a service layer to isolate business logic from HTTP concerns
  • Reduce repetition with a controller factory
  • Write isolated unit tests for a controller by mocking its dependencies

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Extract inline route logic into a controller + service, with shared error handling.

In This Lesson

What Is the Controller Pattern?

The Controller Pattern separates route definitions (which URLs and methods exist) from their implementation logic (what actually happens). Routes become a thin map of endpoints to functions; those functions — the controllers — hold the request-handling code.

💡 Analogy: An Express app is a restaurant. Routes are the menu customers read — a list of what's available. Controllers are the chefs who know how to cook each dish. The menu can be reprinted without touching the recipes, and chefs can refine recipes without reprinting the menu.

flowchart LR A[Client Request] --> B[Route] B -->|delegates| C[Controller] C -->|asks for data| D[Model / Service] D -->|returns data| C C -->|formats response| E[HTTP Response] E --> F[Client]
  • Routes — declare endpoints and wire in middleware.
  • Controllers — parse the request, coordinate work, shape the response.
  • Models / Services — data access and business rules.

Why Separate Routes from Logic

The split pays off across the whole lifecycle of an app:

BenefitWhat it buys you
Separation of concernsRoutes handle URL/method binding; controllers handle logic — each changes independently.
TestabilityControllers can be unit-tested without spinning up an HTTP server.
ReusabilityThe same controller method can back multiple routes or API versions.
ReadabilityRoute files become a scannable table of contents for the API.
ScalabilityTeams work on different controllers without colliding in one file.
Layered Express architecture Three columns — Routes mapping URLs, Controllers processing requests, and Models handling data access — with arrows flowing left to right. Routes URL & method mapping GET /users GET /users/:id POST /users DELETE /users/:id Controllers request processing getAllUsers() getUserById() createUser() deleteUser() Models / Services data & business rules User.find() User.findById() User.create() User.delete()
Figure 1 — Each layer has one job. A request flows left to right; data flows back. Any layer can be swapped or tested without disturbing the others.

🌍 Real-world: Large platforms keep API controllers (HTTP handling) separate from service classes (inventory, payments, fulfillment) so the same business logic backs the web UI, mobile app, and public API without divergence.

A Basic Controller

Start by moving each route's callback into a named method on a controller object. The route file shrinks to a clean mapping.

project/
├── app.js
├── routes/user.routes.js
├── controllers/user.controller.js
└── models/user.model.js
// controllers/user.controller.js
const User = require('../models/user.model');

const userController = {
  getAllUsers: async (req, res) => {
    try {
      const users = await User.find();
      res.status(200).json({ data: users });
    } catch (err) {
      res.status(500).json({ error: err.message });
    }
  },

  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 (err) {
      res.status(500).json({ error: err.message });
    }
  },

  createUser: async (req, res) => {
    try {
      const user = await User.create(req.body);
      res.status(201).json({ data: user });
    } catch (err) {
      res.status(400).json({ error: err.message });
    }
  },

  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({ error: 'User not found' });
      res.status(200).json({ data: user });
    } catch (err) {
      res.status(400).json({ error: err.message });
    }
  },

  deleteUser: async (req, res) => {
    try {
      const user = await User.findByIdAndDelete(req.params.id);
      if (!user) return res.status(404).json({ error: 'User not found' });
      res.status(204).end();
    } catch (err) {
      res.status(500).json({ error: err.message });
    }
  },
};

module.exports = userController;
// routes/user.routes.js — now just a map
const router = require('express').Router();
const c = require('../controllers/user.controller');

router.get('/',    c.getAllUsers);
router.get('/:id', c.getUserById);
router.post('/',   c.createUser);
router.put('/:id', c.updateUser);
router.delete('/:id', c.deleteUser);

module.exports = router;

📖 RESTful naming convention

MethodPathController
GET/usersgetAllUsers
GET/users/:idgetUserById
POST/userscreateUser
PUT / PATCH/users/:idupdateUser
DELETE/users/:iddeleteUser

Async Error Handling

Notice the repeated try/catch in every method above — noisy and easy to get wrong. In Express 4, an error thrown in an async handler is not caught automatically; you must forward it to next(). A small wrapper removes the boilerplate.

⚠️ Express 4 vs 5 on async errors

In Express 4 a rejected promise in a handler will hang unless you catch it and call next(err). Express 5 automatically forwards rejected promises from async handlers to the error middleware — but the catchAsync pattern below is still the portable, explicit choice that works in both.

// utils/catchAsync.js
const catchAsync = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

module.exports = catchAsync;
// utils/AppError.js — a semantic operational error
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = String(statusCode).startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}
module.exports = AppError;
// controllers/user.controller.js — no try/catch noise
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const User = require('../models/user.model');

exports.getUserById = 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 });
});

exports.getAllUsers = catchAsync(async (req, res) => {
  const users = await User.find();
  res.status(200).json({ data: users });
});
// app.js — one central error handler, registered LAST
app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  res.status(status).json({
    status: err.status || 'error',
    message: err.message,
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
  });
});

💡 Analogy: Central error handling is a factory's safety system. When a machine (DB call) fails, the system detects it, halts that operation, and sends one clear alert to the control room (the client) — instead of every station inventing its own alarm.

The Service Layer

For non-trivial apps, add a service layer between controllers and models. Controllers then only deal with HTTP (reading the request, choosing a status, shaping the response), while services own the business logic and data access. The same service can back an API, a CLI, or a background job.

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

exports.getAllUsers = () => User.find();
exports.getUserById = (id) => User.findById(id);
exports.createUser  = (data) => User.create(data);

// Business logic lives here, not in the controller
exports.activateUser = async (id) => {
  const user = await User.findById(id);
  if (!user) throw new Error('User not found');
  user.active = true;
  user.activatedAt = new Date();
  return user.save();
};
// controllers/user.controller.js — thin HTTP glue
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const userService = require('../services/user.service');

exports.getAllUsers = catchAsync(async (req, res) => {
  const users = await userService.getAllUsers();
  res.status(200).json({ data: users });
});

exports.activateUser = catchAsync(async (req, res, next) => {
  const user = await userService.activateUser(req.params.id);
  res.status(200).json({ message: 'User activated', data: user });
});
graph LR A[Client] --> B[Route] B --> C[Controller] C --> D[Service] D --> E[Model] E --> F[(Database)] F --> E --> D --> C --> A

💡 When do you actually need a service layer?

Skip it for simple CRUD — a controller calling a model directly is fine. Add it once business logic starts appearing in multiple controllers, or a single action touches several models. Don't add layers preemptively.

Controller Factories

Most resources share the same CRUD skeleton. A factory generates those methods for any model, so you write them once and only hand-author the resource-specific extras.

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

exports.getAll = (Model) => catchAsync(async (req, res) => {
  const docs = await Model.find();
  res.status(200).json({ data: docs });
});

exports.getOne = (Model) => catchAsync(async (req, res, next) => {
  const doc = await Model.findById(req.params.id);
  if (!doc) return next(new AppError('Not found', 404));
  res.status(200).json({ data: doc });
});

exports.createOne = (Model) => catchAsync(async (req, res) => {
  const doc = await Model.create(req.body);
  res.status(201).json({ data: doc });
});

exports.deleteOne = (Model) => catchAsync(async (req, res, next) => {
  const doc = await Model.findByIdAndDelete(req.params.id);
  if (!doc) return next(new AppError('Not found', 404));
  res.status(204).end();
});
// controllers/user.controller.js
const factory = require('../utils/controllerFactory');
const User = require('../models/user.model');
const catchAsync = require('../utils/catchAsync');

exports.getAllUsers = factory.getAll(User);
exports.getUser     = factory.getOne(User);
exports.createUser  = factory.createOne(User);
exports.deleteUser  = factory.deleteOne(User);

// Resource-specific method, hand-written
exports.search = catchAsync(async (req, res) => {
  const { q } = req.query;
  const users = await User.find({
    $or: [
      { name:  { $regex: q, $options: 'i' } },
      { email: { $regex: q, $options: 'i' } },
    ],
  });
  res.status(200).json({ data: users });
});

🌍 Real-world: The NestJS framework (built on Express) formalizes this with class-based controllers and dependency injection — the same idea, letting teams reuse CRUD scaffolding while keeping routes and logic cleanly separated.

Testing Controllers

Because controllers no longer touch the HTTP server directly, you can unit-test them by mocking the service and passing fake req/res/next objects — fast tests with no database.

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

jest.mock('../services/user.service');

describe('getAllUsers', () => {
  it('responds 200 with the users', async () => {
    const req = {};
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    const next = jest.fn();
    userService.getAllUsers.mockResolvedValue([{ id: 1, name: 'Ada' }]);

    await userController.getAllUsers(req, res, next);

    expect(res.status).toHaveBeenCalledWith(200);
    expect(res.json).toHaveBeenCalledWith({ data: [{ id: 1, name: 'Ada' }] });
    expect(next).not.toHaveBeenCalled();
  });

  it('forwards service errors to next()', async () => {
    const req = {};
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
    const next = jest.fn();
    const err = new Error('boom');
    userService.getAllUsers.mockRejectedValue(err);

    await userController.getAllUsers(req, res, next);

    expect(next).toHaveBeenCalledWith(err);
  });
});

For end-to-end confidence, add integration tests with Supertest that drive the real routes and assert on real HTTP responses. Use both: unit tests for logic branches, integration tests for the wiring.

💡 Analogy: Unit tests check each machine (controller method) in isolation with simulated inputs; integration tests run the whole assembly line from raw materials (HTTP request) to finished product (HTTP response).

Hands-on Exercise

🏋️ Extract Logic into a Controller + Service

Objective: Refactor an inline route into a layered structure with shared async error handling.

Starting point (all logic inline):

app.get('/tasks/:id', async (req, res) => {
  const task = tasks.find(t => t.id === Number(req.params.id));
  if (!task) return res.status(404).json({ error: 'Not found' });
  res.json({ data: task });
});

Instructions:

  1. Create utils/catchAsync.js and utils/AppError.js.
  2. Create services/task.service.js with getTaskById(id) returning the task or null.
  3. Create controllers/task.controller.js with getTask wrapped in catchAsync, using AppError for the 404.
  4. Create routes/task.routes.js mapping GET /:id to the controller.
  5. Register a central error handler in app.js and confirm a missing id returns a clean 404.
💡 Hint

The controller should contain no try/catchcatchAsync forwards rejections to next, and the central handler turns your AppError into the JSON response. Register that handler last, after all routes.

✅ Solution
// utils/catchAsync.js
module.exports = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// utils/AppError.js
module.exports = class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = String(statusCode).startsWith('4') ? 'fail' : 'error';
  }
};

// services/task.service.js
const tasks = [{ id: 1, title: 'Write tests' }];
exports.getTaskById = async (id) =>
  tasks.find(t => t.id === Number(id)) ?? null;

// controllers/task.controller.js
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/AppError');
const taskService = require('../services/task.service');

exports.getTask = catchAsync(async (req, res, next) => {
  const task = await taskService.getTaskById(req.params.id);
  if (!task) return next(new AppError('Task not found', 404));
  res.status(200).json({ data: task });
});

// routes/task.routes.js
const router = require('express').Router();
const c = require('../controllers/task.controller');
router.get('/:id', c.getTask);
module.exports = router;

// app.js
const express = require('express');
const app = express();
app.use('/tasks', require('./routes/task.routes'));
app.use((err, req, res, next) => {
  res.status(err.statusCode || 500)
     .json({ status: err.status || 'error', message: err.message });
});
app.listen(3000);

🎯 Quick Quiz

Question 1: In the Controller Pattern, what is a route file mainly responsible for?

Question 2: What problem does a catchAsync wrapper solve?

Question 3: Where should the central error-handling middleware be registered?

Best Practices

✅ Do❌ Avoid
Keep controllers focused on HTTP: parse request, pick status, shape responseBurying business logic and raw DB calls in route callbacks
Forward errors with next(err) and handle them centrallyRepeating try/catch and ad-hoc error JSON in every method
Use consistent RESTful method names (getAllUsers, createUser)Inconsistent names that obscure what each does
Add a service layer only when logic is shared or complexLayering everything preemptively for a simple CRUD app
Unit-test controllers with mocks; integration-test the wiringRelying only on manual clicking to verify behavior

Summary & Quiz

🎉 Key Takeaways

  • The Controller Pattern makes routes a thin map and puts logic in controllers.
  • A catchAsync wrapper plus a central error handler removes repetitive try/catch.
  • Express 5 auto-forwards async rejections, but catchAsync stays portable across versions.
  • A service layer isolates business logic — add it when logic is shared or complex, not by default.
  • Controller factories generate CRUD boilerplate for any model.
  • Thin controllers are easy to unit-test with mocked dependencies.

📚 Further Reading

🚀 What's Next?

With routes, routers, and controllers in place, you have the skeleton of a real backend. Next we'll assemble these pieces into a full RESTful API with Express — designing resources, status codes, and endpoints end to end.

🎉 Clean architecture achieved!

Your logic is testable, reusable, and out of the route files. On to building a full REST API.