NestJS Modules, Controllers, and Services: The Architecture Finally Explained

Confused by NestJS modules, controllers, and services? This guide breaks down the core architecture in plain terms, with real code examples and analogies that actually stick.

NestJS Modules, Controllers, and Services: The Architecture Finally Explained

When I first opened a NestJS project, I thought someone had dramatically over-complicated what should be a simple Node.js API.

There was a app.module.ts, a app.controller.ts, a app.service.ts, some kind of NestFactory thing in main.ts — and I hadn’t even written a single route yet. Coming from Express, where you just do app.get('/users', handler) and move on, this felt like ten times the work for the same result.

But here’s what clicked for me eventually: NestJS isn’t adding complexity for the sake of it. It’s giving you the structure that every large Express project eventually builds for itself — just upfront, before things get messy.

Once that clicked, everything else made sense. This article is about getting you to that same point, but faster.

Before you read this: If you haven’t read the first article in this series on how NestJS compares to Express, start there. This one builds on that foundation.


The Three Things You Need to Understand

NestJS has a lot of concepts, but everything starts with these three:

  1. Modules — how your app is organized
  2. Controllers — how HTTP requests get handled
  3. Services — where your actual logic lives

Get these three right and the rest of NestJS starts to make sense on its own.


Modules: Your App’s Feature Folders With Superpowers

In a typical Express project, feature organization is just a folder convention. You create a users/ folder, put some files in it, and manually import them wherever they’re needed. Nothing enforces any of it.

NestJS modules are different. A module is a TypeScript class with a @Module() decorator that declares what belongs to that feature — its controllers, its services, what it needs from other modules, and what it gives back.

Here’s a simple users module:

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

@Module({
  controllers: [UsersController], // handles HTTP routes for /users
  providers: [UsersService],      // services, repositories, helpers
  exports: [UsersService],        // what other modules can use
})
export class UsersModule {}

Four properties. That’s really all there is to it most of the time.

  • controllers — the classes that handle incoming HTTP requests
  • providers — services and other injectable classes this module owns
  • exports — things other modules can import and use (like a public API for the module)
  • imports — other modules this one depends on (not shown above, but used when you need something from another module)

The exports field is the one people miss first. If your AuthModule needs to use UsersService, then UsersModule has to explicitly export it — otherwise NestJS won’t allow it. It’s a boundary, and that’s intentional.

Think of modules like this: each one is a self-contained unit with a clear contract. It knows what it needs and it knows what it offers. That’s it.


The Root Module: Where Everything Connects

Every NestJS app has one AppModule. It’s not where you put your business logic — it’s just the thing that ties all your feature modules together.

// app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { DocumentsModule } from './documents/documents.module';

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

That’s all AppModule should really do — import feature modules. If you find yourself putting controllers and services directly in AppModule, that’s usually a sign it’s time to extract a feature module.


Controllers: The Traffic Cop of Your API

A controller’s job is simple: receive an HTTP request, figure out what to do with it, and return a response.

That’s it. Nothing else belongs in a controller.

No database calls. No business logic. No data transformation. Just route handling, and then delegating to a service.

Here’s what a basic controller looks like:

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

@Controller('users') // all routes in here start with /users
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
  // NestJS injects UsersService automatically — you never call new 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(201)
  create(@Body() body: CreateUserDto) {
    return this.usersService.create(body);
  }

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

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

A few things worth pointing out:

@Controller('users') sets the base path for every route in this class. You don’t repeat /users in every @Get() or @Post() — it’s inherited.

constructor(private readonly usersService: UsersService) is dependency injection. NestJS sees that this controller needs a UsersService, creates one (or reuses an existing instance), and provides it automatically. You never manually instantiate services.

@HttpCode(201) lets you override the default status code. NestJS defaults to 200 for most methods and 201 for POST — but sometimes you need explicit control.

You return data directly. No res.json(). NestJS handles serialization. Whatever you return from a method gets sent as JSON.


DTOs: What Goes In and What Comes Out

You’ll notice CreateUserDto and UpdateUserDto in the controller. DTO stands for Data Transfer Object — it’s just a class that describes the shape of incoming data.

// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;
}

If you’ve used Zod or Yup on the frontend, this is the same concept. Define the shape, add validation rules, and NestJS (with ValidationPipe enabled) will automatically reject requests that don’t match before they even reach your controller.

To enable that globally, add this to main.ts:

// main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe()); // validate all incoming requests
  await app.listen(3000);
}

One line. Every controller in your entire app now validates incoming data automatically.

⚠️ Don’t skip DTOs. It’s tempting to just type body: any and move fast. But you’ll end up with garbage in your database and confusing bugs. DTOs are cheap to write and they save you hours of debugging.


Services: Where the Real Work Happens

If the controller is the traffic cop, the service is the actual worker.

All your database calls, your business rules, your calculations — they live here. Services are plain TypeScript classes decorated with @Injectable(), which just tells NestJS “this class can be injected into other classes.”

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

@Injectable()
export class UsersService {
  // In a real app, you'd inject a TypeORM repository or Prisma client here
  // For now, we're using an in-memory array to keep the example clean
  private users: { id: string; name: string; email: string }[] = [];

  findAll() {
    return this.users;
  }

  findOne(id: string) {
    const user = this.users.find(u => u.id === id);

    if (!user) {
      // NestJS catches this and sends a 404 response automatically
      throw new NotFoundException(`User with id ${id} not found`);
    }

    return user;
  }

  create(dto: CreateUserDto) {
    const user = {
      id: crypto.randomUUID(),
      name: dto.name,
      email: dto.email,
    };

    this.users.push(user);
    return user;
  }

  update(id: string, dto: UpdateUserDto) {
    const user = this.findOne(id); // reuse findOne — it handles the 404
    Object.assign(user, dto);
    return user;
  }

  remove(id: string) {
    const index = this.users.findIndex(u => u.id === id);

    if (index === -1) {
      throw new NotFoundException(`User with id ${id} not found`);
    }

    this.users.splice(index, 1);
  }
}

A couple of things I want to highlight here.

NotFoundException is a built-in NestJS HTTP exception. When you throw it, NestJS automatically sends a 404 response with a proper error message. No manual res.status(404).json(...). NestJS ships with exceptions for all common HTTP errors: BadRequestException, UnauthorizedException, ForbiddenException, ConflictException, and more.

Services can call other services. If your AuthService needs to check if a user exists, it can inject UsersService directly — as long as UsersModule exports it and AuthModule imports UsersModule.

// auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}
  // UsersService is injected here — works because AuthModule imports UsersModule
}

How the Three Fit Together

Let’s trace a single request from start to finish.

A POST /users request comes in:

Request → main.ts (bootstrap)
        → AppModule (routes to the right feature module)
        → UsersModule (owns the /users routes)
        → UsersController (receives POST /users, extracts @Body())
        → ValidationPipe (validates body against CreateUserDto)
        → UsersService.create(dto) (does the actual work)
        → UsersController (returns the result)
        → Response (NestJS serializes to JSON, sends 201)

Each layer has one job. Nothing more. That’s the whole point of this architecture — when something breaks, you know exactly where to look.


What Your Project Structure Should Look Like

Here’s a real-world structure for a small NestJS app with users and auth:

src/
├── users/
│   ├── dto/
│   │   ├── create-user.dto.ts
│   │   └── update-user.dto.ts
│   ├── users.controller.ts
│   ├── users.module.ts
│   └── users.service.ts
├── auth/
│   ├── dto/
│   │   └── login.dto.ts
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   └── auth.service.ts
├── app.module.ts
└── main.ts

Every feature gets its own folder. Inside that folder: the module, the controller, the service, and a dto/ subfolder. That’s the pattern. Stick to it and your project will stay navigable no matter how large it gets.

💡 NestJS CLI tip: You don’t have to create these files by hand. Run nest generate module users, nest generate controller users, and nest generate service users and the CLI creates everything and wires up the module automatically.


The Mistake Almost Everyone Makes First

You create a service. You wire it up. You run the app. And you get:

Nest can't resolve dependencies of the UsersController (?).
Please make sure that the argument UsersService at index [0] is available in the UsersModule context.

This means you forgot to add your service to the providers array in the module.

// ❌ Missing provider — this causes the error above
@Module({
  controllers: [UsersController],
})
export class UsersModule {}

// ✅ Correct
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

Every service you create needs to be in providers. Every time. This error will happen to you at least twice before it becomes muscle memory.


What Comes Next

You now understand how NestJS structures an application. Modules own features. Controllers handle routes. Services do the work. DTOs define what comes in.

The next piece of the puzzle is authentication — and honestly, it’s where NestJS starts to really shine. In the next article, we’ll build a complete JWT login and registration system using Guards and Passport, from scratch. [TODO: link when published]

If you have questions about anything in this article, drop them in the comments. I’ll answer every one.

Continue the series →

Next up: NestJS Modules, Controllers and Services — the architecture finally explained.

Read next article →

Related Posts