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?
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 {}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
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?
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 {}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?
// 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 {}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?
// 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); }}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?
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 {}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/?
// 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 {}Go deeper
Pro tip
- Before you merge a scope change, start the app once with
NEST_DEBUGset: Nest logs each provider it introspects as request-scoped, so the spread shows before any traffic. When: addingScope.REQUESTto a provider, or injectingREQUESTinto 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.
- Robert C. Martin, The Dependency Inversion Principle, C++ Report, 1996, the curriculum row's second source, from the author's own company site: https://objectmentor.com/resources/articles/dip.pdf (
martin-1996-dip.txt, text by pdftotext). - NestJS documentation, Providers (NestJS 12, as of 2026-10-02): https://docs.nestjs.com/providers. That page renders in the browser and its static HTML holds no chapter text, so the text was read from its source file at the file's latest commit on 2026-10-02: https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/components.md (
nestjs-providers.txt). Teaching for steps 7–8 and 11. - NestJS documentation, Custom providers (as of 2026-10-02): https://docs.nestjs.com/fundamentals/custom-providers, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/9507239be102d8031d75ef71e4c30c1394199564/content/fundamentals/dependency-injection.md (
nestjs-custom-providers.txt). Teaching for steps 3 and 5–10. - NestJS documentation, Injection scopes (as of 2026-10-02): https://docs.nestjs.com/fundamentals/injection-scopes, read from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/5498a5e74c35c33424ce4b9ac2ad939f77c53624/content/fundamentals/provider-scopes.md (
nestjs-injection-scopes.txt). Teaching for steps 11–15; t1, P1 and D1 execute what it states.
Related lessons
- Read first: SOLID at a glance, with LSP and ISP in TypeScript
- Related: Modular monolith vs microservices
- Related: Types vanish at runtime, validate at the boundary
- Related: Test doubles and boundaries
- Related: Layers, ports and adapters
- Related: Wrappers: adapter, decorator, proxy, facade
- Related: When a pattern is over-engineering
Checked against the tools and docs as of 2026-10-02.