NestJS Architecture: Modules, Controllers, Services
For .NET engineers who know: ASP.NET Core —
Program.cs,IServiceCollection, controllers, services, constructor injection, and the request pipeline You’ll learn: How NestJS maps one-to-one with ASP.NET Core’s architecture, and how to build a complete CRUD API using the same mental model you already have Time: 15-20 min read
The .NET Way (What You Already Know)
An ASP.NET Core application has a fixed architecture that you probably navigate on autopilot. Program.cs wires everything together: you call builder.Services.Add*() to register services, app.Use*() to add middleware, and the framework resolves the dependency graph at startup. Controllers are classes decorated with [ApiController] and [Route], action methods are decorated with [HttpGet], [HttpPost], etc. Services are plain classes registered with AddScoped, AddSingleton, or AddTransient, and injected via constructors.
// Program.cs — the whole startup in one place
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
// OrdersController.cs
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetOrder(int id)
{
var order = await _orderService.GetByIdAsync(id);
return order is null ? NotFound() : Ok(order);
}
}
// OrderService.cs
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db)
{
_db = db;
}
public async Task<Order?> GetByIdAsync(int id) =>
await _db.Orders.FindAsync(id);
}
The pattern is: register in IServiceCollection, inject via constructor, decorate with attributes. NestJS does exactly this, with decorators instead of attributes and modules instead of IServiceCollection.
The NestJS Way
Creating a Project
# Install the NestJS CLI globally
pnpm add -g @nestjs/cli
# Create a new project (equivalent to: dotnet new webapi -n MyApi)
nest new my-api
cd my-api
# The CLI asks for a package manager — choose pnpm
# It scaffolds the project and installs dependencies
# Run in development mode with hot reload (equivalent to: dotnet watch run)
pnpm run start:dev
The generated project structure:
my-api/
├── src/
│ ├── app.controller.ts # Root controller (can delete)
│ ├── app.module.ts # Root module — equivalent to Program.cs
│ ├── app.service.ts # Root service (can delete)
│ └── main.ts # Entry point — equivalent to Program.cs bootstrap
├── test/
├── nest-cli.json # NestJS CLI config
├── tsconfig.json
└── package.json
// main.ts — Entry point. Equivalent to the top of Program.cs.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // Like: app.MapControllers() with a route prefix
await app.listen(3000);
}
bootstrap();
Modules — The IServiceCollection Equivalent
In ASP.NET Core, IServiceCollection is a flat list. You register everything in one place and the DI container handles resolution globally.
NestJS uses modules instead. Each module is a class decorated with @Module() that explicitly declares:
controllers— what controllers belong to this moduleproviders— what services are available within this moduleimports— what other modules this module depends onexports— what providers this module makes available to other modules
This is stricter than ASP.NET Core. In .NET, any registered service is globally available to any other service. In NestJS, a service in OrdersModule cannot be injected into UsersModule unless OrdersModule explicitly exports it.
// app.module.ts — Root module. Equivalent to builder.Services.Add*() in Program.cs.
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
OrdersModule, // Like: builder.Services.AddModule<OrdersModule>() (hypothetical)
UsersModule,
],
})
export class AppModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController], // Registers this controller's routes
providers: [OrdersService], // Equivalent to: builder.Services.AddScoped<OrdersService>()
exports: [OrdersService], // Makes OrdersService injectable in other modules
})
export class OrdersModule {}
The exports array is the key difference from ASP.NET Core. If you forget to export a provider, you get a clean error at startup: Nest can't resolve dependencies of the XyzService. This is actually nicer than .NET’s equivalent, which is a runtime InvalidOperationException when the first request hits.
Controllers — Exactly Like ASP.NET Core
The mapping is direct enough that you can read NestJS controllers on your first day:
// orders/orders.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
ParseIntPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
// @Controller('orders') = [Route("orders")] on the class
// Combined with app.setGlobalPrefix('api'), routes are: /api/orders
@Controller('orders')
export class OrdersController {
// Constructor injection — identical to ASP.NET Core
constructor(private readonly ordersService: OrdersService) {}
// @Get() = [HttpGet]
// No route segment = GET /api/orders
@Get()
findAll() {
return this.ordersService.findAll();
}
// @Get(':id') = [HttpGet("{id}")]
// @Param('id') = the {id} route segment
// ParseIntPipe = equivalent to the automatic int conversion in model binding
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.ordersService.findOne(id);
}
// @Post() = [HttpPost]
// @Body() = [FromBody] — deserializes the request body
@Post()
create(@Body() createOrderDto: CreateOrderDto) {
return this.ordersService.create(createOrderDto);
}
// @Put(':id') = [HttpPut("{id}")]
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateOrderDto: UpdateOrderDto,
) {
return this.ordersService.update(id, updateOrderDto);
}
// @Delete(':id') = [HttpDelete("{id}")]
// @HttpCode() = [ProducesResponseType(204)] — sets the success status code
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number) {
return this.ordersService.remove(id);
}
}
Decorator-to-attribute mapping:
| ASP.NET Core | NestJS | Notes |
|---|---|---|
[ApiController] + [Route("orders")] | @Controller('orders') | Combined into one decorator |
[HttpGet] | @Get() | |
[HttpGet("{id}")] | @Get(':id') | NestJS uses :param syntax |
[HttpPost] | @Post() | |
[HttpPut("{id}")] | @Put(':id') | |
[HttpDelete("{id}")] | @Delete(':id') | |
[FromBody] | @Body() | Parameter decorator |
[FromRoute] / route parameter | @Param('name') | Parameter decorator |
[FromQuery] | @Query('name') | Parameter decorator |
[FromHeader] | @Headers('name') | Parameter decorator |
IActionResult / ActionResult<T> | Return T directly | NestJS serializes the return value |
Ok(result) | Return the value | 200 is the default for @Get, @Put |
Created(result) | Return the value with @HttpCode(201) | Or use @Post() which defaults to 201 |
NoContent() | @HttpCode(204) | |
NotFound() | throw new NotFoundException() | Covered in the Gotchas section |
Services — @Injectable() is AddScoped()
A NestJS service is a plain TypeScript class with the @Injectable() decorator. The decorator marks it as a participant in the DI system — analogous to what AddScoped<T>() does in .NET (with singleton as the default, which we’ll cover in a moment).
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
// @Injectable() = registration token for the DI container
// The service must ALSO be listed in a module's providers array to actually be registered
@Injectable()
export class OrdersService {
// In a real app, inject PrismaService here instead of in-memory storage
private readonly orders: Order[] = [];
private nextId = 1;
findAll(): Order[] {
return this.orders;
}
findOne(id: number): Order {
const order = this.orders.find((o) => o.id === id);
// Equivalent to: return NotFound() in the controller
// NestJS exception filters handle this and return a 404 response
if (!order) {
throw new NotFoundException(`Order #${id} not found`);
}
return order;
}
create(dto: CreateOrderDto): Order {
const order: Order = { id: this.nextId++, ...dto };
this.orders.push(order);
return order;
}
update(id: number, dto: UpdateOrderDto): Order {
const index = this.orders.findIndex((o) => o.id === id);
if (index === -1) {
throw new NotFoundException(`Order #${id} not found`);
}
this.orders[index] = { ...this.orders[index], ...dto };
return this.orders[index];
}
remove(id: number): void {
const index = this.orders.findIndex((o) => o.id === id);
if (index === -1) {
throw new NotFoundException(`Order #${id} not found`);
}
this.orders.splice(index, 1);
}
}
The NotFoundException class (and its siblings BadRequestException, ConflictException, etc.) are NestJS’s equivalent of returning NotFound(), BadRequest(), etc. from a controller. Because services don’t have access to the Response object, throwing exceptions is the right pattern — NestJS’s built-in exception filter catches them and converts them to appropriate HTTP responses.
DTOs
DTOs in NestJS are plain TypeScript classes. They look like C# DTO classes but without attributes — validation is applied separately via pipes (covered in Article 4.2).
// orders/dto/create-order.dto.ts
export class CreateOrderDto {
customerId: number;
items: Array<{ productId: number; quantity: number }>;
notes?: string;
}
// orders/dto/update-order.dto.ts
// PartialType makes all properties optional — equivalent to your UpdateDto in C#
// where all fields are nullable/optional
import { PartialType } from '@nestjs/common';
import { CreateOrderDto } from './create-order.dto';
export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
Dependency Injection Between Modules
Here’s a concrete example of cross-module injection:
// notifications/notifications.module.ts
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
@Module({
providers: [NotificationsService],
exports: [NotificationsService], // Without this, other modules can't inject it
})
export class NotificationsModule {}
// orders/orders.module.ts — importing NotificationsModule
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [NotificationsModule], // Import the module to access its exports
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
// orders/orders.service.ts — injecting NotificationsService
@Injectable()
export class OrdersService {
constructor(
private readonly notificationsService: NotificationsService, // Resolves from NotificationsModule
) {}
}
In .NET terms, importing a module is like calling builder.Services.Add<NotificationsModule>() where the module registers its own services, and exporting a service is like making it public vs. internal.
Provider Scopes
In ASP.NET Core, you choose AddSingleton, AddScoped, or AddTransient. NestJS has the same three scopes with different names:
| ASP.NET Core | NestJS | Behavior |
|---|---|---|
AddSingleton<T>() | Scope.DEFAULT (the default) | One instance for the entire application lifetime |
AddScoped<T>() | Scope.REQUEST | One instance per HTTP request |
AddTransient<T>() | Scope.TRANSIENT | New instance every time it’s injected |
Default in NestJS is Scope.DEFAULT (singleton). Default in ASP.NET Core is… whatever you pick, but the ASP.NET convention is AddScoped for most services.
import { Injectable, Scope } from '@nestjs/common';
// Singleton (the default — same as AddSingleton in .NET)
@Injectable()
export class CacheService {}
// Request-scoped (same as AddScoped in .NET)
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {}
// Transient (same as AddTransient in .NET)
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService {}
The important implication: because the default scope is singleton, services should be stateless by default (just like singleton services in .NET). If you need per-request state, use Scope.REQUEST. Note that request-scoped providers cause a performance overhead because NestJS has to create new instances on each request and cannot cache the resolved dependency graph — the same trade-off as in ASP.NET Core.
The Request Lifecycle
In ASP.NET Core:
graph TD
subgraph aspnet["ASP.NET Core Request Lifecycle"]
A1["Request"]
A2["Middleware pipeline\n(UseAuthentication, UseAuthorization, etc.)"]
A3["Controller action method"]
A4["Action filters (before/after)"]
A5["Model binding"]
A6["Response"]
A1 --> A2 --> A3 --> A4 --> A5 --> A6
end
In NestJS:
graph TD
subgraph nestjs["NestJS Request Lifecycle"]
N1["Request"]
N2["Middleware (global → module-level)"]
N3["Guards (global → controller → handler)"]
N4["Interceptors — before (global → controller → handler)"]
N5["Pipes (global → controller → handler)"]
N6["Controller handler method"]
N7["Interceptors — after (handler → controller → global)"]
N8["Exception filters (if an exception was thrown)"]
N9["Response"]
N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7 --> N8 --> N9
end
This is covered in depth in Article 4.2. For now, the key insight is that the same concept (things that run before/after your controller logic) exists in both frameworks under different names.
Building a Complete CRUD API
Let’s build a complete, runnable Orders API that maps directly to what you’d write in ASP.NET Core. This uses Prisma for the database layer — if you don’t have it set up yet, the service can use an in-memory array and you can swap it later.
Step 1: Generate the Module
# The NestJS CLI generates the full set of files with correct wiring
# Equivalent to using Visual Studio's "Add Controller" scaffolding
nest generate module orders
nest generate controller orders
nest generate service orders
# Or all at once with a resource (generates CRUD boilerplate):
nest generate resource orders
# The CLI asks if you want REST API or GraphQL, and whether to generate CRUD entry points
Step 2: Define the Data Types
// orders/order.entity.ts
// In a real project this would be your Prisma-generated type.
// "Entity" is the NestJS convention for the domain model class.
export class Order {
id: number;
customerId: number;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
totalCents: number;
createdAt: Date;
updatedAt: Date;
}
// orders/dto/create-order.dto.ts
export class CreateOrderDto {
customerId: number;
items: Array<{
productId: number;
quantity: number;
unitPriceCents: number;
}>;
}
// orders/dto/update-order.dto.ts
import { PartialType } from '@nestjs/common';
import { CreateOrderDto } from './create-order.dto';
export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
Step 3: The Service
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { Order } from './order.entity';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
@Injectable()
export class OrdersService {
// In production: inject PrismaService and use this.prisma.order.*
private orders: Order[] = [];
private nextId = 1;
async findAll(): Promise<Order[]> {
// Prisma equivalent: return this.prisma.order.findMany();
return this.orders;
}
async findOne(id: number): Promise<Order> {
// Prisma equivalent: const order = await this.prisma.order.findUnique({ where: { id } });
const order = this.orders.find((o) => o.id === id);
if (!order) {
// NestJS translates this to a 404 response with a standard error body
throw new NotFoundException(`Order #${id} not found`);
}
return order;
}
async create(dto: CreateOrderDto): Promise<Order> {
const totalCents = dto.items.reduce(
(sum, item) => sum + item.quantity * item.unitPriceCents,
0,
);
const order: Order = {
id: this.nextId++,
customerId: dto.customerId,
status: 'pending',
totalCents,
createdAt: new Date(),
updatedAt: new Date(),
};
// Prisma equivalent: return this.prisma.order.create({ data: { ... } });
this.orders.push(order);
return order;
}
async update(id: number, dto: UpdateOrderDto): Promise<Order> {
const index = this.orders.findIndex((o) => o.id === id);
if (index === -1) {
throw new NotFoundException(`Order #${id} not found`);
}
this.orders[index] = {
...this.orders[index],
...dto,
updatedAt: new Date(),
};
// Prisma equivalent: return this.prisma.order.update({ where: { id }, data: dto });
return this.orders[index];
}
async remove(id: number): Promise<void> {
const index = this.orders.findIndex((o) => o.id === id);
if (index === -1) {
throw new NotFoundException(`Order #${id} not found`);
}
// Prisma equivalent: await this.prisma.order.delete({ where: { id } });
this.orders.splice(index, 1);
}
}
Step 4: The Controller
// orders/orders.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
ParseIntPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { UpdateOrderDto } from './dto/update-order.dto';
import { Order } from './order.entity';
@Controller('orders') // Routes: /api/orders (with global prefix)
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
// GET /api/orders
@Get()
findAll(): Promise<Order[]> {
return this.ordersService.findAll();
}
// GET /api/orders/:id
// ParseIntPipe converts the ':id' string parameter to a number
// Throws a 400 if ':id' is not a valid integer
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Promise<Order> {
return this.ordersService.findOne(id);
}
// POST /api/orders — returns 201 Created by default
@Post()
create(@Body() createOrderDto: CreateOrderDto): Promise<Order> {
return this.ordersService.create(createOrderDto);
}
// PUT /api/orders/:id
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateOrderDto: UpdateOrderDto,
): Promise<Order> {
return this.ordersService.update(id, updateOrderDto);
}
// DELETE /api/orders/:id — returns 204 No Content
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.ordersService.remove(id);
}
}
Step 5: Wire Up the Module
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService], // Export if other modules need to inject OrdersService
})
export class OrdersModule {}
// app.module.ts — Register the feature module
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [OrdersModule],
})
export class AppModule {}
Step 6: Test It
pnpm run start:dev
# In another terminal:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"customerId": 1, "items": [{"productId": 10, "quantity": 2, "unitPriceCents": 1999}]}'
# Returns: {"id":1,"customerId":1,"status":"pending","totalCents":3998,"createdAt":"...","updatedAt":"..."}
curl http://localhost:3000/api/orders/1
# Returns the order
curl http://localhost:3000/api/orders/999
# Returns: {"statusCode":404,"message":"Order #999 not found","error":"Not Found"}
The 404 response format is NestJS’s built-in exception filter — the same JSON structure every NestJS app returns by default. In ASP.NET Core, this is the ProblemDetails format enabled by [ApiController].
Key Differences
| Concern | ASP.NET Core | NestJS | Notes |
|---|---|---|---|
| Service registration location | Program.cs (one place) | Each @Module() (distributed) | NestJS modules co-locate registration with the feature |
| Default service scope | You choose per registration | DEFAULT (singleton) | NestJS defaults to singleton; request scope is opt-in |
| Service visibility | Global (any registered service is injectable everywhere) | Scoped to module; must export to share | NestJS is stricter — good for large codebases |
| Returning errors from controllers | return NotFound() | throw new NotFoundException() | NestJS services throw; filters convert to HTTP responses |
| Route definition | [Route] + [HttpGet] on methods | @Controller() + @Get() on methods | Functionally identical |
| Parameter binding | [FromBody], [FromQuery], etc. | @Body(), @Query(), etc. | Same concept, parameter decorators vs. parameter attributes |
| Interface extraction for services | Common pattern (IOrderService) | Rarely used — inject the concrete class | TypeScript’s structural typing makes interfaces less necessary |
| Startup validation | Resolved lazily at first request | Resolved eagerly at startup | NestJS validates the entire DI graph before accepting requests |
The interface point deserves explanation. In C#, you extract an interface (IOrderService) primarily for two reasons: to enable mocking in tests, and to allow DI to swap implementations. In TypeScript/NestJS, you can mock a class directly (structural typing means any object with matching methods works), and swapping implementations can be done via module configuration. Most NestJS codebases inject concrete classes, not interfaces.
Gotchas for .NET Engineers
Gotcha 1: Forgetting to List Providers in the Module
@Injectable() is not enough on its own. The service must also appear in the providers array of a module, or NestJS will refuse to inject it. The error message is specific but confusing at first:
Nest can't resolve dependencies of the OrdersController (?).
Please make sure that the argument OrdersService at index [0]
is available in the OrdersModule context.
This means you added @Injectable() to OrdersService but did not add it to OrdersModule.providers.
// WRONG — @Injectable() alone is not enough
@Injectable()
export class OrdersService { /* ... */ }
@Module({
controllers: [OrdersController],
// providers: [OrdersService] ← forgot this
})
export class OrdersModule {}
// CORRECT
@Module({
controllers: [OrdersController],
providers: [OrdersService], // Must be here
})
export class OrdersModule {}
In ASP.NET Core, there’s no equivalent mistake — builder.Services.AddScoped<OrderService>() is the only registration step. NestJS splits registration into two steps: the decorator on the class, and the listing in the module.
Gotcha 2: Forgetting to Export Services That Other Modules Need
If OrdersModule needs to inject NotificationsService, and you import NotificationsModule but NotificationsService is not in NotificationsModule.exports, you get another confusing error. The service exists and is registered — it’s just not visible outside its module.
// NotificationsModule — service is registered but not exported
@Module({
providers: [NotificationsService],
// exports: [NotificationsService] ← forgot this
})
export class NotificationsModule {}
// OrdersModule — imports the module, but can't see NotificationsService
@Module({
imports: [NotificationsModule],
providers: [OrdersService],
controllers: [OrdersController],
})
export class OrdersModule {}
// This injection fails at startup with:
// "Nest can't resolve dependencies of the OrdersService"
@Injectable()
export class OrdersService {
constructor(private readonly notificationsService: NotificationsService) {} // ERROR
}
The fix is to add NotificationsService to NotificationsModule.exports. In ASP.NET Core, there is no equivalent — every registered service is globally available. The NestJS module system is more explicit, which is genuinely better for large codebases, but requires getting used to.
Gotcha 3: Circular Module Dependencies
You’ll eventually create a situation where OrdersModule imports UsersModule and UsersModule imports OrdersModule. NestJS detects this at startup and throws:
Error: A circular dependency has been detected (OrdersModule -> UsersModule -> OrdersModule).
Please, make sure that each side of a bidirectional relationships are using forwardRef().
The fix is forwardRef():
// orders/orders.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
@Module({
imports: [forwardRef(() => UsersModule)], // Deferred reference
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
// users/users.module.ts
@Module({
imports: [forwardRef(() => OrdersModule)],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
But before reaching for forwardRef(), reconsider the design. Circular dependencies are usually a sign that two modules are too tightly coupled. The better solution is usually to extract the shared logic into a third module (SharedModule or a more specific domain module) that both can import without creating a cycle. This is the same advice you’d give in ASP.NET Core — circular service dependencies are an architecture smell.
Gotcha 4: NestJS Decorators Are Not ASP.NET Attributes
They look the same. They are not the same thing at all.
ASP.NET attributes are metadata — the CLR reads them via reflection at runtime. The attribute class has no impact on how your code executes unless something explicitly reads it.
TypeScript decorators are functions that execute when the class is defined. @Controller('orders') runs immediately when the JavaScript module is loaded, calling the NestJS framework function that registers the class as a controller. The decorator has side effects. If you apply a decorator to the wrong thing, you get an error at class definition time, not at request time.
This matters in one practical way: decorators cannot be conditional at runtime (unlike attributes, which can be applied based on runtime configuration in some patterns). Decorators are determined at class definition time and cannot be changed dynamically.
Gotcha 5: The Default HTTP Status Codes Differ Slightly
ASP.NET Core’s [ApiController] attribute changes some default behaviors. NestJS has its own defaults:
| Operation | ASP.NET Core default | NestJS default |
|---|---|---|
GET returning an object | 200 | 200 |
POST returning an object | 200 (unless you return Created()) | 201 |
PUT returning an object | 200 | 200 |
DELETE returning void | 200 (unless you return NoContent()) | 200 |
The surprise: NestJS @Post() returns 201 by default. To override, use @HttpCode(HttpStatus.OK). To make DELETE return 204 (the semantically correct response), use @HttpCode(HttpStatus.NO_CONTENT). ASP.NET engineers habitually use return Ok() and return NoContent() — in NestJS, you use @HttpCode() on the method decorator.
Hands-On Exercise
Build a complete Products feature module from scratch without using the CLI’s scaffolding — write each file manually so you understand what the CLI generates.
Requirements:
POST /api/products— create a product withname: string,priceCents: number,sku: stringGET /api/products— list all productsGET /api/products/:id— get one product; return 404 if not foundPATCH /api/products/:id— partial update (not PUT — all fields optional)DELETE /api/products/:id— delete; return 204
Then:
- Add a
StatsModulewith aStatsServicethat has agetProductCount()method - Export
StatsServicefromStatsModule - Import
StatsModuleintoProductsModule - Inject
StatsServiceintoProductsControllerand addGET /api/products/statsthat returns{ count: number }
Verify the dependency graph is correct by checking that you can call the stats endpoint after creating a few products.
Expected file structure when complete:
src/
├── products/
│ ├── dto/
│ │ ├── create-product.dto.ts
│ │ └── update-product.dto.ts
│ ├── product.entity.ts
│ ├── products.controller.ts
│ ├── products.module.ts
│ └── products.service.ts
├── stats/
│ ├── stats.module.ts
│ └── stats.service.ts
├── app.module.ts
└── main.ts
Quick Reference
ASP.NET Core → NestJS Cheat Sheet
| ASP.NET Core | NestJS | File Location |
|---|---|---|
Program.cs (builder setup) | app.module.ts | src/app.module.ts |
Program.cs (app startup) | main.ts | src/main.ts |
builder.Services.AddScoped<T>() | providers: [T] in @Module() | The feature’s *.module.ts |
builder.Services.AddSingleton<T>() | providers: [{ provide: T, useClass: T }] (default scope) | Same |
builder.Services.AddTransient<T>() | @Injectable({ scope: Scope.TRANSIENT }) | On the class |
[ApiController] + [Route("x")] | @Controller('x') | Controller class |
[HttpGet("{id}")] | @Get(':id') | Controller method |
[HttpPost] | @Post() | Controller method |
[HttpPut("{id}")] | @Put(':id') | Controller method |
[HttpDelete("{id}")] | @Delete(':id') | Controller method |
[FromBody] | @Body() | Method parameter |
[FromRoute] / route param | @Param('name') | Method parameter |
[FromQuery] | @Query('name') | Method parameter |
return NotFound() | throw new NotFoundException(msg) | In service or controller |
return BadRequest() | throw new BadRequestException(msg) | In service or controller |
return NoContent() | @HttpCode(204) on the method | Controller method decorator |
[Authorize] | Guard (see Article 4.2) | |
IServiceProvider | ModuleRef (rarely needed directly) | Injected service |
Generating Files with the CLI
nest generate module <name> # Creates <name>/<name>.module.ts
nest generate controller <name> # Creates <name>/<name>.controller.ts
nest generate service <name> # Creates <name>/<name>.service.ts
nest generate resource <name> # Creates all of the above + DTOs + CRUD boilerplate
Provider Scope Reference
// Singleton (default) — one instance for the app lifetime
@Injectable()
export class MyService {}
// Request-scoped — new instance per HTTP request
@Injectable({ scope: Scope.REQUEST })
export class RequestService {}
// Transient — new instance per injection point
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {}
Built-in HTTP Exceptions
// These map directly to HTTP status codes
throw new BadRequestException('message'); // 400
throw new UnauthorizedException('message'); // 401
throw new ForbiddenException('message'); // 403
throw new NotFoundException('message'); // 404
throw new ConflictException('message'); // 409
throw new UnprocessableEntityException('message'); // 422
throw new InternalServerErrorException('message'); // 500
Further Reading
- NestJS Official Documentation — First Steps — The official walkthrough; skim it after reading this article for completeness
- NestJS Documentation — Modules — The authoritative reference for module configuration, shared modules, and dynamic modules
- NestJS Documentation — Providers — Covers injection scopes, custom providers, and factory providers in detail
- NestJS Documentation — Controllers — Complete decorator reference for routing, parameter binding, and response handling