Skip to content

Controllers

intermediate2 min read
  • NestJS
  • Express
  • Node.js

The layer that receives an HTTP request, coordinates the work, and returns a response, while delegating the actual business logic to services.

Prerequisites

  • Routing and REST basics (see Related Topics)

What you'll understand

  • What a controller is responsible for
  • Why controllers stay thin and delegate to services
  • How separating the HTTP layer from logic keeps code testable

Explanation

A controller is the entry point for a request in a layered application. Its job is narrow: read the incoming request, pull out what it needs (parameters, query, body), call the code that does the real work, and shape the response with the right status code. A controller is about HTTP, not about business rules.

The guiding principle is a thin controller. Business logic, the rules about what should actually happen, belongs in a service (see Related Topics), not in the controller. When a controller stays thin, it is easy to read and the logic underneath can be reused and tested without an HTTP request in the picture. A controller that grows fat with rules becomes hard to test and impossible to reuse.

This separation is a form of separation of concerns. The controller knows about the web, methods, status codes, request shapes, and the service knows about the domain. Frameworks make this explicit: Express uses plain handler functions as controllers, while NestJS provides a Controller class whose methods map to routes. The idea is identical in both.

Examples

This TypeScript example is a thin controller: it validates and reads input, delegates to a service, and returns a status code.

app.post('/users', async (req, res) => {
  const input = CreateUser.parse(req.body); // validation at the boundary
  const user = await userService.create(input); // business logic lives here
  res.status(201).json(user);
});

The same responsibility as a NestJS controller method:

@Controller('users')
class UsersController {
  constructor(private readonly users: UsersService) {}

  @Post()
  create(@Body() input: CreateUserDto) {
    return this.users.create(input); // delegate to the service
  }
}

Common mistakes

  • Putting business rules or database queries directly in the controller.
  • Duplicating the same logic across several controllers instead of sharing a service.
  • Returning inconsistent status codes for the same kind of outcome.
  • Letting the controller know too much about the database instead of the domain.

Best practices

  • Keep controllers thin: parse, delegate, respond.
  • Move business logic into services so it can be reused and tested directly.
  • Validate input at the controller boundary before delegating (see Related Topics).
  • Return accurate HTTP status codes for each outcome.

Further reading

Related topics