DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning NestJS As If You Built It Yourself

If you have ever built a Node.js app with plain Express, you know the moment. The first three routes feel great. By route thirty, you have a routes/ folder, a middlewares/ folder, a utils/ folder, and one giant app.js where everyone wires things up however they feel like that day. There is no rulebook, so every developer invents one.

That is fine for a weekend project. For a real backend with a team, it gets painful. You want structure that you do not have to invent, you want testability without bending over backwards, and you want your code to look the same whether it was written today or a year ago.

That is the gap NestJS fills.

What is NestJS, really

Think of NestJS as Express (or Fastify, your pick) with a wise older sibling sitting next to it. The sibling says "actually, put your HTTP code over here, your business logic over there, your validation right at the door, and ask me when you need something instead of building it yourself".

It is opinionated on purpose. Once you learn the pattern once, every NestJS app on the planet starts to feel familiar.

The sibling speaks fluent TypeScript and loves decorators. That is the whole vibe.

Let's pretend we are building one

We want a Node.js framework that scales nicely with the team and the codebase. We will call it NestJS. As we design it, every decision we make is a thing you will see again and again in real code, so let's make them on purpose.

For our running example, imagine a tiny app: a little online library. Books and members. Nothing fancy.

Decision 1: Code should be organized in feature boxes

We will call these boxes modules. A module is a folder with everything one feature needs: its routes, its logic, its data shapes, its tests. If you delete the folder, the feature is gone, and nothing else breaks.

A module is just a class with a decorator on top:

// books/books.module.ts
import { Module } from "@nestjs/common";
import { BooksController } from "./books.controller";
import { BooksService } from "./books.service";

@Module({
  controllers: [BooksController],
  providers:   [BooksService],
  exports:     [BooksService],
})
export class BooksModule {}
Enter fullscreen mode Exit fullscreen mode

Read that decorator like a mini contract:

  • controllers are the doors of this feature, the things that listen for HTTP requests.
  • providers are the workers that do the actual job (services, repositories, helpers).
  • exports is what this module is willing to share with other modules. If you do not export it, it stays private.

Then a top level AppModule wires the features together:

@Module({
  imports: [BooksModule, MembersModule],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Modules give us boundaries. Boundaries give us sanity.

Tiny tip: keep one feature per folder, not one technical layer per folder. A books/ folder with controller, service, dto, and entity inside beats four parallel folders called controllers/, services/, dtos/, entities/. Modern NestJS guides all push this style.

Decision 2: HTTP stuff lives in a controller

We need something that receives requests and returns responses. We do not want HTTP code mixed into business logic, so we put it in its own class called a controller. Decorators do the routing for us.

// books/books.controller.ts
import { Controller, Get, Post, Body, Param } from "@nestjs/common";
import { BooksService } from "./books.service";
import { CreateBookDto } from "./dto/create-book.dto";

@Controller("books")
export class BooksController {
  constructor(private readonly books: BooksService) {}

  @Get()
  findAll() {
    return this.books.findAll();
  }

  @Get(":id")
  findOne(@Param("id") id: string) {
    return this.books.findOne(id);
  }

  @Post()
  create(@Body() dto: CreateBookDto) {
    return this.books.create(dto);
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing, because these patterns repeat everywhere:

  • @Controller("books") mounts every route in the class under /books.
  • @Get, @Post, @Put, @Patch, @Delete map to HTTP verbs. The argument inside is the sub path.
  • @Param, @Body, @Query, @Headers pull pieces out of the request for you. No more req.body.something, you ask for what you want directly.
  • The controller does not know what a database is, and that is the point. It just calls the service.

The controller is the door. It greets visitors and points them inside.

Decision 3: The brain lives in a service

Real work belongs in a class with no HTTP awareness, so the same logic can be reused from a CLI, a queue worker, a GraphQL resolver, or a test. We call these classes services, and we mark them with @Injectable() so our framework knows it is allowed to manage them.

// books/books.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";

@Injectable()
export class BooksService {
  private books = [
    { id: "1", title: "The Little Prince", author: "Saint-Exupery" },
  ];

  findAll() {
    return this.books;
  }

  findOne(id: string) {
    const book = this.books.find((b) => b.id === id);
    if (!book) throw new NotFoundException(`Book ${id} not found`);
    return book;
  }

  create(dto: { title: string; author: string }) {
    const book = { id: String(this.books.length + 1), ...dto };
    this.books.push(book);
    return book;
  }
}
Enter fullscreen mode Exit fullscreen mode

Services are where the boring, valuable code lives. They are also the easiest things in the universe to unit test, because they are just classes with constructor arguments.

Decision 4: Don't new your stuff, ask for it

This is the heart of NestJS, and it is the part that feels weird for about two days, then makes total sense.

Look at the controller again:

constructor(private readonly books: BooksService) {}
Enter fullscreen mode Exit fullscreen mode

We never wrote new BooksService() anywhere. We just declared "I need a BooksService", and somehow we got one. That is dependency injection, and the framework's IoC container is the thing pulling the strings.

Here is roughly what happens at boot:

  1. NestJS reads every module's providers array.
  2. For each provider, it figures out what it needs (by looking at constructor types).
  3. It builds them in the right order, hands each one its dependencies, and caches the result.
  4. By default, every provider is a singleton, one shared instance for the whole app.
  5. When a controller is created, the container wires in the services it asked for.

Why bother? Three reasons that pay off forever:

  • Testing is trivial. In a test you can hand a controller a fake service. No mocking libraries required.
  • Swapping implementations is one line. Change which class is bound to a token in the module, and the entire app uses the new one.
  • No spaghetti require graph. Modules tell the framework what they expose, and that is the only path other modules can use.

You can also bind by token instead of by class, for things like config or external clients:

@Module({
  providers: [
    {
      provide: "BOOKS_REPO",
      useFactory: (config: ConfigService) =>
        new BooksRepo(config.get("DB_URL")),
      inject: [ConfigService],
    },
  ],
})
export class BooksModule {}
Enter fullscreen mode Exit fullscreen mode

Then inside a service:

constructor(@Inject("BOOKS_REPO") private repo: BooksRepo) {}
Enter fullscreen mode Exit fullscreen mode

That same trick is how you swap a real database for an in memory fake in tests.

Decision 5: Validate at the door with DTOs

We agreed earlier that controllers are the door of the app. Doors should check who is coming in. So we want to define exactly what the body of POST /books looks like, and reject anything weird before it ever reaches the service.

We use a plain class called a DTO (Data Transfer Object), and we decorate its fields with rules from class-validator:

// books/dto/create-book.dto.ts
import { IsString, Length, IsOptional, IsInt, Min } from "class-validator";

export class CreateBookDto {
  @IsString()
  @Length(1, 120)
  title!: string;

  @IsString()
  @Length(1, 80)
  author!: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  pages?: number;
}
Enter fullscreen mode Exit fullscreen mode

Then in main.ts, we turn on the global ValidationPipe once, and the whole app is protected:

// main.ts
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,            // drop fields we did not declare
      forbidNonWhitelisted: true, // or throw if someone tries
      transform: true,            // turn the body into a real DTO instance
      transformOptions: { enableImplicitConversion: true },
    }),
  );

  await app.listen(3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

That single block of options is one of the highest leverage settings in NestJS. whitelist strips junk, forbidNonWhitelisted shouts about junk, transform gives you a typed DTO instance instead of a plain object. Most security and bug pain disappears once this is on.

For partial updates, you do not rewrite the whole DTO. NestJS gives you helpers:

import { PartialType } from "@nestjs/mapped-types";
import { CreateBookDto } from "./create-book.dto";

export class UpdateBookDto extends PartialType(CreateBookDto) {}
Enter fullscreen mode Exit fullscreen mode

Now every field is optional, but every existing rule still applies.

Decision 6: Plug in extra logic with a tiny pipeline

Real apps need cross cutting things. Logging. Auth. Caching. Error handling. We do not want any of that mess inside controllers or services, so we offered five clean places to plug in. The order they run in is fixed and worth memorizing:

incoming request
    -> Middleware
        -> Guards
            -> Interceptors (before)
                -> Pipes
                    -> Controller handler
                        -> Service
                    -> Interceptors (after / response shaping)
                -> Exception Filters (only if something throws)
outgoing response
Enter fullscreen mode Exit fullscreen mode

Each one has a job:

  • Middleware is the classic Express style function. Runs first, knows nothing about which route, good for logging, request ids, raw header tweaks.
  • Guards answer one question: "is this request allowed in?". Auth, roles, feature flags. They have access to the route metadata, so they know which controller and handler they are guarding.
  • Interceptors wrap the handler. They can run code before and after, modify the response, add caching, add timing logs. Powered by RxJS observables.
  • Pipes transform and validate inputs. ValidationPipe is one. ParseIntPipe, ParseUUIDPipe, ParseDatePipe (added in NestJS 11) are others.
  • Exception Filters catch errors and shape them into nice HTTP responses. You can have a global one for consistent error JSON.

A guard looks like this:

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    return Boolean(req.headers.authorization);
  }
}
Enter fullscreen mode Exit fullscreen mode

Apply it to one route, one controller, or globally. Same shape for the rest of the family.

If you only remember one rule: middleware does not know the route, guards do. That distinction will save you future headaches.

Decision 7: Make it fast and easy to start

NestJS 11 (the current major version) made some quality of life moves we should mention, because you will see them in fresh projects:

  • SWC is the default compiler. Cold starts and rebuilds are dramatically faster than the old tsc based dev loop. You barely notice the build step anymore.
  • Vitest is the suggested test runner in new project templates, and it pairs nicely with SWC.
  • Standalone applications via NestFactory.createApplicationContext(AppModule) give you the IoC container without an HTTP server, perfect for CLI tools, cron jobs, and serverless handlers that just want dependency injection.
  • ConsoleLogger supports JSON output out of the box, so shipping logs to your aggregator does not need a custom logger anymore.
  • IntrinsicException lets you throw errors that skip the framework's automatic logging, useful for sensitive flows.
  • ParseDatePipe validates and converts incoming date strings into real Date objects.
  • Better CSRF helpers and small but nice microservice transport improvements (unwrap() to reach the underlying client).

You do not need to use any of this on day one. But knowing these exist means you will recognize them when you see them in code or release notes.

A peek under the hood

What really happens when a request hits a NestJS app, end to end:

  1. Node receives the request through the underlying platform (Express by default, Fastify if you swapped it in).
  2. NestJS runs registered middleware in order.
  3. Guards run for the matched route. If any returns false or throws, we stop.
  4. Interceptors (before phase) wrap the handler call.
  5. Pipes transform and validate each parameter (so your @Body() dto becomes a real CreateBookDto with validations applied).
  6. The controller method runs. It calls a service, which the IoC container had already wired up.
  7. The return value flows back through the interceptor (after phase), which can map, cache, or log it.
  8. If anything threw on the way, exception filters catch it and turn it into a clean HTTP response.
  9. Node writes the response back to the client.

That entire flow is what makes NestJS feel "boring in a good way". You always know where to put new code.

Tiny tips that will save you later

  • One feature, one folder, one module. Do not pre split by layer.
  • Turn on the global ValidationPipe in main.ts on day one, with whitelist, forbidNonWhitelisted, and transform enabled.
  • Use @nestjs/config with a Zod or Joi schema so missing env vars fail at boot, not at 3am in production.
  • Keep controllers thin. If a controller method is more than a few lines, push the logic into the service.
  • Inject by class when you can, by token when you must. Tokens are great for swapping implementations and for things you cannot easily type as a class.
  • Default scope is singleton. Only opt into request scope when you genuinely need per request state, since it is more expensive.
  • Use nest g generators (nest g module books, nest g resource books) early on. They scaffold the boilerplate you would have typed anyway, in the canonical shape.
  • Test services as plain classes. Test controllers with a fake service. Reach for Test.createTestingModule only when you actually need the DI graph.

Wrapping up

So that is the whole story. We wanted Express scale to stop being painful. We built a framework that gives every app the same shape:

Module
  -> Controller (the door, decorated routes)
    -> Service (the brain, plain TypeScript)
      -> whatever it needs (repos, clients, configs)
Enter fullscreen mode Exit fullscreen mode

Then we surrounded that core with a tidy pipeline: middleware, guards, interceptors, pipes, filters, each with one job. We let the IoC container wire everything together, so you never type new and never wonder where a dependency came from. We made validation a one liner at the boundary. And in version 11, we made the whole thing faster to start and lighter to ship.

Once that shape clicks, NestJS stops feeling like a framework you are learning, and starts feeling like a project layout you can fall back on, no matter how big the codebase grows.

Happy building, and put a nice book on the shelf for me.

Top comments (0)