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.
Introduction To Nest JS
Part 2 of 6
Table of Contents
- The Three Things You Need to Understand
- Modules: Your App’s Feature Folders With Superpowers
- The Root Module: Where Everything Connects
- Controllers: The Traffic Cop of Your API
- DTOs: What Goes In and What Comes Out
- Services: Where the Real Work Happens
- How the Three Fit Together
- What Your Project Structure Should Look Like
- The Mistake Almost Everyone Makes First
- What Comes Next
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:
- Modules — how your app is organized
- Controllers — how HTTP requests get handled
- 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 requestsproviders— services and other injectable classes this module ownsexports— 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: anyand 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, andnest generate service usersand 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
NestJS + PostgreSQL with TypeORM: Complete Setup Guide
Learn how to connect NestJS to PostgreSQL using TypeORM — from installation to entities, relations, and repositories. With real code and the mistakes you'll actually make.
JWT Authentication in NestJS: Complete Guide with Real Code
Learn how to implement JWT authentication in NestJS from scratch — registration, login, protected routes, and Guards — with real working code every step of the way.
NestJS Guards vs Middleware: When to Use Which
Guards and middleware look similar in NestJS but they solve very different problems. Here's how to tell them apart and know exactly when to reach for each one.