open atlas
↑ Back to track
NestJS, zero to senior NEST · 00 · 01

Why NestJS, and the DI mental model

NestJS adds structure to a bare Node backend: modules, controllers, providers — and dependency injection. You declare what a class needs in its constructor; the IoC container builds and injects it, so wiring is central and code stays testable.

NEST Junior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

Three months into a bare Express app, the routes/ folder has 40 files and every handler reaches out to a global db and new MailService() near the top. To test one controller you have to spin up a real database, because the controller hard-codes how it gets its dependencies. Adding caching means editing twelve files that all new-ed the same service. Nothing is wrong, exactly — there’s just no structure, and no seam to swap anything out. NestJS exists to give that app a spine.

What Nest is, and the problem it solves

Express and Fastify are deliberately minimal: they route HTTP requests to functions and stop there. That’s perfect for a 200-line service and painful for a 20,000-line one. Without a convention, every team invents its own — where business logic lives, how a handler gets a database client, how features are grouped — and the answers drift file by file. The result is the tangle from the hook: logic glued to globals, dependencies created with new wherever they’re needed, and no clean place to substitute a fake in a test.

NestJS is an opinionated framework that sits on top of Node (it uses Express by default and can run on Fastify instead). It doesn’t replace HTTP handling; it organises it. It gives you three primary building blocks and one mechanism that ties them together:

  • Modules — feature-scoped containers (@Module({...})) that group related code and declare what they expose and what they need.
  • Controllers — classes whose methods handle incoming requests and shape the response. They contain routing, not business logic.
  • Providers — anything injectable: services, repositories, factories, clients. This is where business logic lives, marked with @Injectable().

Together, these three building blocks give every feature a consistent home: a module owns it, a controller exposes it over HTTP, and a provider does the work. When you add a new feature, you know exactly where each piece lives — and more importantly, so does the next engineer who opens the project.

The mechanism is dependency injection (DI), and it is the part worth internalising on day one — everything else in Nest is built on it.

The DI mental model: declare what you need, don’t build it

Here is the whole idea in one sentence: a class lists the things it needs in its constructor, and something else is responsible for providing them. You stop writing new for your dependencies.

Without DI, a controller reaches out and constructs its own collaborators:

// Manual wiring — the controller decides HOW it gets its dependencies
class UsersController {
  private service: UsersService;
  constructor() {
    const db = new Database(process.env.DB_URL);
    this.service = new UsersService(db); // hard-coded, can't substitute
  }
}

The controller is now welded to a concrete UsersService and a real Database. To test it you must provide a real database; to change how UsersService is built you edit every place that constructed one.

With Nest’s DI, the controller simply declares the dependency as a constructor parameter. It never says how the service is made:

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

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

@Controller("users")
class UsersController {
  // Declared, not constructed. Nest supplies the instance.
  constructor(private readonly users: UsersService) {}

  @Get()
  list() {
    return this.users.findAll();
  }
}

@Module({
  controllers: [UsersController],
  providers: [UsersService], // register what can be injected
})
export class UsersModule {}

When the app boots, the Nest IoC container (Inversion of Control container) reads the constructor signatures, works out the dependency graph, constructs each provider once, and passes the instances in. “Inversion of control” is the name for exactly this flip: the framework controls construction, not your code. You declared UsersService in the module’s providers, so when the container builds UsersController it sees the constructor wants a UsersService, builds (or reuses) one, and injects it.

Why this works

How does Nest know which class to inject from private readonly users: UsersService? In TypeScript, with emitDecoratorMetadata enabled, the compiler emits the constructor’s parameter types as metadata that Nest reads at runtime via reflect-metadata. That’s why the type annotation isn’t just documentation here — it’s the injection token. For dependencies that aren’t a class (a config value, an interface, a third-party client), you use custom providers with an explicit token and @Inject(), which the fundamentals docs cover in depth.

Why this matters: testing, wiring, coupling, lifecycle

The payoff of “declare, don’t build” shows up in four concrete ways.

Testability. Because the controller only knows it needs something shaped like UsersService, a test can hand it a mock. Nest’s testing module lets you override a provider so the consumer is exercised with a fake — no real database, no network. The seam the hook was missing is now built in.

const moduleRef = await Test.createTestingModule({
  controllers: [UsersController],
  providers: [UsersService],
})
  .overrideProvider(UsersService)
  .useValue({ findAll: () => [{ id: 1, name: "Mock" }] }) // swap in a fake
  .compile();

Single source of wiring. Construction is described once, in module metadata, instead of scattered across every consumer. Adding a dependency to UsersService is a one-line edit to its constructor; nobody who uses the service changes.

Loose coupling. Consumers depend on what a provider does, not on how it’s assembled. That makes services composable and swappable — caching, logging, a different implementation behind the same token — without touching call sites.

Lifecycle management. The container owns each provider’s lifetime. By default a provider is a singleton — built once and shared — which is what you want for a stateless service or a connection pool. Nest also drives lifecycle hooks (onModuleInit, onApplicationShutdown) so resources start and stop in dependency order.

When to reach for Nest — and when it’s overkill

Nest’s structure is leverage on a big, long-lived codebase and pure overhead on a tiny one. The decision is about size, lifespan, and team — not fashion.

ConcernBare Express / FastifyNestJS
StructureYou invent it; drifts per teamModules/controllers/providers, enforced
Dependency injectionManual new or DIY containerBuilt-in IoC container
Testing seamHand-rolled mocks, real deps leak inoverrideProvider swaps fakes
BoilerplateMinimal to startMore upfront (decorators, modules)
Learning curveLowHigher (DI, decorators, conventions)

A structured TypeScript backend, a team that needs shared conventions, or a service you’ll maintain for years all repay the upfront ceremony many times over. A one-file script, a single serverless function, or a throwaway prototype do not — the decorators and module wiring are pure tax there, and a bare Express handler or a plain function ships faster. When you’re deciding, ask yourself: will this codebase still need to be navigated by someone else in two years? If yes, Nest’s conventions pay for themselves.

Pick the best fit

A team is starting a long-lived internal API in TypeScript: many endpoints, several shared services (auth, db, mailer), 5 engineers, expected to live for years. Pick the foundation.

Quiz

In Nest, a controller has constructor(private readonly users: UsersService) and UsersService is in the module's providers. Where does the UsersService instance come from?

Quiz

What is the main testability benefit of declaring a dependency in the constructor instead of new-ing it inside the class?

Recall before you leave
  1. 01
    Explain the DI mental model in Nest and why it beats new-ing dependencies inside a class.
  2. 02
    When is NestJS the right choice and when is it overkill?
Recap

Bare Express and Fastify route requests and stop there, which leaves a growing backend with no shared structure and no seam for testing — business logic glued to globals and dependencies built with new wherever they’re needed. NestJS sits on top of Node (Express by default, Fastify optional) and adds that structure through modules, controllers, and providers, tied together by dependency injection. The DI mental model is the heart of it: a class declares what it needs in its constructor and never constructs it; the IoC container reads the constructor types, resolves the dependency graph, builds each provider once, and injects the instances — inversion of control means the framework owns construction, not your code. That single shift buys testability (override a provider with a mock, no real database), one central place for wiring, loose coupling between consumers and implementations, and container-managed lifecycle. Reach for Nest on large, long-lived, team-owned TypeScript backends where that structure repays its upfront ceremony, and skip it for tiny scripts and throwaway functions where a plain handler is faster. Now when you see a constructor like constructor(private readonly users: UsersService), you’ll know exactly what’s happening: the class is declaring a need, and the IoC container is the one that fills it — no new, no global, no wiring scattered across the codebase.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.