Skip to content
NestJS ns patterns 4 min read

Dependency Injection Pattern

Dependency Injection (DI) is the architectural backbone of NestJS. Instead of a class constructing the things it depends on, those dependencies are supplied from the outside by a runtime container. This inversion of responsibility decouples what a class needs from how that need is satisfied, and it is the single design decision that makes Nest applications modular, swappable, and trivially testable.

Inversion of control

In classic procedural code, a class is in control: it reaches out and instantiates its collaborators. With Inversion of Control (IoC), that authority is handed to a framework. Nest’s IoC container reads the dependency graph declared through decorators, resolves it in the correct order, and hands fully-constructed instances back to you.

// WITHOUT DI — the class controls construction (tight coupling)
class OrderService {
  private readonly mailer = new SmtpMailer('smtp.acme.io', 587);
  // OrderService is now welded to SmtpMailer forever.
}

The class above cannot be tested without a live SMTP server, and switching providers means editing the class. DI inverts this: OrderService merely declares that it needs a mailer, and the container provides one.

import { Injectable } from '@nestjs/common';

@Injectable()
export class OrderService {
  constructor(private readonly mailer: Mailer) {}

  async place(order: Order): Promise<void> {
    await this.mailer.send(order.customerEmail, 'Order confirmed');
  }
}

Programming to interfaces via tokens

The most powerful form of DI is depending on an abstraction rather than a concrete class. In TypeScript, interfaces vanish at compile time, so Nest uses injection tokens — a string or Symbol — to identify a provider. You bind the token to a concrete implementation in the module, and consumers ask for the token, never the class.

// mailer.interface.ts
export interface Mailer {
  send(to: string, body: string): Promise<void>;
}

export const MAILER = Symbol('MAILER');
// order.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { Mailer, MAILER } from './mailer.interface';

@Injectable()
export class OrderService {
  constructor(@Inject(MAILER) private readonly mailer: Mailer) {}

  async place(order: Order): Promise<void> {
    await this.mailer.send(order.customerEmail, 'Order confirmed');
  }
}
// orders.module.ts
import { Module } from '@nestjs/common';
import { OrderService } from './order.service';
import { MAILER } from './mailer.interface';
import { SmtpMailer } from './smtp-mailer';

@Module({
  providers: [
    OrderService,
    { provide: MAILER, useClass: SmtpMailer },
  ],
})
export class OrdersModule {}

OrderService now knows nothing about SMTP. Swapping to a different transport — SES, a queue, a no-op for local dev — is a one-line change in the module, with no edit to any consumer.

Decoupling construction from use

A clean way to see the payoff is the table below: the same consumer is satisfied by completely different implementations depending only on the binding.

BindingProvider definitionResolved at runtime
Production{ provide: MAILER, useClass: SmtpMailer }live SMTP transport
Staging{ provide: MAILER, useClass: SesMailer }AWS SES transport
Test{ provide: MAILER, useValue: fakeMailer }in-memory spy

Always inject through private readonly constructor parameters. Field assignment is implicit, the dependency is immutable, and the type signature documents exactly what the class requires.

Impact on testability

Because construction is externalized, a unit test can substitute any dependency with a fake — no network, no database, no real clock. Nest’s Test.createTestingModule mirrors a real module, letting you override a token with a controlled double.

// order.service.spec.ts
import { Test } from '@nestjs/testing';
import { OrderService } from './order.service';
import { Mailer, MAILER } from './mailer.interface';

describe('OrderService', () => {
  let service: OrderService;
  const fakeMailer: jest.Mocked<Mailer> = { send: jest.fn() };

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrderService,
        { provide: MAILER, useValue: fakeMailer },
      ],
    }).compile();

    service = moduleRef.get(OrderService);
  });

  it('emails the customer when an order is placed', async () => {
    await service.place({ customerEmail: '[email protected]' } as Order);
    expect(fakeMailer.send).toHaveBeenCalledWith('[email protected]', 'Order confirmed');
  });
});

Output:

 PASS  src/orders/order.service.spec.ts
  OrderService
    ✓ emails the customer when an order is placed (4 ms)

Tests:       1 passed, 1 total

The test never touches a real mailer. That is the direct, measurable dividend of inverting control: the dependency graph bends to the test, not the other way around.

Best practices

  • Depend on an interface plus token, not a concrete class, whenever the implementation might vary (transport, storage, third-party clients).
  • Define tokens with Symbol (or a typed InjectionToken constant) to avoid collisions from duplicate string literals.
  • Keep providers @Injectable() and prefer constructor injection over property injection so dependencies are explicit and immutable.
  • Let the module own the binding — consumers should never see useClass/useValue; that knowledge belongs at the composition root.
  • Override tokens with useValue in tests instead of mocking imports, keeping unit tests fast and free of I/O.
  • Avoid new for collaborators inside services; if you reach for it, that dependency probably belongs in the container.
Last updated June 14, 2026
Was this helpful?