Skip to content

Express Controllers

As routes grow beyond a couple of lines each, mixing routing definitions and business logic in the same file becomes hard to maintain. This lesson introduces the controller pattern for keeping route files thin.

What Is a Controller in Express?

A controller is simply a function, or a group of exported functions, that receives (req, res, next) and contains the actual logic for handling a request. Route files import controllers and wire them to paths, rather than defining that logic inline.

Express itself has no built-in concept of a "controller", this is purely a convention, borrowed from MVC-style frameworks, that keeps files organized as the number of routes grows.

// controllers/users.controller.js
exports.listUsers = (req, res) => {
  res.json(users);
};

exports.getUser = (req, res) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
};

// routes/users.router.js
const controller = require('../controllers/users.controller');
router.get('/', controller.listUsers);
router.get('/:id', controller.getUser);

The router file now only describes "which path maps to which function", with zero business logic inline.

Controller File Conventions

controllers/
├── users.controller.js
├── orders.controller.js
└── products.controller.js

// each exports named handler functions
exports.list = (req, res) => { ... };
exports.getById = (req, res) => { ... };
exports.create = (req, res) => { ... };
  • Name controller files after the resource they handle (users.controller.js).
  • Export one function per route action (list, getById, create, update, remove).
  • Controllers can call services (see the Services lesson) instead of talking to the database directly.
  • Keep controllers focused on translating HTTP concerns (req/res) into calls on your business logic.

Controller Pattern Cheatsheet

How responsibilities split between router, controller, and (optionally) service layers.

Layer Responsibility Example
Router Maps HTTP method + path to a controller function router.get('/:id', ctrl.getUser)
Controller Reads req, calls business logic, shapes response exports.getUser = (req, res) => {...}
Service (optional) Pure business logic, no req/res userService.findById(id)

A Full Controller Example

A well-structured controller function validates minimal HTTP concerns, delegates the actual work, and translates the result (or error) back into an HTTP response.

// controllers/orders.controller.js
const orderService = require('../services/order.service');

exports.create = async (req, res, next) => {
  try {
    const order = await orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
};

exports.getById = async (req, res, next) => {
  try {
    const order = await orderService.findById(req.params.id);
    if (!order) return res.status(404).json({ error: 'Order not found' });
    res.json(order);
  } catch (err) {
    next(err);
  }
};

Why Thin Controllers Matter

Controllers that only orchestrate, rather than implement, business logic are far easier to test, reuse (e.g. from a CLI script or background job), and reason about. Heavy business logic belongs one layer deeper, in services.

Common Mistakes

  • Writing raw database queries directly inside controller functions instead of delegating to a service layer.
  • Letting controllers grow into 200-line functions instead of splitting logic into smaller helpers.
  • Forgetting to forward errors with next(err) inside async controller functions.
  • Naming controller functions inconsistently across files, making the codebase harder to navigate.

Key Takeaways

  • Controllers are a convention, not an Express feature, for keeping route files thin.
  • Each controller function focuses on translating HTTP request/response into business logic calls.
  • Naming controllers consistently (list, getById, create, update, remove) improves readability.
  • Heavy business logic should live in a service layer, not directly inside controllers.

Pro Tip

If a controller function is hard to unit test without spinning up a real Express server, that's usually a sign business logic has leaked into the controller and should move to a service.