Skip to content

NestJS Architecture Overview

Aman Kumar Singh7 min read
Part 1 of 40From the NestJS Production Guide series
NestJS Architecture Overview — article by Aman Kumar Singh

This is the start of a new series: the NestJS Production Guide. Where the Full Stack SaaS Masterclass walked through wiring up a NestJS app inside a monorepo, this series goes deeper into the framework itself, module boundaries, dependency injection, guards and pipes, authentication, persistence, background work, and the testing and deployment concerns that show up once an API is carrying real traffic.

Before any of that, it helps to understand why NestJS is built the way it is. A lot of engineers coming from Express treat Nest as "Express with decorators" and get surprised later when the framework's opinions start pushing back on how they structured something. The opinions are the point. NestJS borrows its architecture from Angular on purpose, and once you see why, most of the framework's behavior stops feeling like magic and starts feeling like a consistent set of rules.

This article covers the shape of a NestJS application: how a request actually moves through the framework, why modules are the unit everything else hangs off, and the tradeoffs that come with an opinionated, decorator-driven structure. Later articles in the series go deep on each piece; this one is the map.

Why NestJS looks the way it does

Express gives you a router and lets you build whatever structure you want on top of it. That flexibility is genuinely useful for small services, and it's exactly what causes pain at scale: every team ends up inventing its own conventions for where middleware lives, how services get instantiated, and how a request handler finds its dependencies. Two Express codebases at the same company can look nothing alike.

NestJS trades that flexibility for consistency. It layers a full application architecture on top of Express (or Fastify) that borrows Angular's ideas almost directly: modules that group related code, a dependency injection container that manages object lifetimes, and decorators that attach metadata to classes so the framework knows how to wire them together. If you've worked in Angular, Java Spring, or .NET, the shape is familiar. If you've only worked in Express or Fastify directly, it takes a few weeks to stop fighting it.

The payoff shows up on any team bigger than one person. A new engineer who understands modules and DI can navigate an unfamiliar NestJS codebase reasonably fast, because the code has to follow the same shape everywhere. Controllers only handle HTTP. Services only hold business logic. Modules only group cohesive functionality. Nest enforces enough of this through its architecture that "just make it work" code has fewer places to hide.

The cost is a real learning curve and a layer of indirection between your code and the request. Decorators, reflection metadata, and the DI container mean more of what happens is implicit rather than explicit in the call stack. Debugging a dependency that failed to resolve is a different exercise than debugging a plain function call. That's a fair tradeoff for a production API maintained by a team; it's overkill for a weekend script.

The request lifecycle, in order

Every incoming request passes through a fixed pipeline before it reaches your handler, and again on the way back out. Getting this order straight matters because it tells you exactly where to put cross-cutting logic: authentication, logging, validation, and error shaping all have a specific, correct place in this chain.

Incoming request
  → Middleware              (raw request/response, no NestJS context yet)
  → Guards                  (can this request proceed? auth, roles)
  → Interceptors (before)   (wrap the handler, run before it)
  → Pipes                   (validate and transform arguments)
  → Route handler           (your controller method)
  → Interceptors (after)    (wrap the response, run after the handler)
  → Exception filters       (catch anything thrown along the way)
Outgoing response

Middleware runs first and closest to the raw Express or Fastify request; it's the right place for things like request ID generation or low-level logging that don't need Nest's execution context. Guards run next and answer a yes/no question: should this request be allowed to continue at all. That's why authentication and role checks belong in guards rather than in the controller body; a rejected request should never reach your business logic.

Interceptors wrap the handler on both sides, which makes them the tool for anything that needs to run before and after: timing a request, transforming a response shape, or caching a result. Pipes run just before the handler and are responsible for validating and transforming the arguments it receives, which is why ValidationPipe combined with DTOs lives here rather than as a manual check inside every controller method. Exception filters sit at the end of the chain and catch anything thrown anywhere earlier in the pipeline, giving you one place to shape error responses consistently instead of a try/catch in every handler.

// src/modules/orders/orders.controller.ts
import { Controller, Get, Param, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '../../common/guards/auth.guard';
import { LoggingInterceptor } from '../../common/interceptors/logging.interceptor';
import { OrdersService } from './orders.service';

@Controller('orders')
@UseGuards(AuthGuard)
@UseInterceptors(LoggingInterceptor)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Get(':id')
  getOne(@Param('id') id: string) {
    return this.orders.findById(id);
  }
}

This is declarative wiring rather than business logic. The AuthGuard decides whether the request proceeds, the LoggingInterceptor wraps the call for timing, and the controller method itself stays a thin pass-through to the service. That thinness is intentional and worth protecting: controllers should describe routes and delegate, not accumulate logic.

Modules as the organizing unit

Everything in a Nest application ultimately belongs to a module. A module declares its controllers, its providers (services, repositories, anything injectable), and what it imports from other modules or exports for them to use. This is the piece that most directly replaces the ad hoc structure teams invent on top of Express, and it's substantial enough that the next article in this series covers it on its own. For now, the takeaway is simpler: a module boundary should map to a real domain in your product (users, billing, orders), not to a technical layer (controllers, services) or a single file. Get that mapping right early and the rest of the framework's structure follows naturally.

Choosing an HTTP adapter: Express or Fastify

NestJS doesn't implement its own HTTP server. It sits on top of an adapter, and by default that adapter is Express. You can switch to Fastify with one line in main.ts:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(3000, '0.0.0.0');
}
bootstrap();

Express has the larger ecosystem: more third-party middleware, more examples, and it's what most engineers already know, which matters when you're onboarding people or debugging an obscure issue at 11pm. Fastify is generally lighter weight and faster at the raw HTTP layer, which can matter if your service is doing enough request volume that the framework overhead itself becomes measurable next to your actual work per request.

In practice, this decision matters far less than people assume it will before they've built anything. For most SaaS APIs, database queries, external calls, and serialization dominate request time, and the adapter choice is noise next to those. Default to Express unless you have a specific, already-identified reason to switch; don't spend a planning meeting on it before you've written a single endpoint.

Where the structure earns its keep, and where it doesn't

The architecture pays off once an application has enough surface area that consistency across the team matters more than any individual developer's preferred style. A five-person team building a SaaS backend benefits enormously when every module looks the same. Guards become the only place auth logic lives. DTOs become the only place validation rules live. New hires ramp up faster because the framework has already made the structural decisions for them.

The common production mistake is applying that same rigor before it's earned. Teams sometimes reach for CQRS, custom decorators, or a module per tiny concern on day one, because the framework documentation shows those patterns and they look sophisticated. That's solving a problem you don't have yet. It costs real velocity you need right now. Start with plain modules, controllers, services, and the built-in pipeline described above. Reach for the more elaborate patterns later in this series once a specific module has grown complex enough to justify them.

The other pitfall worth flagging early: request-scoped providers. Nest lets you scope a provider to each incoming request instead of instantiating it once for the whole app's lifetime, which is sometimes necessary for things like per-request tenant context. It also means Nest has to rebuild that provider's dependency graph on every single request, which has a real cost if applied broadly. Default providers to singleton scope, and only mark something request-scoped when you have an actual reason, not as a defensive habit.

Key takeaways

  • NestJS deliberately borrows Angular's architecture: modules, decorators, and dependency injection, trading Express's flexibility for consistency across a team and a codebase.
  • Every request passes through a fixed order: middleware, guards, interceptors, pipes, the handler, then interceptors again and exception filters. Put cross-cutting logic in the stage that matches what it needs to do.
  • Modules are the real unit of architecture. Map them to product domains, not technical layers; the next article in this series covers this in depth.
  • The Express vs Fastify adapter choice matters less than most teams assume; default to Express unless you have a concrete reason to switch.
  • Apply Nest's more advanced patterns, CQRS, custom decorators, request-scoped providers, only when a real problem justifies them, not as a starting default.

Frequently asked questions

Is NestJS just Express with extra syntax?

No. NestJS runs on top of Express or Fastify as an HTTP adapter, but it adds a full application architecture: modules, a dependency injection container, and a fixed request pipeline of guards, interceptors, pipes, and exception filters. The HTTP layer is a small part of what the framework provides.

Where should authentication logic live in a NestJS app?

In a guard. Guards run before the route handler and answer whether the request is allowed to proceed at all, which is exactly the semantic authentication needs. Putting auth checks inside the controller or service means a rejected request has already done unnecessary work before being turned away.

What's the difference between an interceptor and a pipe?

Pipes validate and transform the arguments passed into a handler, and they run once, right before the handler executes. Interceptors wrap the handler on both sides, so they can run logic both before and after it, which makes them suited to things like timing, response transformation, or caching that need to see both the request and the response.

Should I choose Fastify over Express for a new NestJS project?

Only if you already have a concrete reason, such as measured HTTP-layer overhead at meaningful request volume. For most SaaS APIs, database and network calls dominate response time far more than the adapter does, so the choice rarely matters as much as it seems to during initial setup.

What's the actual cost of request-scoped providers in NestJS?

Nest has to rebuild a request-scoped provider's dependency graph for every incoming request instead of once at startup. That's a real, ongoing cost across your whole application if applied broadly. Reserve request scope for cases that genuinely need per-request state, such as tenant context, and default everything else to singleton scope.

Related articles

Security Best Practices — Aman Kumar Singh
API Documentation with Swagger — Aman Kumar Singh
Rate Limiting and Throttling — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.