NestJS Core Concepts

How NestJS organises a TypeScript backend: modules, controllers, providers and dependency injection, then the request lifecycle of middleware, guards, interceptors, pipes and exception filters, plus custom providers, scopes and testing.

Intermediate⏱ 6 min readLesson 6 of 13#languages#nestjs#typescript#dependency-injection#backend

The big idea

If Express is a box of LEGO bricks, NestJS is a LEGO set with instructions. It still uses Express (or Fastify) underneath, but decides where things go: every feature is a module, HTTP lives in controllers, business logic in providers, and a built-in dependency injection container wires them together.

Think of a restaurant: the waiter (controller) takes the order, the chef (service) cooks, the supplier (repository) brings ingredients. Nobody hires their own staff; the manager (the DI container) assigns them.

A NestJS request passes middleware, guards, interceptors and pipes before reaching the controller

The building blocks

PieceDecoratorJobRestaurant analogy
Module@ModuleGroups related controllers and providersA department (kitchen, bar)
Controller@Controller, @GetMaps HTTP routes to methodsWaiter
Provider / Service@InjectableBusiness logic, data access, anything injectableChef, supplier
Guard@UseGuardsAllow or deny the request (auth, roles)Door staff
Pipe@UsePipes, ParseIntPipeValidate and transform inputChecking the order is valid
Interceptor@UseInterceptorsWrap the handler: logging, caching, mapping responsesPlating the dish
Exception filter@CatchTurn exceptions into HTTP responsesHandling complaints

Modules

@Module({
  imports: [TypeOrmModule.forFeature([Order]), UsersModule], // modules we depend on
  controllers: [OrdersController],
  providers: [OrdersService],
  exports: [OrdersService],   // what other modules may inject
})
export class OrdersModule {}

@Module({ imports: [ConfigModule.forRoot({ isGlobal: true }), OrdersModule, UsersModule] })
export class AppModule {}
Drawing diagram…

A provider is private to its module unless it is listed in exports. That boundary is what keeps a large Nest app from becoming a tangle.

Controllers and services

@Controller("orders")
export class OrdersController {
  constructor(private readonly orders: OrdersService) {} // injected, not `new`-ed

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.orders.findOne(id); // returned value → JSON, 200
  }

  @Post()
  @HttpCode(201)
  create(@Body() dto: CreateOrderDto, @Req() req: AuthedRequest) {
    return this.orders.create(req.user.id, dto);
  }
}

@Injectable()
export class OrdersService {
  constructor(@InjectRepository(Order) private readonly repo: Repository<Order>) {}

  async findOne(id: number) {
    const order = await this.repo.findOneBy({ id });
    if (!order) throw new NotFoundException(`Order ${id} not found`);
    return order;
  }
}

Dependency injection ⭐

Classes declare what they need in the constructor; Nest creates them once (singletons by default) and hands them over.

Drawing diagram…

Why it matters:

  • Testability: swap the real repository for a fake in tests.
  • Loose coupling: the controller doesn't know how the service is built.
  • One instance: shared connections and caches without global variables.

Custom providers

providers: [
  { provide: "PAYMENT_GATEWAY", useClass: process.env.NODE_ENV === "test" ? FakeGateway : StripeGateway },
  { provide: "APP_NAME", useValue: "shop-api" },
  {
    provide: "REDIS",
    useFactory: async (config: ConfigService) => createClient({ url: config.get("REDIS_URL") }).connect(),
    inject: [ConfigService], // async factory: Nest waits before starting
  },
  { provide: "LEGACY_ORDERS", useExisting: OrdersService }, // alias
]

constructor(@Inject("PAYMENT_GATEWAY") private readonly payments: PaymentGateway) {}

Injection scopes

ScopeNew instanceUse when
DEFAULT (singleton)Once per appAlmost always
REQUESTPer HTTP requestPer-request data (tenant, user); slower, and it "bubbles up" to everything that injects it
TRANSIENTPer consumerEach injector needs its own instance (e.g. a logger with context)

Circular dependencies (A needs B, B needs A) usually mean the responsibilities are wrong; extract the shared part into a third provider. As a last resort, use forwardRef(() => B).

The request lifecycle

Knowing the order is the #1 NestJS interview topic.

Drawing diagram…
StepKnows the route handler?Typical use
Middleware❌Request ID, raw logging, cookies
Guard✅ (ExecutionContext)Authentication, roles
Interceptor✅Timing, caching, response mapping, timeouts
Pipe✅ (per argument)Validation, type conversion
Filter✅Error → HTTP response format

Guard: who may enter

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.get<string[]>("roles", ctx.getHandler());
    if (!required) return true;
    const { user } = ctx.switchToHttp().getRequest();
    return required.some((role) => user?.roles.includes(role));
  }
}

export const Roles = (...roles: string[]) => SetMetadata("roles", roles);

@Delete(":id")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("admin")
remove(@Param("id", ParseIntPipe) id: number) { … }

Pipe: validated DTOs

export class CreateOrderDto {
  @IsString() @IsNotEmpty() productId!: string;
  @IsInt() @Min(1) @Max(100) quantity!: number;
}

// main.ts — validate every body, strip unknown fields, convert types
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));

Interceptor: wrap the handler

@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const start = Date.now();
    return next.handle().pipe(                           // an RxJS Observable
      tap(() => console.log(`${ctx.getHandler().name} took ${Date.now() - start}ms`)),
    );
  }
}

Exception filter: one error format

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;
    res.status(status).json({
      statusCode: status,
      message: status === 500 ? "Internal server error" : (exception as HttpException).message,
      timestamp: new Date().toISOString(),
    });
  }
}

Dynamic modules and lifecycle hooks

@Module({})
export class MailModule {
  static forRoot(options: { apiKey: string }): DynamicModule {
    return {
      module: MailModule,
      providers: [{ provide: "MAIL_OPTIONS", useValue: options }, MailService],
      exports: [MailService],
    };
  }
}
HookRuns when
onModuleInitThe module's providers are created
onApplicationBootstrapAll modules initialised, before listening
onModuleDestroy / beforeApplicationShutdown / onApplicationShutdownOn shutdown (call app.enableShutdownHooks())

Testing with the DI container

describe("OrdersService", () => {
  let service: OrdersService;
  const repo = { findOneBy: jest.fn() };

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [OrdersService, { provide: getRepositoryToken(Order), useValue: repo }],
    }).compile();
    service = moduleRef.get(OrdersService);
  });

  it("throws NotFound for a missing order", async () => {
    repo.findOneBy.mockResolvedValue(null);
    await expect(service.findOne(1)).rejects.toThrow(NotFoundException);
  });
});

Express or NestJS?

Choose Express when…Choose NestJS when…
A small service or a few endpointsA large app with many modules and developers
You want full control over structureYou want conventions everyone follows
Minimal dependenciesYou need DI, guards, validation, OpenAPI, microservice transports out of the box

Key takeaways

  • Nest = modules (boundaries) + controllers (HTTP) + providers (logic) wired by dependency injection.
  • Providers are private to their module until exported; singletons by default.
  • Lifecycle: middleware → guards → interceptors → pipes → handler → interceptors → filters on error.
  • Use a global ValidationPipe with DTOs, guards for auth/roles, and a filter for one consistent error format.
  • DI makes testing easy: Test.createTestingModule with fake providers.