NestJS Fundamentals: Controllers, Services, Modules & the Request Lifecycle
How NestJS dependency injection really works: providers, custom providers, scopes, modules, lifecycle hooks, and the order a request runs in.
NestJS dependency injection means your classes never build their own dependencies. A class lists what it needs in its constructor, and Nest's container creates each dependency once and passes it in. Controllers handle routes, services (providers) do the work, and modules decide which providers each part of the app can see.
The decorators make NestJS look noisy at first: @Controller, @Injectable, @Module, @Get on everything. They're just labels on those three building blocks, plus a few hooks that run as a request passes through. This post covers how the wiring works, the provider types and scopes you'll actually use, lifecycle hooks, and the fixed order a request moves through middleware, guards, interceptors, pipes and filters. Once you see that order, you'll know where any new line of code belongs.
In short: what goes where
- –Handle an HTTP route → Controller
- –Write business logic or DB queries → Service
- –Validate the shape of a request body → DTO +
ValidationPipe - –Convert a URL param string into a number →
ParseIntPipe - –Block unauthenticated requests → Guard
- –Log every request's duration → Interceptor
- –Run something for every request, before anything else → Middleware
- –Return a consistent error JSON shape → Exception Filter
The three core building blocks
Think of a NestJS feature as a small company department. The Module is the department itself: HR, Finance, whatever. The Controller is the receptionist. It takes incoming requests and routes them, but never does the work itself. The Service is the team that does the real work: business logic, database queries, calculations. The controller never talks to the database directly; it delegates to the service. The service, in turn, has no idea what HTTP is. It does its job and returns a result. That separation is what keeps a growing NestJS app testable instead of tangled.
Controllers — handling the request
A controller maps HTTP methods and URL paths to class methods using decorators. @Controller('users') prefixes every route inside with /users. From there:
- –
@Get(),@Post(),@Patch(':id')and@Delete(':id')map onto GET, POST, PATCH and DELETE requests. - –
@Param('id')reads a URL segment like/users/123. - –
@Query('role')reads a query string like?role=admin. - –
@Body()grabs the full JSON body.
The controller's job ends the moment it calls the matching service method and returns whatever comes back.
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}Services and providers — doing the actual work
A Provider is any class marked with @Injectable(). A Service is the most common kind of provider: one that holds business logic. @Injectable() tells NestJS's IoC (Inversion of Control) container, the part of NestJS that creates your classes and passes them where they're needed, that it may hand this class to whoever asks for it. That's the core of dependency injection.
@Injectable()
export class UsersService {
private users: User[] = [];
findOne(id: string) {
return this.users.find((user) => user.id === id);
}
create(dto: CreateUserDto) {
// Demo only: this keeps the password in plain text and returns it.
// A real app hashes it first and never sends it back.
const user = { id: crypto.randomUUID(), ...dto };
this.users.push(user);
return user;
}
}Dependency injection, explained without the jargon
Without DI, a controller would create its own service by hand: this.usersService = new UsersService(). That's tightly coupled. It's hard to swap out in tests, and every class ends up building its own dependencies. With DI, you declare what you need in the constructor, constructor(private usersService: UsersService), and NestJS looks at the type, finds (or creates) a matching instance, and hands it to you. By default it creates one instance and reuses it everywhere, which also saves memory. You never write "new" yourself; NestJS's container does the wiring.
What Nest does when it reads a constructor
Every provider is registered under a token. For a normal class the token is the class itself, so providers: [UsersService] is shorthand for { provide: UsersService, useClass: UsersService }. When Nest builds UsersController, it reads the constructor's parameter types (TypeScript records them because the project has emitDecoratorMetadata switched on), looks each token up in the module, creates anything that doesn't exist yet, and passes it in. If a token can't be found in the module or in anything it imports, the app refuses to start. Here's the error you get when PostsModule imports UsersModule but UsersModule forgot to export UsersService:
Nest can't resolve dependencies of the PostsService (?). Please make sure that the argument UsersService at index [0] is available in the PostsModule module.
Potential solutions:
- Is PostsModule a valid NestJS module?
- If UsersService is a provider, is it part of the current PostsModule?
- If UsersService is exported from a separate @Module, is that module imported within PostsModule?Read it as: argument 0 of PostsService is UsersService, and PostsModule can't see it. The fix is nearly always one of two lines: add UsersService to exports in UsersModule, or add UsersModule to imports in PostsModule. Another classic is putting a service class in imports instead of providers. The NestJS common errors page covers the rest.
Modules — grouping things together
A Module groups related controllers and providers. Every app has at least one, the root AppModule. Inside @Module() there are four lists:
- –
controllers: the routes this module owns. - –
providers: the services and other injectable classes this module creates and can inject. Guards, pipes and interceptors you attach with @UseGuards() and similar decorators don't need to be listed here. - –
imports: other modules whose exported providers you need. - –
exports: your own providers that importing modules can use.
If PostsModule needs UsersService, UsersModule has to export it and PostsModule has to import UsersModule. Nothing is available globally by accident, which keeps large codebases predictable.
@Module({
imports: [UsersModule],
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService],
})
export class PostsModule {}AppModule contains UsersModule and PostsModule. UsersModule has UsersController and UsersService and exports UsersService. PostsModule imports UsersModule, so PostsService can use UsersService.
Global and dynamic modules
Mark a module with @Global() and its exported providers are available everywhere without an import. The NestJS docs say to register a global module only once, usually from the root module, and that making everything global isn't a recommended design. Save it for things nearly every module needs, like config or a database connection. You'll also meet dynamic modules: the ones you import with a method call, like ConfigModule.forRoot() or BullModule.forRoot(). The method returns a module whose providers are configured from the options you pass, which is how a library can ship one module that works with your connection details.
Custom providers: useValue, useClass, useFactory and useExisting
The class shorthand covers most services. For everything else, a provider is an object with a provide token and one of four recipes:
- –
useValue: a ready-made value, such as a config object, or a mock in tests. - –
useClass: the class Nest should create for this token, which lets you pick an implementation at startup. - –
useFactory: a function whose return value becomes the provider. Its own dependencies go in an inject array, and it can be async. This is how database clients usually get created. - –
useExisting: an alias, a second token that points at the same instance.
Tokens don't have to be classes. A string or a Symbol works too, but Nest can't infer it from a type, so you inject it with @Inject(TOKEN). TypeScript interfaces disappear when the code compiles, so they can't be tokens. Use a Symbol or an abstract class instead. Here are three of them in one module; useFactory gets its own example further down (details in the custom providers docs). The imports are written for a CommonJS project, so pick CJS when nest new asks (its default is ESM). In an ESM project, add .js to relative imports.
// clock.ts
export abstract class Clock {
abstract now(): Date;
}
export class SystemClock extends Clock {
now() {
return new Date();
}
}
export class FakeClock extends Clock {
now() {
return new Date('2026-01-01T00:00:00Z');
}
}
// config.ts
export const APP_CONFIG = Symbol('APP_CONFIG');
export interface AppConfig {
pageSize: number;
databaseUrl: string;
}
// users.module.ts
import { Module } from '@nestjs/common';
import { Clock, FakeClock, SystemClock } from './clock';
import { APP_CONFIG } from './config';
import { UsersService } from './users.service';
@Module({
providers: [
UsersService,
// useValue: a ready-made value (config, a constant, a test mock)
{ provide: APP_CONFIG, useValue: { pageSize: 20, databaseUrl: process.env.DATABASE_URL ?? '' } },
// useClass: pick which class backs a token
{ provide: Clock, useClass: process.env.NODE_ENV === 'test' ? FakeClock : SystemClock },
// useExisting: a second token that points at the same instance
{ provide: 'TIME_SOURCE', useExisting: Clock },
],
exports: [UsersService],
})
export class UsersModule {}import { Inject, Injectable } from '@nestjs/common';
import { Clock } from './clock';
import { APP_CONFIG, type AppConfig } from './config';
@Injectable()
export class UsersService {
constructor(
private readonly clock: Clock, // class token: no @Inject() needed
@Inject(APP_CONFIG) private readonly config: AppConfig, // symbol token: @Inject() required
) {}
listMeta() {
return { pageSize: this.config.pageSize, generatedAt: this.clock.now().toISOString() };
}
}Two details in that service are easy to miss. UsersService depends on the abstract Clock, not on SystemClock, so tests and other environments can swap the implementation without touching the service. And AppConfig is imported with type on purpose: the nest new tsconfig turns on isolatedModules, and TypeScript then refuses to compile an interface used in a decorated constructor unless it's a type-only import (error TS1272). That's safe here because the token is APP_CONFIG, not the type. Never do it for a class you inject by type, like Clock, because a type-only import leaves nothing for Nest to read at runtime.
Injection scopes: singleton, request and transient
By default every provider is a singleton: one instance, created at startup and shared by the whole app. You can change that per provider with the scope option on @Injectable():
- –
Scope.DEFAULT: one shared instance. What you want almost every time. - –
Scope.REQUEST: a new instance for each incoming request, thrown away once the request is handled. Useful when a provider needs the request itself, like a per-request context or a tenant id. - –
Scope.TRANSIENT: every class that injects it gets its own private instance.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
constructor(@Inject(REQUEST) private readonly request: Request) {}
get requestId() {
return this.request.headers['x-request-id'] ?? 'none';
}
}Request scope has a cost people miss: it bubbles up the injection chain. If CatsController depends on CatsService, and CatsService depends on a request-scoped provider, then CatsService and CatsController become request-scoped too. One scoped provider deep in the tree can mean Nest builds a chain of objects on every request. Transient doesn't spread that way: a singleton that injects a transient provider stays a singleton. Lifecycle hooks like onModuleInit() also don't run for request-scoped classes. The injection scopes docs strongly recommend the default singleton scope unless a provider really must be request-scoped. In tests, fetch scoped providers with await moduleRef.resolve(), because get() only returns singletons.
Lifecycle hooks: code that runs at startup and shutdown
Providers and modules can implement hook interfaces, and Nest calls them in this order:
- –
onModuleInit(): once the host module's dependencies are resolved. - –
onApplicationBootstrap(): once every module is initialized, before the app starts listening. - –
onModuleDestroy(): after a termination signal such as SIGTERM, or when app.close() is called. - –
beforeApplicationShutdown(): after every onModuleDestroy() has finished. - –
onApplicationShutdown(): the last step, after connections close.
Hooks can be async and Nest waits for them, so onModuleInit() is a good place to check the database is reachable before the app takes traffic. The three shutdown hooks only fire on signals like SIGTERM if you call app.enableShutdownHooks() in main.ts. It's off by default because the listeners use resources (see the lifecycle events docs). Here's useFactory and the hooks together, wrapping a pg connection pool:
// npm i pg && npm i -D @types/pg
import { Inject, Injectable, Module, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { Pool } from 'pg';
export const PG_POOL = 'PG_POOL';
@Injectable()
export class DatabaseService implements OnModuleInit, OnApplicationShutdown {
constructor(@Inject(PG_POOL) private readonly pool: Pool) {}
async onModuleInit() {
// Fail at startup, not on the first request, if the database is unreachable
await this.pool.query('SELECT 1');
}
async onApplicationShutdown() {
await this.pool.end();
}
query(text: string, params?: unknown[]) {
return this.pool.query(text, params);
}
}
@Module({
providers: [
{
provide: PG_POOL,
useFactory: () => new Pool({ connectionString: process.env.DATABASE_URL }),
},
DatabaseService,
],
exports: [DatabaseService],
})
export class DatabaseModule {}import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // run the shutdown hooks on SIGTERM / SIGINT
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();Circular dependencies and forwardRef()
If AuthorsService injects PostsService and PostsService injects AuthorsService, Nest can't build either one first. forwardRef() tells Nest to resolve the reference later, and you need it on both sides. Modules that import each other use the same wrapper: imports: [forwardRef(() => PostsModule)].
// posts.service.ts
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { AuthorsService } from './authors.service';
@Injectable()
export class PostsService {
constructor(
@Inject(forwardRef(() => AuthorsService))
private readonly authorsService: AuthorsService,
) {}
}
// authors.service.ts
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { PostsService } from './posts.service';
@Injectable()
export class AuthorsService {
constructor(
@Inject(forwardRef(() => PostsService))
private readonly postsService: PostsService,
) {}
}Treat forwardRef() as a warning sign more than a fix. Two services that need each other usually share a piece of logic that belongs in a third service both can depend on, and moving it there removes the cycle. Also keep the two classes in separate files. Put both in one file and the app crashes at startup with "ReferenceError: Cannot access 'AuthorsService' before initialization", even with forwardRef(), because the decorator metadata refers to a class that isn't defined yet.
Testing: swapping a provider for a mock
This is where DI pays for itself. @nestjs/testing (already a dev dependency in a nest new project) builds a real module inside your test, and overrideProvider() replaces one dependency without changing the code under test. The CommonJS scaffold runs tests with Jest:
import { Test } from '@nestjs/testing';
import { Clock } from './clock';
import { UsersModule } from './users.module';
import { UsersService } from './users.service';
describe('UsersService', () => {
it('stamps results with the clock time', async () => {
const moduleRef = await Test.createTestingModule({ imports: [UsersModule] })
.overrideProvider(Clock)
.useValue({ now: () => new Date('2030-01-01T00:00:00Z') })
.compile();
const usersService = moduleRef.get(UsersService);
expect(usersService.listMeta().generatedAt).toBe('2030-01-01T00:00:00.000Z');
});
});DTOs and validation — trusting nothing from the client
A DTO (Data Transfer Object) is a class describing exactly what shape of data is allowed into (or out of) an endpoint. You describe it with decorators from class-validator, like @IsEmail(), @MinLength(8), @IsOptional() and @IsEnum(). Once you enable ValidationPipe globally in main.ts, every request body is checked against its DTO before it reaches your controller. Three options are worth knowing:
- –
whitelist: truesilently strips unknown fields. - –
forbidNonWhitelisted: truethrows an error for unknown fields instead. - –
transform: truegives your controller an instance of the DTO class. It also converts route and query params, which always arrive as strings, to the type you declared, so "1" becomes the number 1 for a param typed as number. Inside a DTO class you need @Type(() => Number) for that.
If validation fails, NestJS returns a 400 Bad Request with every failing rule listed at once, not just the first one. So the client (or you, testing in Postman) sees everything that needs fixing in one response. For update routes where every field should be optional, PartialType(CreateUserDto) generates an "everything optional" version of your create DTO without repeating every decorator by hand.
// npm i @nestjs/mapped-types
import { PartialType } from '@nestjs/mapped-types';
import { IsEmail, IsEnum, IsOptional, MinLength } from 'class-validator';
export enum Role {
User = 'user',
Admin = 'admin',
}
export class CreateUserDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
@IsOptional()
@IsEnum(Role)
role?: Role;
}
// PATCH /users/:id — every field becomes optional automatically
export class UpdateUserDto extends PartialType(CreateUserDto) {}This post only skims validation. For whitelist and forbidNonWhitelisted in practice, nested DTOs, custom validators and custom error formats, see the hands-on NestJS validation walkthrough.
Pipes — transform or reject, before the controller sees it
A Pipe does one of two things to incoming data: transforms it (turn the string "123" from a URL into the number 123) or validates it (reject the request outright if the data is wrong). ParseIntPipe, ParseUUIDPipe, and ParseBoolPipe are common built-ins — attach one to @Param('id', ParseIntPipe) and NestJS throws a 400 automatically if someone passes a non-numeric id, so your controller method can safely assume id is already a real number. DefaultValuePipe is handy for pagination: @Query('page', new DefaultValuePipe(1), ParseIntPipe) means "default to page 1 if it's missing, and make sure whatever's there is a number."
Guards — deciding who's allowed through
A Guard answers one question: is this request allowed to proceed? It implements a canActivate() method that returns true or false (or throws an exception). The classic example is an AuthGuard that checks for a valid token in the Authorization header and rejects the request with a 401 if it's missing or invalid. Guards can be applied per route (@UseGuards(AuthGuard)), per controller (protecting every route inside), or for the whole app. For a global guard, add it to a module's providers as APP_GUARD so it can still inject dependencies. app.useGlobalGuards() in main.ts also works, but a guard registered there can't inject anything.
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// The header looks like "Bearer <token>", so take the part after the space
const [type, token] = request.headers.authorization?.split(' ') ?? [];
// isValidToken: your own check, e.g. verifying a JWT
if (type !== 'Bearer' || !token || !isValidToken(token)) {
// Returning false would send a 403 Forbidden. Throw to send a 401.
throw new UnauthorizedException();
}
return true;
}
}Interceptors — wrapping around the handler
An Interceptor wraps the route handler, running code both before and after it executes — useful for logging how long a request took, or reshaping the response before it goes out. A common pattern is a response-transform interceptor that wraps every successful response in a standard envelope, like { statusCode, timestamp, data }, so every endpoint in the API returns a consistent shape without each controller having to build that structure by hand.
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const res = context.switchToHttp().getResponse();
return next.handle().pipe(
map((data) => ({
statusCode: res.statusCode, // 201 for a POST, 200 for a GET, etc.
timestamp: new Date().toISOString(),
data,
})),
);
}
}Middleware — the very first thing that runs
Middleware runs before guards, interceptors, or pipes, and has access to the raw request and response objects. By default NestJS runs on Express, so this is Express-style middleware. It's the right place for things like request logging, setting headers, or rate limiting — anything that should happen for every request regardless of which route it hits. A middleware function must call next() to pass control forward; forget that, and the request just hangs.
Exception filters — turning errors into clean responses
NestJS ships with built-in HTTP exceptions (NotFoundException, BadRequestException, UnauthorizedException, ConflictException and more) that you can throw straight from a service. NestJS catches them and formats a proper JSON error response with the right status code. If you want a custom error shape across the whole app (say, adding a timestamp and the request path to every error), write a custom exception filter and register it globally in main.ts. Either way, error handling lives in one place instead of every controller method wrapping itself in try/catch.
Custom decorators — cleaning up repeated patterns
Once a guard attaches something to the request (like the authenticated user, after verifying a JWT), you'll want to pull it out cleanly instead of writing @Req() req and reaching into req.user everywhere. A custom parameter decorator like @CurrentUser() does exactly that — it reads the request internally and hands your controller method just the piece of data it needs, which keeps controller signatures readable.
The request lifecycle — how it all connects
Every request that hits your NestJS server travels through these layers in a fixed order. If anything in the early layers throws, the request never reaches your controller at all — it gets caught by the exception filter and turned into a clean error response instead.
A request passes through middleware, then guards that decide if it may proceed, interceptors (before), pipes such as ValidationPipe, the controller, and the service with business logic, then interceptors (after) shape the response. An error thrown by a guard, pipe or service goes to an exception filter, which sends the response.
The order inside each layer
Each layer can be bound globally, to a controller, or to a single route. The request lifecycle FAQ spells out how those combine:
- –Middleware runs first: globally bound middleware, then module-bound middleware.
- –Guards, interceptors (on the way in) and pipes each run global first, then controller, then route. Pipes on a single parameter, like ParseIntPipe on @Param('id'), run last, just before your handler.
- –On the way out, interceptors unwind in reverse: route, then controller, then global.
- –Exception filters go the other way round: Nest checks route filters first, then controller, then global. Only one filter handles a given exception, and it can't pass the error on to the next.
Guards vs interceptors vs middleware: which one?
They overlap enough to be confusing. A rule of thumb that holds up:
- –Middleware for work that doesn't care which route matched: adding a request id, setting headers, raw request logging. It runs before Nest knows which handler will run.
- –Guard when the answer is yes or no: may this request reach this handler? Guards get an ExecutionContext, so they can read metadata on the handler, like a roles decorator. Middleware can't.
- –Interceptor when you need to wrap the handler: timing it, caching or reshaping the result, or mapping one error to another. Interceptors run after guards, so they only see requests that were let through.
- –Pipe when the work is about one argument: converting or validating a param, query or body.
- –Exception filter when the work is about what the error response looks like.
Common questions
What is dependency injection in NestJS?
It's the pattern where a class asks for its dependencies in its constructor and Nest's IoC container creates them and passes them in. You register classes as providers in a module, and Nest wires everything up when the app starts.
Are NestJS services singletons?
Yes, by default. One catch: if two modules each list the same class in their providers, each module gets its own instance. Export the service from one module and import that module everywhere else instead.
What's the difference between a guard and an interceptor?
A guard decides whether the request reaches the handler at all. An interceptor wraps the handler, so it can run code before and after it and change the result or the error. Guards always run first.
How do I fix "Nest can't resolve dependencies"?
Use the index in the message to find the missing constructor argument. Then make sure that provider is in the same module's providers, or exported by a module listed in imports. Also check you didn't import a class with import type, because then there's nothing left at runtime for Nest to read.
When should I use request-scoped providers?
Only when a provider truly needs per-request state, like the current request or tenant. The scope spreads to everything that depends on it, so start with singletons. Pass request data in as method arguments where you can.
What's next
This post is one step in my backend developer roadmap. It comes right after building your first NestJS API.
Start with controllers, services and modules. Guards, pipes, interceptors and filters are each a place for code that applies to many routes at once, like auth or logging, and you'll reach for each one the first time you need it. Next up is PostgreSQL essentials, the database most of these services end up talking to. I'm a frontend engineer who builds the UI on top of APIs like this. If you need that, see my work or get in touch.