DEV Community

Sospeter Mong'are
Sospeter Mong'are

Posted on

Understanding Controllers in Express.js

As your Express.js application grows, route handlers can quickly become cluttered with business logic, validation, database queries, and response formatting. Before long, your routes file turns into a chaotic scroll of spaghetti code that becomes harder to read, test, and maintain.

This is where controllers in Express.js become invaluable.

Controllers help you separate what the application does from how requests are routed. They give your project structure, modularity, and long-term maintainability. In this article, we’ll explore what controllers are, how they work in Express, and how to implement them effectively in real-world applications.


What Is a Controller?

A controller is a module or function responsible for handling the core logic for a specific request. Instead of doing everything inside the route definition, the controller takes over.

Without a controller (not scalable):

app.get('/users', async (req, res) => {
  const users = await User.find();
  res.json(users);
});
Enter fullscreen mode Exit fullscreen mode

With a controller (clean and scalable):

users.controller.js

exports.getUsers = async (req, res) => {
  const users = await User.find();
  res.json(users);
};
Enter fullscreen mode Exit fullscreen mode

users.routes.js

router.get('/', usersController.getUsers);
Enter fullscreen mode Exit fullscreen mode

Controllers help keep your routes thin and your logic organized.


Why Use Controllers?

1. Clean Separation of Concerns

The router should only map URLs to actions.
The controller should define what happens when that action is triggered.

2. Reusability

Controller functions can be reused across multiple routes or routers.

3. Easier Testing

You can unit-test controller logic without touching the routing layer.

4. Scalability

As your API grows, controllers allow you to organize functionality into clear modules.

5. Better Team Collaboration

Backend developers can work on logic in controllers without affecting routing files.


How Controllers Fit Into Express’s Flow

Think of the flow like this:

Client Request
   ↓
Route (URL + Method)
   ↓
Controller (business logic)
   ↓
Response Back to Client
Enter fullscreen mode Exit fullscreen mode

Routes decide where the request goes;
Controllers decide what to do with it.


A Realistic Example: Using Controllers in a Users Module

1. Create a Users Controller

controllers/users.controller.js

exports.getAllUsers = async (req, res) => {
  try {
    const users = await User.find();
    res.json({ success: true, data: users });
  } catch (err) {
    res.status(500).json({ success: false, message: 'Server error' });
  }
};

exports.createUser = async (req, res) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    res.status(400).json({ success: false, message: 'Invalid data' });
  }
};
Enter fullscreen mode Exit fullscreen mode

2. Use Them in a Router

routes/users.routes.js

const express = require('express');
const router = express.Router();
const usersController = require('../controllers/users.controller');

router.get('/', usersController.getAllUsers);
router.post('/', usersController.createUser);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

3. Attach the Router to the App

app.js

app.use('/users', require('./routes/users.routes'));
Enter fullscreen mode Exit fullscreen mode

Now the system is clean:

  • /users is handled by the users router
  • Controller functions handle actual logic

Folder Structure for Using Controllers

A typical Express project using controllers may look like:

project/
  app.js
  routes/
    users.routes.js
    products.routes.js
    orders.routes.js
  controllers/
    users.controller.js
    products.controller.js
    orders.controller.js
  models/
    User.js
    Product.js
  middlewares/
    auth.js
    validate.js
Enter fullscreen mode Exit fullscreen mode

This structure makes growth predictable and painless.


Advanced Controller Patterns

1. Controller Classes

Some developers prefer class-based controllers:

class UserController {
  async getUsers(req, res) {}
  async createUser(req, res) {}
}

module.exports = new UserController();
Enter fullscreen mode Exit fullscreen mode

2. Dependency Injection

Useful for large apps where services are injected into controllers.

3. Service Layer Architecture (Recommended)

Break controllers down even further:

Controller → Service → Model
Enter fullscreen mode Exit fullscreen mode

Controller handles requests.
Service handles business logic.
Model handles database operations.

Example:

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

exports.getUsers = async (req, res) => {
  const users = await userService.listAll();
  res.json(users);
};
Enter fullscreen mode Exit fullscreen mode

This is how enterprise-grade Express apps are built.


Best Practices for Controllers

✅ Keep controllers focused on handling requests
✅ Move heavy logic into services
✅ Never mix database queries directly in routes
✅ Name controllers clearly: users.controller.js, auth.controller.js
✅ Keep controllers small and single-purpose
✅ Use async/await and proper error handling
✅ Centralize common logic in a service layer


Final Thoughts

Controllers turn Express applications from tangled collections of routes into well-organized systems that are easier to develop, maintain, and scale. Whether you're building a small API or a complex SaaS backend, adopting controllers early will save you time and headaches.

They bring structure.
They bring clarity.
And they help your code speak in organized paragraphs instead of scattered sentences.

Top comments (0)