Skip to content
ExponaBLOG
NESTJS · DAY 099 / 100

Advice for Node devs moving to NestJS: a practical guide

Things I wish I knew on day one.

Expona Admin7 min read

I've watched dozens of Node developers pick up NestJS and stumble on the same rocks I did. Most of the pain comes not from NestJS being difficult, but from approaching it like Express or Fastify. Here's what I wish someone had told me clearly on day one: advice for Node devs moving to NestJS starts with understanding that it's not a lightweight framework bolted on top of Node—it's a complete architectural philosophy.

Learn DI and Modules Before Decorator Magic

When you first open a NestJS project, you'll see decorators everywhere: @Controller, @Injectable, @UseGuards. They're beautiful syntax, but they obscure the real engine underneath: dependency injection and module organization.

I spent my first week thinking decorators were the "NestJS thing" I needed to master. Wrong. The decorators are just sugar on top of a solid DI container. If you don't understand how the container resolves dependencies, you'll debug circular dependencies, scope issues, and mysterious "undefined service" errors for hours.

Start here: create a service, inject it into a controller, and trace through what actually happens. Don't use dynamic modules or complex patterns yet. Understand that when you write:

typescript
@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}
}

NestJS's built-in DI container is scanning your module metadata, finding UsersService (which should be decorated with @Injectable), creating an instance, and injecting it into the controller. That's the foundation. Everything else builds on it.

Next, learn modules properly. Every service lives in a module. Modules declare their providers and exports. If you need a service in another module, you import that module and the service must be exported. This feels rigid compared to Node's casual require-whatever-you-want approach, but it forces clarity at scale. On a 5-service project, it's noise. On a 50-service codebase across 8 teams, it's your lifeline.

Real talk: spend a full day reading the DI and modules documentation without writing code. Then write code. This order matters.

Read the Request Lifecycle Docs Twice

NestJS has a request lifecycle. Request comes in, goes through middleware, guards, pipes, and eventually hits your controller. Then it flows back out through interceptors and exception filters. If you don't know this flow, you'll place logic in the wrong place repeatedly.

I learned this the hard way. I put validation logic in an interceptor because it felt generic. Wrong spot. Interceptors see the response, not the raw request body. Then I tried to throw an exception from a pipe to a guard. Didn't work as expected because they live in different layers. Finally I read the docs twice (not exaggerating) and stopped fighting the structure.

Here's a mental model that helped me: think of the request lifecycle as a series of gates. A middleware is a gate that can inspect, log, or reject the entire request early. A guard is a gate that checks authorization. A pipe is a gate that transforms and validates specific parameters or body data. Your controller receives cleaned, validated data. An interceptor wraps the entire handler, useful for timing, caching, or transforming responses. An exception filter catches errors and formats the response.

Concrete example: you want to validate a UUID in the URL parameter and return a 400 if it's invalid.

typescript
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { validate as uuidValidate } from 'uuid';

@Injectable()
export class UuidValidationPipe implements PipeTransform {
  transform(value: string) {
    if (!uuidValidate(value)) {
      throw new BadRequestException('Invalid UUID');
    }
    return value;
  }
}

@Controller('users')
export class UsersController {
  @Get(':id')
  getUser(@Param('id', UuidValidationPipe) id: string) {
    return { id };
  }
}

This is the right place because the pipe runs before the handler, and if validation fails, the request stops there. Put this logic in a controller method? You're doing defensive coding in every handler. Put it in middleware? You're checking every route, not just this one. Pipes nail the semantics.

Read the lifecycle docs. Then re-read them. Bookmark them. Your future self will thank you.

Do Not Fight the Framework Structure

Here's a temptation I had: "NestJS is just Express with DI. I can organize my code however I want." I organized handlers in one directory, services in another, created a utils file at the root, and thought I was being smart.

Three months later, my codebase was a mess. New team members couldn't find anything. I had circular dependencies I didn't notice until runtime. Guards were scattered everywhere.

NestJS's opinionated structure exists for a reason. Follow it:

  • One feature = one module = one directory
  • Controllers handle HTTP in that module
  • Services contain business logic
  • DTOs define request/response shapes
  • Entities (or your domain models) live there too
  • Cross-cutting concerns (auth, logging, error handling) live in top-level providers or shared modules

For a user management feature:

typescript
src/
  users/
    controllers/
      users.controller.ts
    services/
      users.service.ts
    dto/
      create-user.dto.ts
      update-user.dto.ts
    entities/
      user.entity.ts
    users.module.ts

This structure scales. When someone onboards, they immediately know where to look. When you need to modify user creation, you know exactly which three files to touch. When you deprecate the users feature, you delete one directory. Done.

Does this feel like more structure than Express? Yes. Is that a flaw? No. It's a feature. Structure at scale saves hundreds of hours of refactoring and debugging.

Keep Services Framework-Agnostic Where Possible

A common anti-pattern: your service methods know they're in NestJS. They return NestJS exceptions, they receive decorators in their signatures, they depend on NestJS utilities.

typescript
// Bad: service is tightly coupled to NestJS
@Injectable()
export class UsersService {
  async getUser(id: string) {
    if (!user) {
      throw new NotFoundException('User not found');
    }
    return user;
  }
}

Why is this bad? Your service can't be unit tested easily outside NestJS. You can't reuse it in a CLI tool or a background job runner. You can't port your business logic to another framework without refactoring. It's brittle.

Better approach:

typescript
// Good: service throws domain errors, controller translates
export class UserNotFoundError extends Error {
  constructor(id: string) {
    super(`User ${id} not found`);
    this.name = 'UserNotFoundError';
  }
}

export class UsersService {
  async getUser(id: string) {
    const user = await this.database.users.findOne(id);
    if (!user) {
      throw new UserNotFoundError(id);
    }
    return user;
  }
}

@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    try {
      return await this.usersService.getUser(id);
    } catch (error) {
      if (error instanceof UserNotFoundError) {
        throw new NotFoundException(error.message);
      }
      throw error;
    }
  }
}

Now your service is framework-agnostic. It can be tested with just Node and a mock database. It can be called from a CLI, a job queue, or another framework. Your controller translates domain errors into HTTP responses. This separation of concerns scales beautifully.

Common Mistakes

I see three patterns over and over in junior NestJS codebases:

  • Circular dependencies: Module A imports Module B, Module B imports Module A. This happens when you don't think through your module boundaries. Use shared modules for cross-cutting concerns instead of circular imports.
  • Injection in static methods: You can't inject into static methods. If you find yourself trying, it's a code smell. Make it an instance method.
  • Services that do too much: A service should have one reason to change. If your UsersService has database logic, email logic, and cache logic, split it into three smaller services and compose them in a facade or use case.

Key Takeaways

  • Learn dependency injection and modules before you play with decorators. Decorators are syntax; DI is the engine.
  • Read the request lifecycle documentation carefully. Placement matters—use guards for authorization, pipes for validation, interceptors for cross-cutting concerns.
  • Embrace the opinionated structure. It's not a restriction; it's a scaling tool. One feature per module, one purpose per file.
  • Keep services framework-agnostic by throwing domain errors and letting controllers translate them. This makes your code portable and testable.
  • When things feel complicated, you're probably fighting the framework. Step back and read the docs again. The answer is usually there, and you missed it the first time (I certainly did).

I'm at day 99 of this NestJS journey, and these four principles have shaped every serious codebase I've built with it. They're not advanced. They're foundational. Get them right early, and everything that comes next—custom decorators, dynamic modules, complex middleware chains—becomes intuitive.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles