Understanding NestJS Architecture: Modules, Controllers, and Providers
Technology Blogs

Understanding NestJS Architecture: Modules, Controllers, and Providers

Bhuvnesh Sharma
Software Engineer

NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Built on top of Express (or Fastify) and written in TypeScript, it is a natural fit for teams who want strong typing and modern architectural patterns.

What makes NestJS stand out is its opinionated structure. Unlike Express, which gives you complete freedom to organise your project, NestJS enforces a clear architecture inspired by Angular. At the heart of this architecture are three fundamental building blocks:

  • Modules — organise and encapsulate related features
  • Controllers — handle incoming HTTP requests and routing
  • Providers — hold business logic and are injectable via DI

This guide breaks down each building block in depth, explains how they interact, and shows you practical examples so you can confidently structure scalable, maintainable NestJS applications.

Why NestJS?

Before diving into the architecture, it is worth understanding why NestJS has become a dominant choice for backend development in the Node.js ecosystem. Key reasons include:

  • TypeScript-first — full TypeScript support with decorators and type safety out of the box
  • Opinionated structure — predefined conventions reduce decision fatigue in large teams
  • Dependency injection — first-class DI system that promotes testability and decoupling
  • Modular architecture — features are encapsulated in modules, enabling reuse and separation of concerns
  • CLI tooling — powerful CLI to scaffold modules, controllers, and services instantly
  • Ecosystem compatibility — works with TypeORM, Mongoose, Prisma, GraphQL, WebSockets, and more
  • Testing support — built-in Jest integration makes unit and e2e testing straightforward
  • Microservices ready — native support for TCP, Redis, RabbitMQ, and gRPC transports

Prerequisites

Before starting, ensure you have the following tools and knowledge:

  • Node.js (latest LTS version) installed on your machine
  • npm or yarn as your package manager
  • Basic understanding of TypeScript syntax and decorators
  • Familiarity with REST API concepts (HTTP methods, status codes)
  • A code editor such as VS Code with the TypeScript extension

Step 1: Setting Up a NestJS Project

Install the NestJS CLI globally and scaffold a new project:

npm install -g @nestjs/cli
nest new nestjs-architecture-demo
cd nestjs-architecture-demo
npm run start:dev

The CLI generates a well-organised folder structure for you:

src/
  app.controller.ts          # Root controller
  app.controller.spec.ts     # Controller unit tests
  app.module.ts              # Root module
  app.service.ts             # Root service (provider)
  main.ts                    # Application entry point

This generated structure already demonstrates the three core building blocks working together. Let us now explore each one in depth.

Step 2: Understanding Modules

What is a Module?

A module is the fundamental organisational unit in NestJS. Every NestJS application has at least one module — the root module (AppModule). Modules group related controllers and providers into a single cohesive feature boundary.

Think of a module as a self-contained feature box. In an e-commerce application, for example, you might have:

  • UsersModule — manages user accounts and authentication
  • OrdersModule — handles order creation, tracking, and history
  • ProductsModule — manages the product catalogue and inventory
  • PaymentsModule — integrates with payment gateways

Creating a Module

Use the CLI to generate a module instantly:

nest generate module users

A basic module looks like this:

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

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // Makes UsersService available to other modules
})
export class UsersModule {}

The @Module() Decorator

The @Module() decorator accepts a metadata object with four key properties:

  • imports — other modules whose exported providers are needed here
  • controllers — controllers that belong to this module and handle incoming requests
  • providers — services, repositories, factories — anything injectable in this module
  • exports — subset of providers to make available to other modules that import this one

Shared and Global Modules

If a module needs to be available application-wide without re-importing it everywhere, mark it as global using @Global():

import { Global, Module } from '@nestjs/common';

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}

Use @Global() sparingly. It is best reserved for cross-cutting concerns such as:

  • Database connections
  • Configuration services
  • Logging utilities
  • Caching layers

Step 3: Understanding Controllers

What is a Controller?

Controllers are responsible for handling incoming HTTP requests and returning responses to the client. They define the routing layer of your application. A controller maps specific HTTP methods and URL paths to handler functions.

Controllers should NOT contain business logic. Their sole responsibility is to:

  • Receive an incoming HTTP request
  • Validate and extract route params, query strings, or request body
  • Delegate the actual work to a service (provider)
  • Return the service result as an HTTP response

Creating a Controller

nest generate controller users

Here is a complete users controller example:

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

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

  @Get()           // GET /users
  findAll() { return this.usersService.findAll(); }

  @Get(':id')      // GET /users/:id
  findOne(@Param('id') id: string) { return this.usersService.findOne(+id); }

  @Post()          // POST /users
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createUserDto: CreateUserDto) { return this.usersService.create(createUserDto); }

  @Put(':id')      // PUT /users/:id
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

  @Delete(':id')   // DELETE /users/:id
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id') id: string) { return this.usersService.remove(+id); }
}

Key Controller Decorators

NestJS provides a rich set of decorators for controllers:

  • @Controller(‘route’) — defines the base path for all routes in the class
  • @Get(), @Post(), @Put(), @Delete(), @Patch() — map HTTP methods to handler functions
  • @Param() — extracts route parameters (e.g., :id)
  • @Body() — extracts the full request body
  • @Query() — extracts query string parameters
  • @HttpCode() — sets the HTTP response status code
  • @Headers() — extracts specific request headers
  • @Req() / @Res() — injects the raw Express request/response objects (use sparingly)

Step 4: Understanding Providers

What is a Provider?

Providers are the backbone of any NestJS application. They are classes that can be injected as dependencies into controllers or other providers. A class becomes a provider when it is annotated with the @Injectable() decorator, which tells NestJS that the class can be managed by its DI container.

Common provider types include:

  • Services — contain core business logic (e.g., UsersService)
  • Repositories — abstract database access (e.g., with TypeORM or Prisma)
  • Factories — dynamically create or configure other services
  • Helpers / Utilities — shared logic like hashing, formatting, or validation
  • Guards — protect routes based on authentication or roles
  • Interceptors — transform responses or add cross-cutting logic

Creating a Service Provider

nest generate service users

import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

@Injectable()
export class UsersService {
  private users = []; // In-memory store for demo; use a DB in production
  private idCounter = 1;

  findAll() { return this.users; }

  findOne(id: number) {
    const user = this.users.find(u => u.id === id);
    if (!user) throw new NotFoundException(`User #${id} not found`);
    return user;
  }

  create(createUserDto: CreateUserDto) {
    const user = { id: this.idCounter++, ...createUserDto };
    this.users.push(user);
    return user;
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    const user = this.findOne(id);
    Object.assign(user, updateUserDto);
    return user;
  }

  remove(id: number) {
    const idx = this.users.findIndex(u => u.id === id);
    if (idx === -1) throw new NotFoundException(`User #${id} not found`);
    this.users.splice(idx, 1);
  }
}

How Dependency Injection Works

NestJS uses constructor-based dependency injection. When the DI container creates a controller, it automatically instantiates and injects the required providers. You never manually call new UsersService() — NestJS handles the lifecycle for you:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {
    // usersService is injected by NestJS — no new keyword needed
  }
}

The DI lifecycle follows these steps:

  • NestJS reads the constructor signature of the controller
  • It looks up UsersService in the module’s providers array
  • If not yet instantiated, it creates a singleton instance
  • The instance is injected into the controller automatically

Want to Implement Nestjs in Your Project? Contact Us for a Consultation.

Custom Providers

Beyond simple services, NestJS supports advanced provider patterns:

  • Value providers — inject plain values or configuration objects
  • Factory providers — use a factory function to create the provider dynamically
  • Class providers — swap one implementation for another (e.g., mock in tests)
  • Alias providers — provide the same instance under a different token
// Example: factory provider for a database connection
@Module({
  providers: [
    {
      provide: 'DB_CONNECTION',
      useFactory: async () => {
        const connection = await createDatabaseConnection();
        return connection;
      },
    },
  ],
})
export class DatabaseModule {}

Step 5: How Modules, Controllers, and Providers Work Together

Now that we understand each building block individually, let us see how they come together to form a complete feature:

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

@Module({
  controllers: [UsersController], // Handles HTTP routing
  providers:   [UsersService],    // Contains business logic
  exports:     [UsersService],    // Allow other modules to inject UsersService
})
export class UsersModule {}

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

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

The data flow through this architecture is straightforward and predictable:

  • Client sends HTTP request → Controller receives and routes it
  • Controller delegates business logic → Service (Provider) executes it
  • Service interacts with data layer → returns result to Controller
  • Controller sends HTTP response → Client receives the result
  • Module encapsulates both Controller and Service → AppModule imports the feature module

Step 6: Running the Application

Start the development server with hot reload:

npm run start:dev

Your API is now live at http://localhost:3000. Test the users endpoints:

# Create a user
curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# Fetch all users
curl http://localhost:3000/users

# Fetch user by ID
curl http://localhost:3000/users/1

# Update a user
curl -X PUT http://localhost:3000/users/1 \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice Updated"}'

# Delete a user
curl -X DELETE http://localhost:3000/users/1

Architecture Best Practices

Adhering to NestJS conventions ensures your application remains maintainable as it grows. Follow these guidelines:

Module Design

  • One module per feature — keep each feature (users, orders, products) in its own module folder
  • Avoid circular dependencies — restructure to use a shared module if two modules need each other
  • Export only what is needed — use the exports array to expose only required providers
  • Use @Global() sparingly — only for truly cross-cutting concerns like config or logging

Controller Design

  • Thin controllers — controllers only receive requests and return responses; no business logic
  • Use DTOs for validation — combine DTOs with class-validator for automatic payload validation
  • Use guards for authentication — protect routes with @UseGuards() instead of inline checks
  • Keep route handlers short — if a handler is more than 5 lines, extract logic to a service

Provider Design

  • Single-responsibility services — each service should do one thing well
  • Use interfaces for testability — define interfaces for services so they can be easily mocked
  • Leverage the CLI — always use nest generate to scaffold files with correct boilerplate
  • Avoid injecting repositories directly into controllers — always go through a service layer
// Production-ready app.module.ts with ConfigModule
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }), // Global config
    UsersModule,
  ],
})
export class AppModule {}

Quick Reference: Core Building Blocks

A summary of the three pillars:

ConceptWhat it doesNestJS Decorator
ModuleOrganises and encapsulates features@Module()
ControllerHandles routing and HTTP requests@Controller()
ProviderHolds business logic, injectable@Injectable()
ServiceCommon provider type for business logic@Injectable()
RepositoryData-access provider (TypeORM, etc.)@Injectable()
coma

Conclusion

NestJS’s three-pillar architecture Modules, Controllers, and Providers provides a clear, scalable structure for building backend applications. This architecture is designed to grow with your Node.js app, making it ideal for teams looking to scale effectively over time.

Modules play a crucial role in enforcing feature isolation and establishing clear boundaries between application domains. By organizing related features, they help maintain a clean structure as the application expands. Controllers, on the other hand, manage routing, ensuring the routing layer remains clean, thin, and free of business logic.

Providers are the backbone of business logic in NestJS. They ensure that the logic is decoupled, injectable, and thoroughly testable, making the system more maintainable. Together, these pillars make NestJS an excellent choice for building scalable, enterprise-grade APIs, microservices, or any server-side application with long-term maintainability in mind.

Bhuvnesh Sharma

Bhuvnesh Sharma

Software Engineer

Bhuvnesh is a proficient Full-Stack developer with 4+ years of expertise in the MERN stack. He excels in creating sustainable, scalable web applications and RESTful APIs with optimized code. Specializing in dynamic user interfaces and robust server-side applications, he is dedicated to staying current with the latest tech trends. His passion for innovation drives him to deliver high-quality solutions that exceed client expectations.

Share This Blog

Read More Similar Blogs

Let’s Transform
Healthcare,
Together.

Partner with us to design, build, and scale digital solutions that drive better outcomes.

Location

5900 Balcones Dr, Ste 100-7286, Austin, TX 78731, United States

Contact form