Controllers, Modules, Providers: The NestJS Building Blocks Explained

Posted November 17, 2025 by Karol Polakowski

NestJS is built around a few small, composable concepts that together form a powerful framework for building scalable server-side applications. Understanding Controllers, Providers, and Modules — and how dependency injection connects them — is essential to architect maintainable NestJS apps.


Why these building blocks matter

Controllers handle incoming requests and return responses. Providers (commonly services) contain business logic and are injected where needed. Modules organize related controllers and providers into cohesive units. This separation enforces single responsibility, improves testability, and makes dependency management predictable.

Controllers

Controllers are classes responsible for handling incoming HTTP requests, mapping routes to handler methods, and returning responses. They should be thin — delegating business logic to providers.

Key points:

  • Use controllers for routing and request/response concerns (validation, parsing, status codes).
  • Keep them thin: let providers do heavy lifting.
  • Decorators like @Controller, @Get, @Post, @Param, and @Body are used to declare routes and extract request data.

Example controller

import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';

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

  @Get()
  async findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }

  @Post()
  async create(@Body() payload: any) {
    return this.usersService.create(payload);
  }
}

Note: Controller methods can return values directly or leverage Nest’s built-in response handling (exceptions, interceptors, pipes).

Providers (Services and other injectables)

Providers are the classes that provide functionality and are instantiated by Nest’s IoC (Inversion of Control) container. Services are the most common provider type.

Key points:

  • Annotate providers with @Injectable() so Nest can manage them.
  • Providers are singletons by default within the module scope.
  • Rely on dependency injection rather than manual new-keyword instantiation.

Example provider (service)

import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  private users = [];

  async findAll() {
    return this.users;
  }

  async findOne(id: string) {
    return this.users.find(u => u.id === id);
  }

  async create(payload: any) {
    const user = { id: String(Date.now()), ...payload };
    this.users.push(user);
    return user;
  }
}

This service contains the business logic and is then injected into the controller via constructor injection.

Modules

Modules are the organizational unit of NestJS. Each module is a class annotated with @Module that groups related controllers and providers, and optionally exports providers to be reused by other modules.

Key points:

  • Every application has at least one root module (AppModule).
  • Modules encapsulate providers and controllers and configure the DI boundary.
  • Use exports to share providers with other modules.

Example module

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  imports: [],
  controllers: \[UsersController\],
  providers: \[UsersService\],
  exports: \[UsersService\],
})
export class UsersModule {}

By exporting UsersService, other modules that import UsersModule can inject UsersService as well.

How they interact (DI, scope, and lifecycle)

  • Modules register providers and controllers. The DI container instantiates providers and resolves dependencies declared in constructors.
  • By default, providers are singletons within the module’s scope. If you need a provider with a narrower lifetime (per-request), you can configure the scope.
  • Import a module when you need to consume exported providers from that module.

Diagram (conceptual):

  • AppModule imports UsersModule
  • UsersModule defines UsersController and UsersService
  • UsersController constructor injects UsersService

Practical example: small app wiring

Below is a minimal wiring example including AppModule and main bootstrap. Note that arrays in the module metadata are escaped for embedding contexts where square brackets need escaping.

// app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';

@Module({
  imports: \[UsersModule\],
})
export class AppModule {}

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

This sets up a root module that imports UsersModule, which provides controllers and services to handle user-related routes.


Common patterns & best practices

  • Keep controllers lean: validation, request parsing, and response shaping; delegate business logic to providers.
  • Favor constructor injection over property injection — it makes dependencies explicit and easier to test.
  • Organize code by feature (feature modules) rather than by technical layer when applications grow.
  • Use module exports intentionally: export only what other modules need to keep boundaries clear.
  • For cross-cutting concerns (logging, metrics, database connections), create shared modules and export configured providers.

Testing controllers and providers

  • Providers are easy to unit test: instantiate them in isolation and mock dependencies.
  • Controllers can be unit tested by injecting mock providers into the controller constructor.
  • For integration tests, use Nest’s TestingModule to build a module graph, then call initialize() to bootstrap a test app.

Example: creating a testing module for a controller

import { Test } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let usersController: UsersController;
  let usersService: UsersService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: \[UsersController\],
      providers: \[UsersService\],
    }).compile();

    usersService = moduleRef.get(UsersService);
    usersController = moduleRef.get(UsersController);
  });

  it('should call service.findAll', async () => {
    jest.spyOn(usersService, 'findAll').mockResolvedValue([]);
    expect(await usersController.findAll()).toEqual([]);
  });
});

When to use custom providers, factories, or dynamic modules

  • Use factory providers when provider construction requires async initialization or configuration values.
  • Use dynamic modules to allow modules to be configured at import time (for example, configuring an SDK with API keys).
  • Use custom providers with ‘useClass’, ‘useFactory’, or ‘useValue’ to control how the DI container resolves a token.

Conclusion

Controllers, Providers, and Modules form a simple but powerful mental model in NestJS. Controllers handle routing and HTTP concerns, providers encapsulate business logic and are managed by the DI container, and modules organize and encapsulate functionality. Mastering how these pieces fit together — along with DI scopes, exports, and module imports — lets you build maintainable, testable, and scalable server-side TypeScript applications.