When I first started learning NestJS, I kept seeing decorators like @Module(), @Controller(), and @Injectable() everywhere.
Then there were modules, providers, services, dependency injection, and the request lifecycle.
At first, they looked like lots of separate concepts to memorize. But they make much more sense when I think of NestJS as a well-organized team: each part has a responsibility, and the framework connects those parts together.
In this blog, I'll walk through the main concepts using a small example: a simple API that manages users.
1. What Is NestJS?
NestJS is a framework for building server-side applications with Node.js. It uses TypeScript by default and builds on HTTP platforms such as Express or Fastify.
You can build APIs using plain Node.js, but as an application grows, you need a consistent way to organize code.
NestJS gives us a structure for that:
Application
|
├── Modules → organize related features
├── Controllers → handle incoming requests
├── Providers → contain reusable logic and dependencies
└── DI Container → connects classes and their dependencies
Instead of putting every feature into one giant file, we split the application into clear pieces.
2. Imagine a Restaurant
A restaurant is a useful analogy for understanding NestJS.
Imagine a customer orders a meal.
- The waiter receives the customer's request.
- The kitchen team prepares the meal.
- The restaurant manager organizes the staff and their responsibilities.
- The restaurant's staffing system makes sure the right people are available to do their jobs.
In a NestJS application:
| Restaurant | NestJS |
|---|---|
| Waiter receiving an order | Controller |
| Kitchen preparing the meal | Service/provider |
| Department organizing related work | Module |
| System connecting staff and dependencies | Dependency Injection (DI) container |
| Rules for how a request is handled | Request lifecycle |
This analogy isn't exact, but it helps explain why these parts exist.
3. Controllers: Receiving Requests
A controller handles incoming requests and sends responses.
For example, a user visits:
GET /users
A controller can handle that route.
Create users.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}
Let's understand what's happening.
@Controller('users')
This tells NestJS that the class is a controller and its routes start with /users.
@Get()
This maps a GET request to the controller's base route.
Together, these decorators map to:
GET /users
findAll()
This method handles the request. It delegates the actual user-related work to UsersService.
Why not put all the logic in the controller?
Controllers should generally focus on HTTP-related work: receiving input, calling the appropriate logic, and returning a response.
Keeping business logic in a service makes the code easier to test and maintain.
4. Providers and Services: Doing the Work
A provider is a class or value that NestJS can manage and inject into other parts of the application.
A service is a common kind of provider.
For example, UsersService can contain the logic for retrieving users.
Create users.service.ts:
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private readonly users = [
{ id: 1, name: 'Koushik' },
{ id: 2, name: 'Alex' },
];
findAll() {
return this.users;
}
findOne(id: number) {
return this.users.find((user) => user.id === id);
}
}
This service stores some sample users in memory. In a real application, the service might query PostgreSQL, MongoDB, or another data source.
What does @Injectable() mean?
It marks the class as available for NestJS's dependency injection system.
It does not automatically register the service in every module. The provider generally still needs to be registered in a module.
5. Modules: Organizing the Application
A module groups related parts of an application.
For example, all user-related code can belong to a UsersModule.
Create users.module.ts:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Let's break it down.
@Module()
This decorator gives NestJS metadata describing how the module is organized.
controllers
controllers: [UsersController]
These are the controllers that belong to this module.
providers
providers: [UsersService]
These are the providers NestJS should manage in this module's dependency injection context.
The module connects the controller and service so NestJS can instantiate them and provide the service to the controller.
6. How the Pieces Connect
Our feature has three files:
src/
└── users/
├── users.controller.ts
├── users.service.ts
└── users.module.ts
Their responsibilities are different:
UsersModule
|
├── registers UsersController
|
└── registers UsersService
↑
|
UsersController ┘
The controller depends on the service. The module registers both with NestJS.
But who creates the service instance and supplies it to the controller?
That's where dependency injection comes in.
7. Dependency Injection (DI)
Dependency Injection sounds complicated, but the basic idea is simple:
A class declares what it needs, and another system supplies that dependency.
Consider the controller:
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
}
The controller needs a UsersService.
Instead of creating the service manually like this:
const usersService = new UsersService();
NestJS creates and manages the provider, then supplies it to the controller.
The controller declares its dependency through its constructor. NestJS's DI system resolves that dependency using the provider registrations in the module.
8. What Is the DI Container?
The dependency injection container is the part of NestJS that manages registered providers and their dependencies.
A simplified view:
UsersModule
|
┌───────┴────────┐
| |
UsersController UsersService
|
| needs
v
DI Container
|
└── supplies UsersService
The container helps NestJS:
- create class instances
- resolve constructor dependencies
- manage provider lifetimes
- reuse providers according to their scope
- make code easier to test by replacing dependencies
By default, many NestJS providers are singleton-scoped within their module/application context. The same instance is generally reused rather than creating a new instance for every request. Request-scoped and transient providers are also available when needed.
9. Why Is DI Better Than Creating Classes Manually?
Imagine a controller creates its service directly:
export class UsersController {
private readonly usersService = new UsersService();
}
This may work for a tiny example, but it tightly couples the controller to that concrete class.
Later, you might want a mock service for tests or a different implementation.
With dependency injection:
constructor(private readonly usersService: UsersService) {}
NestJS manages the dependency. In tests, you can provide a mock instead of the real service.
The key benefit: DI reduces manual object creation and makes dependencies easier to replace and test.
10. How Does NestJS Know What to Inject?
In the usual NestJS TypeScript pattern, the constructor declares the dependency:
constructor(private readonly usersService: UsersService) {}
NestJS uses metadata and its module provider registrations to resolve UsersService.
For this to work, the provider must be registered in the appropriate module or made available through an imported module.
If the provider isn't registered or accessible, NestJS generally reports a dependency resolution error during startup.
11. The Main Application Module
NestJS applications usually have a root module, often called AppModule. It brings the application's feature modules together.
For example, app.module.ts:
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}
The imports array means that AppModule imports UsersModule.
It does not mean that every provider inside UsersModule automatically becomes available everywhere. Modules control provider visibility, and providers that need to be shared are commonly exported by one module and imported by another.
The root module is loaded when the application starts.
12. Understanding the Three Main Decorators
@Module()
Used on a module class.
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
It describes which controllers and providers belong to the module, and which other modules it imports or exports.
@Controller()
Used on a controller class.
@Controller('users')
export class UsersController {}
It identifies the class as a controller and can define a route prefix.
@Injectable()
Used on a class that participates in dependency injection.
@Injectable()
export class UsersService {}
It marks the class as injectable so NestJS can manage and inject it when registered appropriately.
Remember:
Decorators attach metadata. NestJS uses that metadata to organize and run the application.
13. The Request Lifecycle
Now let's connect everything.
Imagine the client sends:
GET /users
A simplified request flow looks like this:
Client Request
|
v
Middleware
|
v
Guards
|
v
Interceptors (before)
|
v
Pipes
|
v
Controller
|
v
Service / Provider
|
v
Database or other work
|
v
Controller result
|
v
Interceptors (after)
|
v
HTTP Response
Exception filters handle errors along the way. The exact flow depends on where an error occurs and which features are configured.
Let's understand the main pieces.
14. Middleware
Middleware runs early in the request pipeline, before the route handler.
It can inspect or modify the request and response objects, end the request, or pass control onward.
Common uses include:
- request logging
- adding a request ID
- basic request processing
For example, middleware might log:
GET /users
Request ID: abc-123
If middleware ends the response, the request may not proceed to the controller.
15. Guards
Guards decide whether a request is allowed to continue to a route handler.
For example:
Request
|
v
Is the user authenticated?
|
├── Yes → Continue
|
└── No → Reject request
Guards are commonly used for authentication and authorization decisions.
A guard is not the same as a service: a guard decides whether the request may proceed, while a service typically performs application work.
16. Interceptors
Interceptors can run logic before and after the route handler's execution.
They are useful for things like:
- logging execution time
- transforming response data
- working with RxJS observables
- implementing caching patterns
Conceptually:
Request
|
v
Interceptor: before
|
v
Controller + Service
|
v
Interceptor: after
|
v
Response
The "after" part usually wraps the result returned by the handler.
17. Pipes
Pipes are commonly used to transform or validate incoming values.
For example, a route parameter arrives as text:
/users/123
But the service may need the ID as a number.
A pipe such as ParseIntPipe can convert and validate it.
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return { id };
}
}
Now NestJS parses the id route parameter as an integer. If the value isn't a valid integer, the pipe throws an HTTP exception instead of allowing the invalid value through.
Pipes are also frequently used with DTO validation to validate request bodies.
18. Exception Filters
Something can go wrong at many points in a request.
For example:
- the requested user doesn't exist
- the user is not allowed to access a resource
- a database operation fails
- input validation fails
Exception filters control how exceptions are translated into HTTP responses.
NestJS includes built-in exception handling, and you can also create custom exception filters.
For example, a not-found exception can produce a response such as:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}
The exact response shape depends on the exception and any custom filters.
19. A Small End-to-End Example
Let's imagine a client requests:
GET /users/2
Our controller:
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}
}
Our service:
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private readonly users = [
{ id: 1, name: 'Koushik' },
{ id: 2, name: 'Alex' },
];
findOne(id: number) {
return this.users.find((user) => user.id === id);
}
}
Our module:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
And our root module:
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}
When the request arrives, the main steps are:
- NestJS matches
GET /users/2toUsersController.findOne(). -
ParseIntPipevalidates and converts"2"to the number2. - The controller calls
usersService.findOne(2). - NestJS has already resolved the service dependency through DI.
- The service finds the user.
- NestJS serializes the returned object as the HTTP response.
Because this example uses an in-memory array, the data resets when the application restarts. A real service might query a database instead.
20. A Note About Provider Scope
By default, providers are usually singleton-scoped. NestJS generally creates one instance and reuses it in the relevant application context.
Other scopes include:
- Singleton (default): instance is shared in the module/application context.
- Request-scoped: a new instance is created for each request context.
- Transient: a new instance is created for each consumer that injects it.
Don't use request scope everywhere just because requests are coming in. It can increase object creation and affect performance. Use it when you genuinely need per-request state.
21. Common Beginner Mistakes
Mistake 1: Forgetting to register a service
Adding @Injectable() alone isn't enough. The provider generally needs to be registered in a module's providers array or supplied through another valid provider configuration.
Mistake 2: Putting all business logic in the controller
Controllers become harder to maintain if they contain database queries, validation rules, and complex business logic.
Keep controllers focused on handling HTTP and delegate the work to services/providers.
Mistake 3: Thinking modules automatically share every provider
A provider is not automatically available across all modules.
To share a provider, you typically export it from its module and import that module where it is needed.
Mistake 4: Confusing authentication and validation
- Guards commonly decide whether the request is allowed.
- Pipes commonly validate or transform incoming values.
- Services perform business operations.
Each has a different responsibility.
Mistake 5: Thinking a service must always connect to a database
A service can do many kinds of work. It may calculate values, call another API, coordinate business rules, or interact with a database.
22. The Big Picture
Here's how the concepts fit together:
AppModule
|
v
UsersModule
|
┌─────────┴─────────┐
| |
UsersController UsersService
| |
| needs | performs
| | business logic
v v
DI Container Data source
|
└── injects UsersService
into UsersController
And the request pipeline:
HTTP Request
|
Middleware
|
Guards
|
Interceptors (before)
|
Pipes
|
Controller
|
Service
|
Data source
|
Interceptors (after)
|
HTTP Response
Exception filters handle errors along the way.
23. Quick Revision Cheat Sheet
| Concept | Simple Meaning |
|---|---|
| Module | Groups related controllers and providers |
| Controller | Handles incoming requests and route mapping |
| Provider | A dependency managed by NestJS |
| Service | A common provider for application/business logic |
| DI | A class declares dependencies; the framework supplies them |
| DI container | Resolves and manages registered providers |
@Module() |
Describes module structure |
@Controller() |
Declares a controller and optional route prefix |
@Injectable() |
Marks a class as usable with dependency injection |
| Middleware | Runs early and can process requests before routing logic |
| Guard | Decides whether a request may proceed |
| Interceptor | Wraps handler execution before and after |
| Pipe | Validates or transforms input |
| Exception filter | Controls exception handling and error responses |
| Provider scope | Controls how provider instances are created and reused |
24. Final Takeaway
The main thing I want to remember is that NestJS isn't just a collection of decorators.
Each concept has a job:
- Modules organize features.
- Controllers receive HTTP requests.
- Services/providers perform reusable application work.
- The DI container connects classes and manages dependencies.
- Decorators provide metadata that NestJS uses to understand the application.
- The request lifecycle defines how a request moves through the application and becomes a response.
Once this structure makes sense, NestJS code becomes much easier to read.
Instead of asking, "Why are there so many files?", I can ask:
"What responsibility does each file have, and how does NestJS connect them?"
That is a much more useful way to learn the framework.
Top comments (0)