DEV Community

koushikmaya
koushikmaya

Posted on

# NestJS Fundamentals: Modules, Controllers, Services, Dependency Injection & Request Lifecycle

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

Let's break it down.

@Module()

This decorator gives NestJS metadata describing how the module is organized.

controllers

controllers: [UsersController]
Enter fullscreen mode Exit fullscreen mode

These are the controllers that belong to this module.

providers

providers: [UsersService]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Their responsibilities are different:

UsersModule
   |
   ├── registers UsersController
   |
   └── registers UsersService
                ↑
                |
UsersController ┘
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

The controller needs a UsersService.

Instead of creating the service manually like this:

const usersService = new UsersService();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

And our root module:

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

@Module({
  imports: [UsersModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

When the request arrives, the main steps are:

  1. NestJS matches GET /users/2 to UsersController.findOne().
  2. ParseIntPipe validates and converts "2" to the number 2.
  3. The controller calls usersService.findOne(2).
  4. NestJS has already resolved the service dependency through DI.
  5. The service finds the user.
  6. 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
Enter fullscreen mode Exit fullscreen mode

And the request pipeline:

HTTP Request
     |
 Middleware
     |
   Guards
     |
Interceptors (before)
     |
    Pipes
     |
 Controller
     |
   Service
     |
 Data source
     |
Interceptors (after)
     |
HTTP Response
Enter fullscreen mode Exit fullscreen mode

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)