open atlas
↑ Back to track
NestJS, zero to senior NEST · 01 · 01

Modules, controllers, providers

NestJS is three building blocks: controllers route requests and stay thin, providers hold business logic and are injected, modules group them and gate visibility via imports/exports. Forget to export and DI cannot resolve the dependency.

NEST Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The app boots, then dies on the first request with Nest can't resolve dependencies of the UsersController (?). Please make sure that the argument UsersService at index [0] is available in the UsersModule context. The service exists. It’s @Injectable(). It’s even listed in providers — of the auth module, which never exported it. The dependency was right there, one module over, invisible. This error is the most common rite of passage in Nest, and it’s not a bug in your code. It’s the framework telling you that you broke a boundary you didn’t know you had.

Controllers: thin request handlers

A controller is the HTTP-facing edge of your app. The @Controller('users') decorator declares a route prefix; method decorators like @Get(), @Post(), @Patch(':id'), and @Delete(':id') map handlers to verbs and sub-paths. Parameter decorators pull input out of the request: @Param('id') for path segments, @Query() for the query string, @Body() for the parsed JSON payload. Whatever the handler returns becomes the response body, serialized for you.

The discipline that separates a junior controller from a senior one is thinness. A controller should translate HTTP into a method call and translate the result back into HTTP — nothing more. Validation, business rules, persistence, orchestration: all of that belongs in a provider. When a controller starts running database queries or branching on business state, it has stopped being an adapter and become a god object that you cannot reuse outside HTTP and cannot test without spinning up the whole request pipeline.

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

@Controller('users')
export class UsersController {
  // The service is injected, not constructed. The controller never news it up.
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id); // delegate, return, done
  }

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }
}

Providers: logic that gets injected

Why not put business logic directly in the controller? Because when you do, you lose the ability to test it without HTTP, reuse it from a queue consumer, and swap it in tests for a fake. Injectable providers solve all three.

A provider is any class Nest can instantiate and hand to something that asks for it. The @Injectable() decorator marks a class as a candidate for the dependency-injection (DI) container. The most common provider is a service holding business logic, but repositories, factories, HTTP clients, and config objects are all providers too. The defining trait is that you never call new UsersService() yourself — you declare the dependency in a constructor and Nest resolves and supplies it.

This is what makes the controller above testable: in a unit test you instantiate UsersController with a fake UsersService, no HTTP and no database required. The DI container reads the constructor’s parameter types, finds a matching provider in scope, and injects a single shared instance (providers are singletons within their scope by default).

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

@Injectable()
export class UsersService {
  private readonly users = new Map<string, { id: string; name: string }>();

  findOne(id: string) {
    const user = this.users.get(id);
    if (!user) throw new NotFoundException(`User ${id} not found`);
    return user;
  }

  create(dto: { name: string }) {
    const id = crypto.randomUUID();
    const user = { id, name: dto.name };
    this.users.set(id, user);
    return user;
  }
}

Modules: grouping and the encapsulation boundary

A module is the organizing unit. The @Module() decorator takes four arrays: controllers (declared here), providers (instantiated and made available inside this module), imports (other modules whose exports this module wants to use), and exports (the subset of this module’s providers that other modules are allowed to consume). Every Nest app has a root AppModule; everything else is a feature module that you wire into it.

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

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // <-- without this, no other module can inject UsersService
})
export class UsersModule {}

Here is the rule the hook violated, stated plainly: a provider is private to its module unless it is listed in exports. Declaring UsersService in providers makes it injectable into UsersController within UsersModule — and nowhere else. If OrdersModule wants UsersService, two things must both be true: UsersModule must export it, and OrdersModule must import UsersModule. Miss either half and you get the Nest can't resolve dependencies error. The fix is never to re-list the provider in the consuming module (that creates a second, separate instance and silently breaks shared state) — it’s to export from the owner and import the owner.

Why this works

Why does Nest enforce privacy by default instead of making every provider global? Because encapsulation is the whole point of modules. If every provider were visible everywhere, the dependency graph would be a flat mud-ball and any module could reach into any other’s internals. Explicit exports turns the module’s public surface into a deliberate, reviewable contract: you can see at a glance exactly what a feature offers the rest of the app, and refactoring a private provider can never break a distant consumer because there are none.

This gives you the two module flavors you’ll build constantly. A feature module owns one slice of the domain (users, orders, billing) and exports only what others legitimately need. A shared module packages cross-cutting providers — a database connection, a logger, a config service — and exports them so many feature modules can import the same wiring. When a shared provider is needed almost everywhere, you can mark its module @Global(), but that’s a deliberate escape hatch, not a default: globalizing everything throws away the encapsulation you came for.

BlockResponsibilityDecoratorContains / declares
ControllerMap routes, extract input, return responses — stay thin, delegate logic@Controller(‘users’)@Get/@Post handlers; @Param/@Query/@Body params
ProviderHold business logic; injected via DI, singleton by default@Injectable()Services, repositories, factories — methods other classes call
ModuleGroup related blocks; gate visibility across module boundaries@Module({…})controllers, providers, imports, exports
Quiz

OrdersService needs UsersService, which lives in UsersModule. UsersService is in UsersModule's providers. What makes the injection resolve?

Pick the best fit

A new endpoint must validate a payload, run a business rule, and persist a record. Where does that logic belong?

Recall before you leave
  1. 01
    Walk a request through the three blocks and say what each is responsible for.
  2. 02
    You hit 'Nest can't resolve dependencies of UsersController — UsersService at index [0]'. UsersService exists and is @Injectable(). What's wrong and how do you fix it correctly?
Recap

NestJS structures a backend out of three building blocks. Controllers are the HTTP edge: @Controller('users') sets a route prefix, method decorators like @Get(':id') and @Post() map handlers, and @Param/@Query/@Body extract input — and they must stay thin, translating HTTP into a method call and back while delegating real work elsewhere. Providers are where that work lives: any @Injectable() class — service, repository, factory — that the DI container instantiates and supplies through constructor parameters, never constructed by hand, which is what makes the system testable and keeps providers as scoped singletons. Modules are the organizing unit: @Module() groups controllers and providers, pulls in other modules via imports, and exposes a chosen subset via exports. The encapsulation rule is the one that bites: a provider is private to its module unless exported, so a cross-module dependency resolves only when the owner exports it and the consumer imports the owner. Re-listing it in the consumer’s providers silences the error but spawns a second instance and breaks shared state — always export from the owner and import the owner. Feature modules own a domain slice and export sparingly; shared modules package cross-cutting providers; @Global() is a deliberate exception, not a default. Now when you see Nest can't resolve dependencies, check exports first — the fix is one line in the owning module, not a duplicate provider declaration.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.