Providers and dependency injection
constructor(private readonly products: ProductsService) {}
You have written that line twice without being told what it does. This lesson explains it, because dependency injection is the concept NestJS is built on and the one people work around rather than understand.
The problem it solves
Without injection:
export class OrdersService {
private products = new ProductsService();
private mailer = new MailerService();
}
That looks harmless and causes three problems.
You cannot test it. OrdersService creates a real ProductsService, which
creates a real database client. Testing the order logic now needs a database.
Every instantiation is separate. Ten services each creating a
ProductsService, each with its own connection pool.
Configuration has nowhere to go. MailerService needs an API key. Reading
it inside the constructor means the class is tied to one source of
configuration, and tests cannot replace it.
How injection works
@Injectable()
export class OrdersService {
constructor(
private readonly products: ProductsService,
private readonly prisma: PrismaService,
) {}
}
OrdersService declares what it needs. NestJS reads the constructor's
parameter types, finds those providers, creates them if needed, and passes them
in.
That is the whole mechanism. The class no longer decides where its dependencies come from, so something else can decide — which is what makes it testable.
@Injectable() is what marks a class as available for this. Forget it and
you get:
Nest can't resolve dependencies of the OrdersService (?).
The same message as a missing export, which is why the checklist matters: is
the class @Injectable(), is it in providers, is it exported, is its module
imported.
private readonly in the constructor is TypeScript shorthand that declares
and assigns the property in one line. Without it you would write the field and
the assignment separately. readonly because nothing should reassign a
dependency.
Providers are singletons
@Module({ providers: [ProductsService], exports: [ProductsService] })
By default NestJS creates one instance shared by everything that injects
it. A PrismaService injected into twelve services is one client with one
connection pool.
Two consequences:
State on a provider is shared across every request. Caching a value there is deliberate; accidentally storing request-specific data there is a bug where one user sees another's data.
@Injectable()
export class CartService {
private currentUserId: string; // shared by every request — wrong
}
Request-scoped data goes in method arguments, not on the instance.
Startup order is handled for you. NestJS builds the dependency graph and constructs in the right order.
Scopes exist — Scope.REQUEST gives one instance per request — and they are
slower and rarely necessary. Default singleton unless you have a specific
reason.
A service
// apps/api/src/products/products.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(filters: { category?: string }) {
return this.prisma.product.findMany({
where: filters.category ? { category: filters.category } : undefined,
orderBy: { name: "asc" },
});
}
async findBySlug(slug: string) {
const product = await this.prisma.product.findUnique({ where: { slug } });
if (!product) {
throw new NotFoundException(`No product with slug "${slug}"`);
}
return product;
}
}
The business logic, with no HTTP anywhere. findBySlug could be called from a
controller, a scheduled job, or a test — it does not know or care.
The Prisma provider
The one piece of plumbing worth writing out, because every later module uses it:
// apps/api/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
OnModuleInit and OnModuleDestroy are lifecycle hooks. Connect when the
application starts, disconnect cleanly when it stops — without the second, a
restarting container can leave connections open until the database refuses new
ones.
Custom providers
Sometimes the class name is not the right token.
A value:
@Module({
providers: [{ provide: "MAX_CART_ITEMS", useValue: 50 }],
})
constructor(@Inject("MAX_CART_ITEMS") private readonly maxItems: number) {}
Primitives have no class to key on, so @Inject names the token explicitly.
A factory, when creation needs work or configuration:
{
provide: "PAYMENT_CLIENT",
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new PaymentClient(config.getOrThrow<string>("PAYMENT_KEY")),
}
An alternative implementation:
{ provide: StorageService, useClass: S3StorageService }
Everything injecting StorageService now gets the S3 one. Swap it for
LocalStorageService in development, with no change to any consumer — this is
the payoff of depending on a type rather than constructing a class.
String tokens are error-prone. For anything beyond a couple, export constants:
export const MAX_CART_ITEMS = Symbol("MAX_CART_ITEMS");
Testing
The whole point:
import { Test } from "@nestjs/testing";
describe("ProductsService", () => {
let service: ProductsService;
const prisma = {
product: { findUnique: jest.fn(), findMany: jest.fn() },
};
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
ProductsService,
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = moduleRef.get(ProductsService);
jest.clearAllMocks();
});
it("returns the product when it exists", async () => {
prisma.product.findUnique.mockResolvedValue({ id: "1", slug: "atta-5kg" });
await expect(service.findBySlug("atta-5kg")).resolves.toMatchObject({
slug: "atta-5kg",
});
});
it("throws NotFound when it does not", async () => {
prisma.product.findUnique.mockResolvedValue(null);
await expect(service.findBySlug("nope")).rejects.toThrow(NotFoundException);
});
});
No database, milliseconds to run, and the not-found path is tested — which with a real database would mean carefully arranging for something to be absent.
jest.clearAllMocks() in beforeEach keeps tests independent, which is module
10 of the Python course applied here.
Circular dependencies
Two services needing each other:
constructor(
@Inject(forwardRef(() => ProductsService))
private readonly products: ProductsService,
) {}
As with modules: it works and it is a smell. One of the two usually should not depend on the other — pass the data it needs as an argument instead, or extract the shared part.
Check your work
What @Injectable() does: marks a class as available for injection. Without
it, NestJS cannot provide it and reports an unresolved dependency.
Four things to check on "Nest can't resolve dependencies": the class is
@Injectable(), it is listed in providers, its module exports it, and the
consuming module imports that module.
What private readonly in a constructor does: declares and assigns the
property in one line, and prevents reassignment.
Default scope: singleton — one instance shared by everything.
Why request data must not live on a provider: the instance is shared, so one request's data would be visible to another's.
What useClass allows: swapping the implementation behind a token without
changing any consumer.
Why injection makes testing possible: the dependency is supplied from outside, so a test can supply a fake instead of a real database.
Practice
- Build
ProductsServicewithfindAllandfindBySlug, injected into the controller. Confirm both work. - Remove
@Injectable()and read the error. - Log a message in the service constructor. Inject it into two other services and confirm the log appears once — proving it is a singleton.
- Store a value on the service in one request and read it in another. Observe the leak, then fix it by passing the value as an argument.
- Write
PrismaServicewith both lifecycle hooks. Confirm the connect log appears once at startup. - Register
MAX_CART_ITEMSas a value provider and inject it with@Inject. - Define a
StorageServicewith two implementations and swap them withuseClass. Confirm no consumer changes. - Write both
ProductsServicetests with a faked Prisma. Run them and note the time. - Create a circular dependency between two services, fix it with
forwardRef, then fix it properly.
Next: pipes, filters and interceptors — the things that run around your handler.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship