- NestJS
- Backend
- TypeScript
How NestJS Actually Works: Dependency Injection Explained with a Pizza
Providers, modules, IoC and the NestJS container stop feeling like magic once you see them as a pizza shop — who makes what, who needs what, and who connects it all.
When I first started learning NestJS, concepts like Dependency Injection (DI), Inversion of Control (IoC), providers and the NestJS container felt more complicated than they actually are.
The problem wasn't the code. The problem was understanding who creates what, who is responsible for what, and how all these objects get connected.
So let's forget NestJS for a moment and make a pizza. 🍕
Imagine we're running a pizza shop
A customer orders a pizza. To make a pizza, we need dough. To make dough, we need flour, water, yeast and salt.
Pizza
└── Dough
├── Flour
├── Water
├── Yeast
└── SaltA pizza depends on dough, and dough depends on several ingredients.
That simple relationship is the foundation of dependency injection.
1. The naive approach
Here it is in TypeScript, without dependency injection:
class Flour {
amount = "500g";
}
class Water {
amount = "300ml";
}
class Yeast {
amount = "10g";
}
class Salt {
amount = "5g";
}
class Dough {
make() {
const flour = new Flour();
const water = new Water();
const yeast = new Yeast();
const salt = new Salt();
return `Dough made with ${flour.amount} flour`;
}
}
class Pizza {
make() {
const dough = new Dough();
return `Pizza made with ${dough.make()}`;
}
}It looks fine, but there's a problem. Pizza is responsible for creating Dough, and Dough is responsible for creating every ingredient:
Pizza
└── creates Dough
├── creates Flour
├── creates Water
├── creates Yeast
└── creates SaltThe classes are tightly coupled. Pizza knows too much about how its dependencies are made, and you can't swap the water or test the dough with fake ingredients without editing the class itself.
2. Inject the dependencies instead
Instead of letting Dough create everything, we hand it what it needs:
class Dough {
constructor(
private flour: Flour,
private water: Water,
private yeast: Yeast,
private salt: Salt,
) {}
make() {
return "Dough is ready";
}
}Now Dough doesn't care where the ingredients came from. It simply says:
"I need these things to do my job."
That's the core idea of dependency injection. Instead of Dough → creates → Flour, we have Dough ← receives ← Flour.
3. But who creates everything?
If Dough doesn't create its ingredients, someone else has to. Without a framework, that someone is you:
const pizza = new Pizza(
new Dough(new Flour(), new Water(), new Yeast(), new Salt()),
);For four ingredients that's fine. For a real application with hundreds of classes, it becomes a wall of new.
This is where Inversion of Control (IoC) comes in. Instead of each class controlling the creation of its dependencies, we hand that job to someone else. Imagine the pizza shop has a manager who knows where to get each ingredient, how to assemble them, and which worker needs what:
Pizza Shop Manager
│
┌──────────┬───┴──────┬──────────┐
↓ ↓ ↓ ↓
Flour Water Yeast Salt
└──────────┴────┬─────┴──────────┘
↓
Dough
↓
PizzaIn NestJS, that manager is the IoC container (also called the DI container).
4. The pizza shop in NestJS
First, the ingredients. Each one is a provider:
import { Injectable } from "@nestjs/common";
@Injectable()
export class FlourService {
getFlour() {
return "500g flour";
}
}
@Injectable()
export class WaterService {
getWater() {
return "300ml water";
}
}
@Injectable()
export class YeastService {
getYeast() {
return "10g yeast";
}
}
@Injectable()
export class SaltService {
getSalt() {
return "5g salt";
}
}The dough depends on the ingredients:
@Injectable()
export class DoughService {
constructor(
private readonly flour: FlourService,
private readonly water: WaterService,
private readonly yeast: YeastService,
private readonly salt: SaltService,
) {}
makeDough() {
return {
flour: this.flour.getFlour(),
water: this.water.getWater(),
yeast: this.yeast.getYeast(),
salt: this.salt.getSalt(),
};
}
}And the pizza depends on the dough:
@Injectable()
export class PizzaService {
constructor(private readonly doughService: DoughService) {}
makePizza() {
return {
pizza: "Margherita",
dough: this.doughService.makeDough(),
};
}
}Notice what's missing: there isn't a single new anywhere. Each class only declares what it needs in its constructor. NestJS takes care of the rest.
5. Modules tell NestJS what exists
NestJS needs to know which providers belong to which part of the application. That's what a module does:
@Module({
providers: [
FlourService,
WaterService,
YeastService,
SaltService,
DoughService,
PizzaService,
],
exports: [PizzaService],
})
export class PizzaModule {}providers registers the classes the container may create. exports makes PizzaService available to other modules that import PizzaModule; the ingredients stay private to the kitchen.
6. What does @Injectable() actually do?
This is one of the most misunderstood parts of NestJS. It's tempting to think @Injectable() creates the class. It doesn't.
When a class has a decorator, TypeScript (with emitDecoratorMetadata turned on, as in every Nest project) records the types of its constructor parameters. For PizzaService, that recorded metadata says, in effect:
PizzaService's constructor takes: [DoughService]NestJS reads that metadata at startup to learn what each class needs. @Injectable() is what makes TypeScript emit it, and it marks the class as something the container can manage.
So decorators aren't magic. They leave notes that NestJS reads during startup.
7. What happens when the application starts
Everything begins in main.ts:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}NestFactory.create(AppModule) tells NestJS: start from this root module. From there, NestJS:
- Scans the modules, following every
importsentry, and collects their controllers and providers. - Builds a dependency graph from the constructor metadata:
PizzaService
│
DoughService
┌──────────┬──┴───────┬──────────┐
↓ ↓ ↓ ↓
Flour Water Yeast Salt- Resolves it from the bottom up. To create
PizzaService, it needsDoughService; to create that, it needs the four ingredients. So it creates the ingredients first, then the dough, then the pizza, passing each instance into the next constructor.
That's the whole reason you never write new FlourService() or new PizzaService() yourself. The container does exactly what the manual new Pizza(new Dough(...)) line did, for every class in the app.
If something can't be resolved, for example DoughService isn't listed in any module's providers, Nest fails at startup with an error naming the missing dependency. That's a feature: wiring mistakes show up when the app boots, not in the middle of a request.
8. Controllers enter the picture
So far we only have services. A user reaches them through a controller:
@Controller("pizza")
export class PizzaController {
constructor(private readonly pizzaService: PizzaService) {}
@Get()
makePizza() {
return this.pizzaService.makePizza();
}
}Controllers are injected exactly like services. A request to GET /pizza is routed to PizzaController.makePizza(), which calls the service:
HTTP Request
↓
PizzaController
↓
PizzaService
↓
DoughService
├── FlourService
├── WaterService
├── YeastService
└── SaltServiceEach layer has one job:
- Controller — HTTP: routes, parameters, status codes.
- PizzaService — the business logic of making a pizza.
- DoughService — preparing dough.
- Ingredient providers — one ingredient each.
That separation is a big part of why NestJS applications stay maintainable as they grow.
9. Swapping an ingredient
Now the analogy pays off. Our shop switches to a new water supplier:
@Injectable()
export class MineralWaterService extends WaterService {
getWater() {
return "300ml mineral water";
}
}We don't touch DoughService or PizzaService. We only change how the container provides WaterService:
@Module({
providers: [
FlourService,
{ provide: WaterService, useClass: MineralWaterService },
YeastService,
SaltService,
DoughService,
PizzaService,
],
})
export class PizzaModule {}Everyone who asks for WaterService now gets mineral water. The same mechanism powers real use cases:
- Payments: one
PaymentServiceinterface, with Stripe, PayPal or bKash plugged in by configuration. - Tests: replace a real email or database provider with a fake one, without changing the code under test.
10. Why this matters in real applications
The pizza is simple. A production order flow isn't:
OrderService
├── PaymentService
│ └── StripeClient
├── InventoryService
│ └── ProductRepository
├── NotificationService
│ ├── EmailService
│ └── SmsService
└── OrderRepository
└── DatabaseWiring that by hand, and keeping it correct as it changes, would be painful. The container gives you one central place that owns all of it.
11. Singleton providers
By default, NestJS providers are singletons: the container creates one instance when the app starts and reuses it everywhere.
Request 1 ──┐
Request 2 ──┼──→ the same PizzaService instance
Request 3 ──┘That's ideal for stateless things like database clients, configuration, API clients and most business services. It also means you shouldn't store per-request data on a singleton, because every request shares it.
When you genuinely need otherwise, Nest has two other scopes: Scope.REQUEST creates a new instance for each request, and Scope.TRANSIENT gives every consumer its own instance. Use them sparingly; a request-scoped provider makes everything that depends on it request-scoped too.
12. Where DI fits in the request lifecycle
Here's a simplified view of a request:
HTTP Request
↓
Middleware
↓
Guards (e.g. is the user logged in?)
↓
Interceptors
↓
Pipes (e.g. validate the body)
↓
Controller
↓
Service
↓
Repository / Database / External API
↓
ResponseGuards, interceptors and pipes are classes too, and they get their dependencies from the same container. Once DI clicks, the rest of the architecture is just more nodes in the same graph.
The mental model
After learning how NestJS works, I stopped thinking of it as "a framework with lots of decorators". Instead:
NestJS is a system that builds and manages your application's dependency graph.
- Your classes declare what they need, in their constructors.
- Your modules organize those providers and decide what's shared.
- The container connects everything, creating each instance once and injecting it where it's needed.
The pizza maps straight onto a real backend:
Pizza → Dough → Flour + Water + Yeast + Salt
Order → Payment → StripeClient
Inventory → ProductRepository → PostgreSQLThe names change. The principle doesn't.
The important lesson isn't "use @Injectable()". It's this:
Declare what your component needs, keep responsibilities separated, and let the framework manage the object graph.
Once that clicks, IoC, dependency injection, providers, modules, scopes, controllers and the request lifecycle stop feeling like separate features. They become pieces of the same system — and that's when NestJS starts to make sense.