Skip to content

Dependency inversion and NestJS dependency injection

Decide who owns the interface, then let Nest wire it by token; keep providers singleton, because request scope spreads up the chain.

Predict first

Before you read: predict

ExamHall's results API is one NestJS app. Each read of a grade has to be recorded against the request that made it, so a developer adds a request-scoped AuditLog:

The app starts, then serves GET /grades/a-1, GET /grades/a-2 and GET /grades/a-3, one after another.

After the third reply, how many instances of each class has Nest built?

Code for this question
import { Controller, Get, Injectable, Module, Param, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.REQUEST })export class AuditLog {  reads: string[] = [];} @Injectable()export class GradeRepository {  find(attemptId: string) { return attemptId === 'a-1' ? 71 : 64; }} @Injectable()export class GradingService {  constructor(private audit: AuditLog, private grades: GradeRepository) {}  grade(attemptId: string) {    this.audit.reads.push(attemptId);    return this.grades.find(attemptId);  }} @Controller('grades')export class GradesController {  constructor(private grading: GradingService) {}  @Get(':id') get(@Param('id') id: string) { return this.grading.grade(id); }} @Module({ controllers: [GradesController], providers: [AuditLog, GradeRepository, GradingService] })export class AppModule {}
Pick one

The 30-second version

Inversion is ownership: policy code and a detail both depend on an abstraction the policy side defines. Injection is wiring: Nest builds each provider and passes it in by token; an erased interface needs its own token. Providers are shared singletons by default; request scope rebuilds one, and everything depending on it, per request; transient gives each consumer its own.

Decide who owns the interface, then let Nest wire it by token; keep providers singleton, because request scope spreads up the chain.

How it works

Step 1 of 15

Inversion is ownership. High-level policy and a low-level detail both depend on an abstraction; the policy side states it, and the detail implements it.
In the kitchen
  • the band writes the rider, and each venue brings kit that meets it; the band never learns a venue's brands → the policy side states the abstraction, and details implement it (steps 1–2)

Analogy: A touring band's technical rider (live-music touring)

A band on tour sends every venue its rider: "a five-piece drum kit, two vocal mics, a 24-channel desk".

On tour In NestJS
the band writes the rider, and each venue brings kit that meets it; the band never learns a venue's brands the policy side states the abstraction, and details implement it (steps 1–2)
the venue's production manager sets every item out before the band arrives the container builds and passes in each dependency (step 3)
a rider line reading "our usual kit, whatever it is" gives the manager nothing to look up a parameter typed with an interface: erased, no token (step 8)
a numbered line, "drum kit: line 4", that the manager finds on the sheet a token, and @Inject(TOKEN) (step 9)
the house PA serves every act, all night a singleton (step 11)
the local crew is booked fresh for each show, and so is the monitor engineer who works through them; the house PA is not request scope bubbling up, never down (steps 12–13)
each guitarist gets a new set of strings of their own transient (step 14)

In real terms: inversion decides whose file declares the contract; injection decides who builds each object; the scope decides how many are built.

Breaks down when: the production manager phones the band to ask what "our usual kit" means. Nest asks nothing: an erased type is simply gone.

Contrasting case: a band that only ever plays its own rehearsal room, on its own kit, has no rider to write; with one implementation there may be nothing to invert yet (pat-over-engineering).

Examples, run for real

4.1 ExamHall EXH-2510: an interface in GradingService's constructor

GradingService scores an attempt from its stored answers. Its constructor was typed with an interface, and Prisma's adapter was registered beside it:

// grading/answer-store.ts
export interface AnswerStore { load(attemptId: string): Promise<string[]>; }
// grading/grading.service.ts
@Injectable()
export class GradingService {
  constructor(private store: AnswerStore) {}
  async score(attemptId: string) { return (await this.store.load(attemptId)).length; }
}
// app.module.ts
@Module({ providers: [GradingService, PrismaAnswerStore] })
export class AppModule {}
4.1 EXH-2510 constructor(private store: AnswerStore), AnswerStore an interface: startup throws UnknownDependenciesException · recorded parameter type Object
4.1 MR !2810 AnswerStore an abstract class in grading/: production starts · score a-1 3 (PrismaAnswerStore) · test module starts · score a-1 2 (in-memory fake)
4.1 MR !2810 import declarations in grading/ naming a file in prisma/: 0

The interface was gone at run time, so Nest had no token to look up (step 8). MR !2810 makes AnswerStore an abstract class in grading/: one artefact is the contract and the token, Prisma's adapter implements it, and a test module binds a fake to it (steps 1, 6, 10).

4.2 ExamHall EXH-2511: a request-scoped answer store

To stamp each answer read with the candidate's tenant, a developer marked PrismaAnswerStore Scope.REQUEST and injected the request into it. The results API's graph:

// ResultsController -> GradingService -> AnswerStore (PrismaAnswerStore, Scope.REQUEST)
// ResultsController -> ExamCatalog
4.2 EXH-2511 3 requests, PrismaAnswerStore Scope.REQUEST: built PrismaAnswerStore 3 · GradingService 3 · ResultsController 3 · ExamCatalog 1 · tenants in the replies t-1, t-2, t-1
4.2 MR !2811 store a singleton, tenant read from AsyncLocalStorage: built PrismaAnswerStore 1 · GradingService 1 · ResultsController 1 · ExamCatalog 1 · tenants in the replies t-1, t-2, t-1

Request scope reached every class above the store, and ExamCatalog, beside it, stayed one instance (steps 12–13). MR !2811 makes the store a singleton again and reads the tenant from an AsyncLocalStorage store that middleware fills for each request (step 15).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A payments ledger's NestJS app formats money in two services:

The app starts, then serves three GET /ledger requests, one after another.

After the third reply, how many MoneyFormatter instances has Nest built?

Code for this question
import { Controller, Get, Injectable, Module, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.TRANSIENT })export class MoneyFormatter {  format(cents: number) { return (cents / 100).toFixed(2); }} @Injectable()export class InvoiceService {  constructor(private money: MoneyFormatter) {}  due() { return this.money.format(12500); }} @Injectable({ scope: Scope.REQUEST })export class ReceiptService {  constructor(private money: MoneyFormatter) {}  paid() { return this.money.format(4000); }} @Controller('ledger')export class LedgerController {  constructor(private invoices: InvoiceService, private receipts: ReceiptService) {}  @Get() summary() { return `${this.invoices.due()} due, ${this.receipts.paid()} paid`; }} @Module({ controllers: [LedgerController], providers: [MoneyFormatter, InvoiceService, ReceiptService] })export class AppModule {}
Pick one

Question 2 of 5

A shop's NestJS app has these search files:

Startup throws UnknownDependenciesException. The team wants three things: the app starts with OpenSearchIndex behind the search; a test module can bind an in-memory fake without editing product-search.service.ts; and the files under search/ still type-check with the opensearch/ folder deleted.

Which one change gets all three?

Code for this question
// search/search-index.tsexport interface SearchIndex {  query(text: string): Promise<string[]>;} // search/product-search.service.tsimport { Injectable } from '@nestjs/common';import type { SearchIndex } from './search-index.js'; @Injectable()export class ProductSearchService {  constructor(private index: SearchIndex) {}  async count(text: string) { return (await this.index.query(text)).length; }} // opensearch/opensearch-index.tsimport { Injectable } from '@nestjs/common';import type { SearchIndex } from '../search/search-index.js'; @Injectable()export class OpenSearchIndex implements SearchIndex {  async query(text: string) { return ['mug', 'mug lid', 'travel mug'].filter((p) => p.includes(text)); }} // app.module.tsimport { Module } from '@nestjs/common';import { ProductSearchService } from './search/product-search.service.js';import { OpenSearchIndex } from './opensearch/opensearch-index.js'; @Module({ providers: [ProductSearchService, OpenSearchIndex] })export class AppModule {}
Pick one

Question 3 of 5

A video service's NestJS app picks what to play next:

Until last week its constructor called new VectorSearchClient(process.env.VECTOR_URL).

The merge request says: "NextUpService now follows dependency inversion: Nest injects the client, and the spec binds a fake to the VectorSearchClient token." Which review reply holds up?

Code for this question
// recommend/next-up.service.tsimport { Injectable } from '@nestjs/common';import { VectorSearchClient } from '../vendor/vector-search.client.js'; @Injectable()export class NextUpService {  constructor(private search: VectorSearchClient) {}  async nextUp(videoId: string) { return (await this.search.nearest(videoId, 5)).map((hit) => hit.id); }}
Pick one

Question 4 of 5

A box office's NestJS app:

Nest builds a new BookingService for every request.

Which one registration makes it do that?

Code for this question
import { Controller, Get, Injectable, Module, Param, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.TRANSIENT })export class PriceFormatter {  format(cents: number) { return (cents / 100).toFixed(2); }} @Injectable({ scope: Scope.REQUEST })export class RequestTrace {  steps: string[] = [];} @Injectable()export class SeatLock {  constructor(private trace: RequestTrace) {}  hold(seat: string) { this.trace.steps.push(`hold ${seat}`); return true; }} export class FeeTable {  fee(cents: number) { return Math.round(cents * 0.05); }} @Injectable()export class BookingService {  constructor(private prices: PriceFormatter, private locks: SeatLock, private fees: FeeTable) {}  book(seat: string, cents: number) {    this.locks.hold(seat);    return this.prices.format(cents + this.fees.fee(cents));  }} @Controller({ path: 'bookings', scope: Scope.REQUEST })export class BookingsController {  constructor(private booking: BookingService) {}  @Get(':seat') book(@Param('seat') seat: string) { return this.booking.book(seat, 4000); }} @Module({  controllers: [BookingsController],  providers: [PriceFormatter, RequestTrace, SeatLock, BookingService, { provide: FeeTable, useFactory: () => new FeeTable() }],})export class AppModule {}
Pick one

Question 5 of 5

A chat app's NestJS module builds MessageService with a factory, and chat/'s spec passes it a fake gateway:

The push team's next merge request moves to APNs. The push team owns push/ and app.module.ts: fcm-gateway.ts becomes apns-gateway.ts, FcmGateway becomes ApnsGateway, and the gateway's send takes (deviceToken: string, body: string, badge: number).

Which change should the chat team make now, so that today the app still compiles, starts and posts through FCM, and after that merge request tsc has nothing to report in chat/?

Code for this question
// push/fcm-gateway.tsexport class FcmGateway {  send(userId: string, text: string): boolean { return userId.length > 0 && text.length > 0; }} // chat/message.service.tsimport { FcmGateway } from '../push/fcm-gateway.js';export class MessageService {  constructor(private push: FcmGateway) {}  post(to: string, text: string): boolean { return this.push.send(to, text); }} // chat/message.service.spec.tsimport { MessageService } from './message.service.js';const sent: string[] = [];const service = new MessageService({ send: (userId, text) => sent.push(`${userId} ${text}`) > 0 });export const passes = service.post('u-1', 'hi') && sent.length === 1; // app.module.tsimport { Module } from '@nestjs/common';import { MessageService } from './chat/message.service.js';import { FcmGateway } from './push/fcm-gateway.js'; @Module({ providers: [{ provide: MessageService, useFactory: () => new MessageService(new FcmGateway()) }] })export class AppModule {}
Pick one

Go deeper

Pro tip
  1. Before you merge a scope change, start the app once with NEST_DEBUG set: Nest logs each provider it introspects as request-scoped, so the spread shows before any traffic. When: adding Scope.REQUEST to a provider, or injecting REQUEST into one (steps 12–13). Verify:
    tip 1 NEST_DEBUG set, §4.2's app as written: introspected as request-scoped: PrismaAnswerStore, GradingService, ResultsController
    tip 1 ExamCatalog listed: no
Source

The NestJS chapters are vendor documentation that changes with releases, so their claims are labelled as of 2026-10-02; every NestJS behaviour this lesson grades is also executed on NestJS 12.1.2.

Checked against the tools and docs as of 2026-10-02.