Fullstack CourseLearn by building
Back to week 1

Topic

Dependency injection

Definition

Dependency injection is a design pattern in which a class declares the collaborators it needs, typically through its constructor, and an external container supplies configured instances rather than the class constructing them itself.

In simpler words

Instead of a controller writing new TicketsService(), it just asks for TicketsService in its constructor and Nest hands over the instance.

DI is the hardest Nest concept to fake your way through. Intermediate engineers must reason about tokens, module visibility, scope, and circular graphs — not only “put it in the constructor.”

After this you can

  • Explain why constructor injection beats "new" inside a class
  • Name the DI token Nest uses for a class vs @InjectRepository(Ticket)
  • Diagnose "Nest can't resolve dependencies" as missing provider/import/export
  • Explain why singleton services must not store request-scoped state on this
  • Sketch how a unit test swaps TicketsService for a fake provider

Why not just "new" the service?

Definition

Manually constructing a collaborator with new couples a class to one concrete implementation and its entire construction logic, which a dependency-injection container avoids by supplying an already-configured instance.

In simpler words

new TicketsService() ties your controller to exactly one version of that service forever; asking for it lets Nest swap in whatever is configured — including a test double.

If TicketsController called new TicketsService() itself, it would also need to construct whatever TicketsService needs (a Repository<Ticket>), and so on down the chain — construction logic would leak into every consumer.

With DI, TicketsController only declares "I need a TicketsService" in its constructor signature. Nest resolves the whole chain — TicketsService, its Repository<Ticket>, that repository’s connection — once, using the module graph.

Intermediate rule: if you reach for new on a Nest-managed class, you have left the container. Prefer injection even for helpers that look “simple.”

Constructor injection (apps/api)

@Controller('tickets')
export class TicketsController {
  constructor(private readonly ticketsService: TicketsService) {}
}

Nest sees the TicketsService type token, finds it in TicketsModule.providers, constructs (or reuses) it, and passes it in.

Mistake: manual construction

// Wrong — bypasses the DI container
const ticketsService = new TicketsService(/* how do you get the repo? */);

// Right
constructor(private readonly ticketsService: TicketsService) {}

Manual construction skips Nest’s provider scoping and cannot be swapped for a test double.

Tokens: class vs custom vs repository

Definition

A DI token is the lookup key Nest uses to find a provider; for class providers the token is usually the class itself, while custom providers and TypeORM repositories use string/symbol tokens or decorator-generated tokens.

In simpler words

Nest does not inject “by TypeScript type magic alone” — it injects by token. Decorators like @InjectRepository create a specific token for that entity’s repository.

Class provider: { provide: TicketsService, useClass: TicketsService } is implied when you list TicketsService in providers. Constructor(private ticketsService: TicketsService) asks for that class token.

@InjectRepository(Ticket) injects the repository registered by TypeOrmModule.forFeature([Ticket]). Without forFeature, Nest throws at boot: Nest can't resolve dependencies of TicketsService.

Custom tokens (string/symbol + @Inject("TOKEN")) appear when you inject config objects, clients, or alternate implementations. Prefer clear names over mystery strings.

Repository token in TicketsService

@Injectable()
export class TicketsService {
  constructor(
    @InjectRepository(Ticket)
    private readonly ticketsRepo: Repository<Ticket>,
  ) {}
}

// TicketsModule must register the token:
@Module({
  imports: [TypeOrmModule.forFeature([Ticket])],
  providers: [TicketsService],
  controllers: [TicketsController],
})
export class TicketsModule {}

forFeature creates the repository provider; @InjectRepository asks for it. Both sides are required.

Mistake: inject without registering

// Wrong — AuthService wants UsersService but UsersModule did not export it
@Module({ providers: [UsersService] }) // no exports
export class UsersModule {}

@Module({ imports: [UsersModule], providers: [AuthService] })
export class AuthModule {} // boom at runtime/boot

// Right — export what other modules may inject
@Module({ providers: [UsersService], exports: [UsersService] })
export class UsersModule {}

Importing a module is not enough. Only exported providers cross module boundaries.

Providers, scope, and singletons

Definition

A provider is any value, factory, or class that Nest’s dependency-injection container can register and supply; by default each provider is instantiated once per application and shared as a singleton.

In simpler words

Mark a class @Injectable() and list it in a module’s providers — Nest builds one instance and reuses it everywhere that asks for it.

Default scope is SINGLETON: one TicketsService for the process. That is correct for almost all HTTP APIs.

REQUEST scope creates a new instance per HTTP request. It is useful for request-bound context, but it forces every consumer of that provider toward request scope too — a cascade that hurts performance and surprises beginners.

Because singletons are shared, never store “current user” or “current ticket id” on this.something. Pass them as method arguments from the controller (@CurrentUser(), params, DTOs).

Safe singleton vs unsafe per-request state

// Wrong — races under concurrent requests
@Injectable()
export class TicketsService {
  private currentUserId?: string;
  setUser(id: string) { this.currentUserId = id; }
  create(dto: CreateTicketDto) {
    return this.ticketsRepo.save({ ...dto, ownerId: this.currentUserId });
  }
}

// Right — request data as arguments
create(dto: CreateTicketDto, userId: string) {
  return this.ticketsRepo.save({ ...dto, ownerId: userId });
}

Two overlapping requests would overwrite currentUserId on a singleton. Method params do not.

Circular dependencies and forwardRef

Definition

A circular dependency occurs when module A needs a provider from module B while module B needs a provider from module A, so Nest cannot finish constructing either side without an explicit break such as forwardRef or a shared third module.

In simpler words

If two features import each other, Nest may fail at boot or need forwardRef — treat that as a design smell, not a normal pattern.

First preference: extract the shared piece (for example UsersModule exporting UsersService) so AuthModule and TicketsModule both import UsersModule without importing each other.

forwardRef(() => OtherModule) / @Inject(forwardRef(() => OtherService)) can break cycles, but they hide coupling. Prefer redrawing the boundary.

Symptoms: Nest cannot resolve dependencies, undefined injected values, or modules that only work when registered in a lucky order.

Prefer a shared export over a cycle

// Prefer
UsersModule  --exports--> UsersService
AuthModule   --imports--> UsersModule
TicketsModule --imports--> UsersModule

// Avoid
AuthModule <-> TicketsModule (each needs the other)

Users own identity data; auth and tickets consume it. That direction matches the domain.

Testing with DI

Definition

Unit tests replace real providers with test doubles by overriding the same DI tokens Nest would use in production, so the class under test receives fakes through its normal constructor.

In simpler words

You do not stub private fields — you tell the testing module “when someone asks for TicketsService, give this fake.”

NestTestingModule.createTestingModule({ controllers: [TicketsController], providers: [{ provide: TicketsService, useValue: fake }] }) wires the same injection path as production.

Fake only the next collaborator. Do not mock TypeORM inside the controller test if the controller only talks to TicketsService.

Override a provider token

const fake = { list: jest.fn().mockResolvedValue({ data: [], meta: {} }) };
const moduleRef = await Test.createTestingModule({
  controllers: [TicketsController],
  providers: [{ provide: TicketsService, useValue: fake }],
}).compile();
const controller = moduleRef.get(TicketsController);

TicketsController still receives ticketsService via constructor; only the implementation changed.

Keep in mind

  • Ask for dependencies in the constructor; never new a Nest-managed class yourself.
  • Default (singleton) scope is almost always what you want in an HTTP API.
  • Boot errors about unresolved dependencies are almost always missing providers, missing forFeature, or missing exports — not TypeScript types.
  • If two modules import each other, redraw the boundary before reaching for forwardRef.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥ 80% (enforced by the API) to save progress.

Checking your session…

10 questions · concept 3 · syntax 2 · practical 3 · logic 2

Concept1. Which statement best defines Dependency injection?
Syntax2. Which code-level choice matches Dependency injection?
Practical3. A reviewer spots a bug related to Dependency injection. What is the right fix?
Logic4. Which reasoning correctly explains Dependency injection?
Concept5. Which boundary does correct use of Dependency injection preserve?
Practical6. What is the safest next step when applying Dependency injection?
Syntax7. Which implementation direction matches the rule for Dependency injection?
Logic8. Which consequence follows from applying Dependency injection correctly?
Concept9. Which claim about Dependency injection is true in this Nest + Postgres monorepo?
Practical10. Which team practice best demonstrates Dependency injection?