Engineering Notes
Home/Backend/Frameworks/NestJS Fundamentals
Backend · Frameworks

NestJS Fundamentals

NestJS adds modules, dependency injection and a structured request pipeline on top of Express or Fastify. Learn the building blocks, then follow requests through middleware, guards, interceptors, pipes, the handler and exception filters.

⚙️ Backend● Intermediate⏱ 9 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Nest on top of Express/Fastify
  • Modules, controllers and providers
  • Decorators as metadata
  • The module graph, DI container and request pipeline
Part 1NestJS Fundamentals (Part 1)

1NestJS Is Built On Top of Express (or Fastify)

NestJS is not a replacement for Express — by default, a NestJS app runs on an Express instance underneath (Fastify is a drop-in alternative adapter). What NestJS adds is an opinionated architectural layer on top: modules, controllers, and providers, wired together with decorators and a built-in dependency-injection container. Where 01-express_fundamentals/ showed routes registered imperatively (app.get(path, handler)), NestJS expresses the same routing declaratively via decorators on classes.

ts
import { Controller, Get } from "@nestjs/common";

@Controller("users")
class UsersController {
  @Get()
  findAll() {
    return [{ id: "1", name: "Ana" }];
  }
}

Under the hood, when the app bootstraps, NestJS reads these decorators via reflection and registers the equivalent of app.get("/users", ...) on the underlying Express instance — the decorators are metadata, not magic; something still has to translate @Controller("users") + @Get() into an actual route registration, and that something is NestJS's own bootstrap process.

2The Three Core Building Blocks

text
Module      -- groups related controllers + providers into a cohesive unit
Controller  -- handles incoming requests, delegates to providers, shapes the response
Provider    -- (most commonly a "service") holds business logic / data access,
               injectable into controllers or other providers
ts
import { Injectable, Controller, Get, Module } from "@nestjs/common";

@Injectable()
class UsersService {
  findAll() {
    return [{ id: "1", name: "Ana" }];
  }
}

@Controller("users")
class UsersController {
  constructor(private readonly usersService: UsersService) {} // injected, not `new`'d

  @Get()
  findAll() {
    return this.usersService.findAll(); // controller delegates, doesn't hold logic itself
  }
}

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

This is the thin-controller principle from 01-express_fundamentals/ Common Pitfall 5, now enforced by the framework's own structure rather than left as a convention to remember — UsersController never touches data directly, it asks UsersService for it. The dedicated 10-services_and_repositories/ topic in this group covers the service/repository split in depth; NestJS's provider system is what makes that split mechanically easy to wire up.

3Decorators Are Metadata, Not Behavior

@Controller("users"), @Get(), @Injectable() don't themselves do anything at the moment the class is defined — they attach metadata to the class (via reflect-metadata under the hood) that NestJS's bootstrap process reads later, when building the actual application. This is why a UsersController class with no @Module ever referencing it is simply never wired up — the decorators alone don't register anything; only being listed in a @Module({ controllers: [...] }) array makes NestJS's bootstrap discover and instantiate it.

4@Module() as the Unit of Organization

A real NestJS app is a tree of modules, each @Module({ imports, controllers, providers, exports }):

  • controllers — request handlers this module owns
  • providers — injectable classes this module owns (and makes available to its own controllers/providers)
  • imports — other modules whose exported providers this module wants to use
  • exports — which of this module's own providers other modules are allowed to import
ts
@Module({
  imports: [DatabaseModule], // to use DatabaseModule's exported providers
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // so OTHER modules can inject UsersService too
})
class UsersModule {}

A provider not listed in exports is private to its own module — this is a deliberate encapsulation boundary, not an oversight; it's how a large NestJS app avoids every service being implicitly globally available.

5Route Parameters and the Request Object

ts
import { Controller, Get, Param, Query, Body, Post } from "@nestjs/common";

@Controller("users")
class UsersController {
  @Get(":id")
  findOne(@Param("id") id: string) { // equivalent to req.params.id in Express
    return { id };
  }

  @Get()
  findAll(@Query("limit") limit?: string) { // equivalent to req.query.limit
    return { limit };
  }

  @Post()
  create(@Body() body: { name: string }) { // equivalent to req.body
    return { created: body.name };
  }
}

Every one of these decorators is a thin, typed wrapper around exactly the same req.params/req.query/req.body covered in 01-express_fundamentals/ sections 3 and 5 — NestJS is not inventing a new request model, it's giving you a more structured, dependency-injectable way to pull the same data out of the same underlying Express request object.

⚠️ Common Pitfalls

  1. Forgetting to register a controller or provider in its @Module. The decorators alone don't wire anything up — an unregistered @Controller class is simply never instantiated, silently, with no error.
  2. Forgetting exports on a provider another module needs. imports: [SomeModule] only gives you access to what SomeModule explicitly exports — importing the module doesn't expose everything inside it.
  3. Putting business logic directly in a controller method instead of delegating to an injected service — defeats the entire point of the controller/provider split (section 2).
  4. Assuming decorators run application logic at class-definition time. They only attach metadata; nothing happens until NestJS's bootstrap reads it (section 3) — a common source of confusion when debugging why "nothing happened" even though the decorator is clearly present in the code.

📋 Quick Reference

text
@Module({ controllers, providers, imports, exports })  -- organizational unit
@Controller(prefix)  -- class handles requests under /prefix
@Get()/@Post()/@Put()/@Delete()(path?)  -- method-level route registration
@Injectable()  -- marks a class as available for dependency injection
@Param("x") / @Query("x") / @Body()  -- typed wrappers around req.params/query/body
constructor(private readonly svc: SomeService) {}  -- DI: NestJS instantiates and injects it
exports: [...]  -- only these providers are usable by OTHER modules that import this one
Part 2NestJS Fundamentals (Part 2)

1The Module Dependency Graph

Every NestJS app has exactly one root module (conventionally AppModule), and every other module reaches the app by being imports'd, directly or transitively, from that root — the whole app is a directed graph of modules, and it must be acyclic in the sense that NestJS resolves it at bootstrap into a concrete instantiation order.

text
AppModule
 ├── imports: [UsersModule, OrdersModule]
 │              │              │
 │        exports: [UsersService]   exports: [OrdersService]
 │
 └── OrdersModule also imports UsersModule (to look up a user when creating an order)

When OrdersModule does imports: [UsersModule], it gains access to whatever UsersModule put in its own exports array — nothing more. This is the same encapsulation boundary from Part 1 section 4, now shown as the mechanism that keeps a large module graph from turning into "everything can see everything."

2Providers and NestJS's Built-In DI Container

@Injectable() classes are managed by NestJS's dependency-injection container, not instantiated with new by your own code. When UsersController's constructor declares private readonly usersService: UsersService, NestJS's container:

  1. Sees UsersController needs a UsersService.
  2. Checks whether it already has an instance of UsersService (by default, providers are singletons — one instance per application, reused everywhere it's injected).
  3. If not, instantiates UsersService itself, recursively resolving its constructor dependencies the same way, then hands the instance to UsersController's constructor.
ts
@Injectable()
class UsersService {
  constructor(private readonly db: DatabaseService) {} // DI resolves THIS too, recursively
}

This recursive resolution is why forgetting to register a provider (Part 1 Common Pitfall 1) or forgetting to export one (Common Pitfall 2) surfaces as a bootstrap-time error (Nest can't resolve dependencies of ...) rather than a runtime crash later — the entire dependency graph is validated once, at startup, before the app starts accepting requests. The dedicated 09-dependency_injection/ topic in this group covers DI as a general pattern (including manual/constructor injection outside any framework); this section is specifically about NestJS's own container implementing that pattern.

A module declares controllers, providers, imports and exports; the DI container reads constructor types and builds singleton instances in dependency order
A module declares controllers, providers, imports and exports; the DI container reads constructor types and builds singleton instances in dependency order

3The Request Pipeline: Middleware, Guards, Pipes, Interceptors

A request passing through a NestJS route doesn't go straight from "matched" to "handler runs" — it passes through several distinct extension points, each with a specific job, run in this order:

text
Request
  → Middleware        (same concept as Express middleware — 06-middleware/ covers this generally)
  → Guards            (authorization: CAN this request proceed at all? true/false)
  → Interceptors (pre) (wrap the handler: transform input, add logging/timing)
  → Pipes              (validation/transformation of individual arguments — @Body(), @Param(), etc.)
  → Route Handler       (your @Get()/@Post() method itself)
  → Interceptors (post) (transform the handler's return value before it's serialized)
  → Exception Filters    (catch anything thrown anywhere in this pipeline, shape the error response)
ts
import { UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";

@Controller("users")
@UseGuards(AuthGuard) // every route in this controller requires AuthGuard to return true
class UsersController {
  @Post()
  @UsePipes(new ValidationPipe()) // validates @Body() against a DTO's decorators before this runs
  create(@Body() dto: CreateUserDto) {
    // by the time this line runs, dto has already passed validation
  }
}

Each stage exists precisely because a single Express middleware function trying to do "auth check, then validate this specific argument, then transform the return value" would tangle three unrelated concerns into one function — NestJS splits them into named, purpose-specific extension points instead. Guards, Pipes, and Interceptors are each covered in more depth by their own relevant topics elsewhere in this curriculum (validation/DTOs, authentication/authorization, exception handling) — this section's job is just to show where each one sits in the overall pipeline.

4Why This Structure Exists at All

A small Express app (01-express_fundamentals/) with a handful of routes doesn't need any of this — a few app.get() calls and a couple of middleware functions are perfectly manageable by hand. NestJS's entire value proposition shows up at a different scale: once an app has dozens of controllers, shared validation logic, shared auth logic, and multiple teams touching the same codebase, an unenforced convention ("controllers should be thin", "always validate input") tends to erode — someone, somewhere, puts business logic in a controller because it was faster that day. NestJS's module/controller/provider/pipe/guard structure makes several of those conventions mechanically difficult to violate rather than merely documented as a best practice.

⚠️ Common Pitfalls

  1. Manually new-ing a provider instead of injecting it. Bypasses the DI container entirely — you get a fresh, un-managed instance disconnected from the rest of the app's singleton graph, silently defeating dependency injection.
  2. Circular module imports (ModuleA imports ModuleB which imports ModuleA) — NestJS can resolve some circular cases with forwardRef(), but an unhandled cycle fails at bootstrap; the fix is usually to extract the shared piece into a third module both import, not to force the cycle through.
  3. Putting validation logic inside the route handler instead of a Pipe, or authorization logic inside the handler instead of a Guard — works, but scatters cross-cutting concerns across every handler instead of centralizing them in the pipeline stage built for that job.
  4. Assuming providers are NOT singletons by default. They are, unless explicitly scoped otherwise (REQUEST-scoped, TRANSIENT) — code that assumes fresh instance-per-request state in a default-scoped provider will leak state across unrelated requests.

📋 Quick Reference

text
Root module -> imports -> ... -> every other module reachable in the graph
DI container: providers default to SINGLETON scope, resolved recursively at bootstrap
Bootstrap-time errors: unresolved dependency graph fails FAST, before serving any request

Request pipeline order:
Middleware -> Guards -> Interceptors(pre) -> Pipes -> Handler -> Interceptors(post) -> Exception Filters

Guards    -- can this request proceed? (authorization)
Pipes      -- validate/transform individual arguments (@Body/@Param/@Query)
Interceptors -- wrap the handler call itself (logging, timing, response transformation)

🧠 Key takeaways

  • Nest runs on Express or Fastify and adds modules, DI and a request pipeline.
  • Building blocks: modules (organization), controllers (routes), providers (injectable services).
  • Decorators attach metadata; Nest reads it to build routes, DI and validation.
  • Pipeline: middleware → guards → interceptors → pipes → handler → interceptors → (filters on error).

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What are NestJS's core building blocks?core▾

Modules (organize the app and define DI scope), controllers (handle routes), providers/services (business logic, injected via DI), plus pipes (validation/transformation), guards (authorization), interceptors (cross-cutting logic around handlers), filters (exception handling) and middleware.

Follow-ups interviewers ask
  • Inspired by? Angular's architecture.
Q2What's the order of the NestJS request pipeline?core▾

Middleware → guards → interceptors (before) → pipes → controller handler → interceptors (after) → exception filters (if errors occur).

Follow-ups interviewers ask
  • Where does authentication go? Guards (often with Passport strategies).
Q3How does dependency injection work in NestJS?core▾

Classes decorated with @Injectable() are registered as providers in a module; Nest resolves constructor parameters by type token (TypeScript metadata). Scopes: singleton (default), request, transient. Modules export providers to share them.

Follow-ups interviewers ask
  • Risk of request-scoped providers? They make the whole dependency chain request-scoped, hurting performance.
Q4How do you validate DTOs in NestJS?core▾

ValidationPipe with class-validator decorators on DTO classes, whitelist: true to strip unknown properties, forbidNonWhitelisted to reject them, and transform: true to convert to class instances.

Follow-ups interviewers ask
  • Why whitelist? Prevents mass assignment of unexpected fields.
Q5How do you structure a large NestJS app?senior▾

Feature modules per domain, a shared/core module for cross-cutting services, configuration via @nestjs/config, repositories abstracted behind providers, and clear module boundaries (possibly a modular monolith).

Follow-ups interviewers ask
  • How do you avoid circular dependencies? Clarify boundaries, use events, or forwardRef as a last resort.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Which component decides whether a request may reach the handler?
Authorization.
2. ParseIntPipe and ValidationPipe are…
Argument-level.
3. Measuring handler duration and mapping responses fits…
Wrap around the handler.
4. Providers are available to other modules when…
Explicit module graph.