Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 module
  • providers — what services are available within this module
  • imports — what other modules this module depends on
  • exports — 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 CoreNestJSNotes
[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 directlyNestJS serializes the return value
Ok(result)Return the value200 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 CoreNestJSBehavior
AddSingleton<T>()Scope.DEFAULT (the default)One instance for the entire application lifetime
AddScoped<T>()Scope.REQUESTOne instance per HTTP request
AddTransient<T>()Scope.TRANSIENTNew 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

ConcernASP.NET CoreNestJSNotes
Service registration locationProgram.cs (one place)Each @Module() (distributed)NestJS modules co-locate registration with the feature
Default service scopeYou choose per registrationDEFAULT (singleton)NestJS defaults to singleton; request scope is opt-in
Service visibilityGlobal (any registered service is injectable everywhere)Scoped to module; must export to shareNestJS is stricter — good for large codebases
Returning errors from controllersreturn NotFound()throw new NotFoundException()NestJS services throw; filters convert to HTTP responses
Route definition[Route] + [HttpGet] on methods@Controller() + @Get() on methodsFunctionally identical
Parameter binding[FromBody], [FromQuery], etc.@Body(), @Query(), etc.Same concept, parameter decorators vs. parameter attributes
Interface extraction for servicesCommon pattern (IOrderService)Rarely used — inject the concrete classTypeScript’s structural typing makes interfaces less necessary
Startup validationResolved lazily at first requestResolved eagerly at startupNestJS 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:

OperationASP.NET Core defaultNestJS default
GET returning an object200200
POST returning an object200 (unless you return Created())201
PUT returning an object200200
DELETE returning void200 (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 with name: string, priceCents: number, sku: string
  • GET /api/products — list all products
  • GET /api/products/:id — get one product; return 404 if not found
  • PATCH /api/products/:id — partial update (not PUT — all fields optional)
  • DELETE /api/products/:id — delete; return 204

Then:

  1. Add a StatsModule with a StatsService that has a getProductCount() method
  2. Export StatsService from StatsModule
  3. Import StatsModule into ProductsModule
  4. Inject StatsService into ProductsController and add GET /api/products/stats that 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 CoreNestJSFile Location
Program.cs (builder setup)app.module.tssrc/app.module.ts
Program.cs (app startup)main.tssrc/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 methodController method decorator
[Authorize]Guard (see Article 4.2)
IServiceProviderModuleRef (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