You've written @Injectable() dozens of times. But if someone asked you what it actually does, could you explain it without saying "it enables dependency injection"?
I used to treat NestJS like a magic box.
Write a decorator, add it to a module, and somehow the service appeared with all its dependencies already wired. A controller got its service. The service got its repository. The repository got a database connection. I had no idea what was happening underneath, and for a while, I didn't care.
Then I hit a circular dependency error in production. The stack trace was useless. The NestJS docs told me what a circular dependency was, but not why the container couldn't resolve it, or what the container was even doing when it tried. I had no mental model. I was just guessing.
So I sat down and built a container from scratch.
This article is everything I learned. By the end, decorators like @Injectable(), @Inject(), and @Module() should stop feeling like rituals you copy from tutorials and start feeling like obvious answers to specific problems. We're going from zero — a class that creates its own dependencies — all the way to a working container with factory registration, singleton scope, reflection-based injection, circular dependency detection, and provider definitions.
Let's start with the problem.
1. Why the Naive Approach Breaks
Start with something simple. You're building a user module. You need a database connection, a repository to fetch users, and a service with business logic:
class Database {}
class UserRepository {
private db = new Database();
}
class UserService {
private repo = new UserRepository();
}
This runs. But look at what's actually happening: every class creates its own dependencies internally. UserRepository decides how Database gets constructed. UserService decides how UserRepository gets constructed.
Try to test UserService in isolation. You want to replace UserRepository with a mock. You can't — it's hardcoded inside. Try to swap Database for a different implementation. You'd have to dig into every class that references it and change the new Database() call.
The business logic and the object construction logic are tangled together. That's the real problem. Not the classes themselves — the way they're organized.
The restaurant that doesn't scale
Imagine a restaurant where every waiter grows vegetables, bakes bread, and cooks food before serving a customer. One table? Fine. Five customers at once? The whole thing collapses.
A real restaurant separates concerns: suppliers bring ingredients, chefs prepare the food, waiters serve it. The waiter doesn't know how the vegetables were grown. The chef doesn't drive to the farm. Each role has a clear boundary.
Our code has the same structural problem. Each class is acting as the waiter, the chef, and the farmer simultaneously.
2. Inversion of Control
The fix is called Inversion of Control (IoC). The idea is simple: instead of a class creating what it needs, something external creates it and hands it over.
Before IoC — class controls its own dependencies:
UserRepository
└── creates Database internally
After IoC — external container controls everything:
Container
├── creates Database
└── creates UserRepository(Database)
The responsibility for object creation moves outward. Classes no longer manage their own dependencies. A container does. That's the inversion.
3. Dependency Injection: How the Container Delivers
Once the container controls creation, it needs a way to give dependencies to the classes that need them. That's Dependency Injection.
Instead of a class creating its dependency:
class UserRepository {
private db = new Database(); // internal creation
}
The class declares what it needs through the constructor:
class UserRepository {
constructor(private db: Database) {} // receives it from outside
}
The constructor becomes a contract. The class is saying: "I need a Database. I don't care where it comes from." The container answers: "I'll handle that."
That's DI. Not a design pattern, not a framework feature — just a way of expressing dependencies through the constructor so something external can satisfy them.
4. Manual DI: Better, But It Doesn't Scale
Before building a container, do this manually. Wire everything up in one place:
const db = new Database();
const repo = new UserRepository(db);
const service = new UserService(repo);
Already better. Object creation is centralized. You can swap implementations for testing. But now imagine a real system:
const db = new Database();
const logger = new Logger();
const config = new ConfigService();
const email = new EmailService(config, logger);
const userRepo = new UserRepository(db, logger);
const userService = new UserService(userRepo, email, logger);
const userController = new UserController(userService);
const paymentService = new PaymentService(config, logger);
// ...keeps going
The startup file becomes a maintenance nightmare. Add a new dependency to any class and you have to come back here and update the wiring by hand. Tests need to replicate all this setup. Bugs get introduced in bootstrap code that nobody wants to touch.
This is exactly why containers exist.
5. What a Container Actually Is
Here's the thing nobody explains clearly: a container is not magic. It's a registry with logic.
At its core, a container is just a map:
Map<Token, ProviderDefinition>
That's it. A token (the identifier) maps to a provider definition (the instructions for how to create the thing). Everything else — reflection, singleton caching, circular detection — is logic that sits on top of that map.
Think of it as three layers:
Smart Registry
+
Object Factory
+
Lifecycle Manager
Registry: Stores the mapping between tokens and provider definitions.
Object Factory: Reads the provider definition and creates the object, including resolving sub-dependencies recursively.
Lifecycle Manager: Decides whether to return a cached instance (singleton) or create a fresh one (transient).
That's the full picture. When you call container.resolve(UserService), those three layers work together. The registry finds the definition, the factory builds the object graph, the lifecycle manager decides whether to cache the result.
Here's a minimal but real implementation of that idea:
class Container {
private registry = new Map<any, any>(); // Token → ProviderDefinition
private singletons = new Map<any, any>(); // Token → Cached instance
register(definition: ProviderDefinition) {
this.registry.set(definition.token, definition);
}
resolve<T>(token: any): T {
// Layer 3: Lifecycle check first
if (this.singletons.has(token)) {
return this.singletons.get(token);
}
// Layer 1: Registry lookup
const definition = this.registry.get(token);
if (!definition) throw new Error(`No provider registered for token`);
// Layer 2: Object factory
const instance = this.createFromDefinition(definition);
// Layer 3: Cache the result (singleton scope)
this.singletons.set(token, instance);
return instance;
}
private createFromDefinition(def: ProviderDefinition): any {
if ("useValue" in def) return def.useValue;
if ("useFactory" in def) return def.useFactory(this);
if ("useClass" in def) {
const deps = this.getDependencies(def.useClass);
return new def.useClass(...deps);
}
}
private getDependencies(cls: any): any[] {
const paramTypes = Reflect.getMetadata("design:paramtypes", cls) || [];
const customTokens = Reflect.getMetadata("custom:inject", cls) || [];
return paramTypes.map((type: any, i: number) =>
this.resolve(customTokens[i] ?? type)
);
}
}
This is roughly 40 lines. Every DI framework in existence — NestJS, Spring, Angular, TSyringe, InversifyJS — is a more sophisticated version of this same structure. The sophistication comes from edge cases, performance optimization, module scoping, and error handling. But the core is always: registry + factory + lifecycle.
6. Why Framework Authors Actually Built This
Here's the context most articles skip.
Without a container, a large application might have 200 service classes. Each one creates its own logger. That's 200 logger instances, all doing the same thing, consuming memory separately.
Each class creates its own database client. Now you have 200 database connections open simultaneously. Your database crashes.
Each class creates its own config service. That's 200 objects reading the same environment variables and parsing the same JSON files at startup.
The container centralizes ownership. One logger, one database client, one config service — shared everywhere. The container creates each thing once and hands it out to whoever needs it.
That's why Spring was invented. Java enterprise applications in the early 2000s had hundreds of beans (their word for injectable services), and manually wiring all of them was becoming impossible. Spring's IoC container solved that problem. Two decades later, NestJS brought the same solution to Node.js with TypeScript syntax.
7. Building the First Real Container
The first version: a map that stores factories (not instances).
type Token<T> = new (...args: any[]) => T;
type Factory<T> = () => T;
class Container {
private factories = new Map<Token<any>, Factory<any>>();
register<T>(token: Token<T>, factory: Factory<T>) {
this.factories.set(token, factory);
}
resolve<T>(token: Token<T>): T {
const factory = this.factories.get(token);
if (!factory) throw new Error(`No provider found for ${token.name}`);
return factory();
}
}
Why store a factory instead of an instance?
// Storing the instance — application controls creation
container.register(Database, new Database());
// Storing the factory — container controls creation
container.register(Database, () => new Database());
The factory is a recipe. The container decides when to cook, how many times, and whether to reuse the result. Storing an instance gives up that control.
8. Singleton Scope (One Object, Shared Everywhere)
Call resolve(Database) twice without caching:
const db1 = container.resolve(Database);
const db2 = container.resolve(Database);
console.log(db1 === db2); // false — two separate objects
For a database client, that's a problem. You don't want a new connection every time something needs the database. Add a cache:
class Container {
private factories = new Map<Token<any>, Factory<any>>();
private instances = new Map<Token<any>, any>(); // cache
register<T>(token: Token<T>, factory: Factory<T>) {
this.factories.set(token, factory);
}
resolve<T>(token: Token<T>): T {
if (this.instances.has(token)) {
return this.instances.get(token); // return cached
}
const factory = this.factories.get(token);
if (!factory) throw new Error(`No provider found for ${token.name}`);
const instance = factory();
this.instances.set(token, instance); // cache it
return instance;
}
}
Now:
const db1 = container.resolve(Database);
const db2 = container.resolve(Database);
console.log(db1 === db2); // true
This is singleton scope. Create once, reuse forever.
Think of a building's water tank — one tank, shared by everyone. Nobody builds a new tank for each person who wants water.
Singleton is the right default for stateless services: database clients, loggers, config services, cache clients. They're expensive to create, they don't hold per-user state, and there's no reason to duplicate them.
But singleton has a real danger. Never use it for objects that carry per-request or per-user state:
class ShoppingCart {
items = [];
}
If this is singleton, User A adds an item and User B sees it. This bug is invisible in development when you're the only user, and catastrophic in production when you're not. The container will do exactly what you tell it to. Make sure you're telling it the right thing.
That's why NestJS defaults to singleton but also offers Scope.TRANSIENT (new instance every time) and Scope.REQUEST (one instance per HTTP request) for cases where shared state is dangerous.
The mental model that matters:
Creation strategy ≠ Lifetime strategy
(useClass / useValue / useFactory) (singleton / transient / request)
These are two independent decisions. useClass: UserService tells the container how to build the object. Scope.DEFAULT tells the container how long to keep it. You configure them separately, and frameworks that collapse them into one thing make both harder to reason about.
9. Constructor Injection and the Dependency Graph
Now the real challenge. The container can create Database. But what about this?
class UserRepository {
constructor(private db: Database) {}
}
To create UserRepository, the container needs Database first. And for UserService:
class UserService {
constructor(private repo: UserRepository) {}
}
The full dependency graph looks like this:
UserService
└── UserRepository
└── Database
Without automatic resolution, you'd write:
container.register(UserRepository, () =>
new UserRepository(container.resolve(Database))
);
That works, but you're manually spelling out every dependency. The whole point of a container is to figure this out automatically. Which means the container needs to read a class's constructor and discover what it needs.
10. Why Interfaces Disappear at Runtime
This is the exact moment most developers get confused about @Inject(), so let's be precise about it.
You write this in TypeScript:
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User>;
}
Now compile it. Open the JavaScript output. The interface is gone. Completely. Not compiled differently — just absent. JavaScript has no concept of interfaces. TypeScript's type system exists only at compile time.
The same thing happens with constructor parameter types:
// TypeScript
class UserService {
constructor(private repo: UserRepository) {}
}
// Compiled JavaScript
class UserService {
constructor(repo) {} // ← UserRepository is gone
}
The parameter name (repo) survives. The type (UserRepository) doesn't.
So when the container looks at the compiled JavaScript and asks "what does UserService need?", it sees repo with no type information. It can't resolve what to inject.
This is why @Inject() exists — and we'll get to it after setting up reflection.
11. Reflection Metadata
TypeScript has a way to preserve type information at runtime, but you have to opt into it.
Two compiler options:
{"experimentalDecorators":true,"emitDecoratorMetadata":true}
Install:
npm install reflect-metadata
Import once at startup:
import "reflect-metadata";
With emitDecoratorMetadata on, TypeScript embeds constructor parameter types into the compiled output. You read them back like this:
Reflect.getMetadata("design:paramtypes", UserRepository);
// Returns: [Database]
Now the container can see that UserRepository needs Database. It doesn't have to guess.
Think of it as a blueprint. The class itself is the house. The metadata is the blueprint that says "this house needs these materials." The builder (the container) reads the blueprint and sources what's needed.
One important note: this only works when the class has at least one decorator on it. That's actually one of the real reasons @Injectable() exists — not just to mark the class, but to trigger metadata emission on it. Without a decorator, TypeScript doesn't emit the metadata even if emitDecoratorMetadata is on.
12. Recursive Resolution (Walking the Graph)
With reflection, the container can now automatically discover and build dependency chains.
Here's the resolution flow for UserService:
resolve(UserService)
→ read metadata → needs [UserRepository]
→ resolve(UserRepository)
→ read metadata → needs [Database]
→ resolve(Database)
→ read metadata → needs nothing
→ create Database ✓
→ create UserRepository(Database) ✓
→ create UserService(UserRepository) ✓
The container walks the graph bottom-up. It creates the lowest-level dependency first, assembles upward, and ultimately hands you back a fully-constructed UserService.
You call one line:
const service = container.resolve(UserService);
And the container builds the entire tree. This is the core mechanism behind both NestJS and Spring.
13. @Injectable() — The Badge, Not the Magic
Most developers think @Injectable() enables dependency injection. It doesn't.
The real question it answers is: should this class participate in the dependency graph at all?
A real codebase has hundreds of classes — entities, DTOs, utilities, helpers, event handlers, value objects. The container shouldn't try to manage all of them. It needs a way to know which classes have opted in.
@Injectable() is that opt-in marker.
Think of a hotel. Not everyone in the building is staff. Guests, delivery people, contractors, employees — they all walk the same hallways. The employee badge doesn't create the employee. It tells the system who belongs to the operational structure.
Here's the full implementation:
const INJECTABLE_KEY = Symbol("injectable");
function Injectable(): ClassDecorator {
return (target) => {
Reflect.defineMetadata(INJECTABLE_KEY, true, target);
};
}
The decorator runs, stores one boolean on the class, and does nothing else. When the container resolves a class, it checks:
const isInjectable = Reflect.getMetadata(INJECTABLE_KEY, token);
if (!isInjectable) {
throw new Error(`${token.name} is not marked @Injectable()`);
}
That's the whole thing. No magic. A metadata flag that the container checks before proceeding.
14. @Inject() — Solving the Interface Problem
Remember how TypeScript interfaces disappear at runtime? Here's when that actually causes a problem.
Your service depends on a UserRepository interface:
constructor(private repo: UserRepository) {}
After compilation, the type is gone. The metadata shows Object — useless to the container. Same issue with primitive values:
constructor(private url: string) {}
Metadata shows String. But which string? The database URL? The Redis host? The container has no way to know.
@Inject() solves this by attaching an explicit token to a parameter:
const DATABASE_URL = Symbol("DATABASE_URL");
class MysqlUserRepository {
constructor(@Inject(DATABASE_URL) private url: string) {}
}
Now the container doesn't guess. You're telling it directly: "For this parameter, look up what's registered under DATABASE_URL."
The warehouse analogy: two boxes on a shelf, both the same size and brown. One has USB cables, one has Ethernet cables. No label — the worker guesses and gets it wrong half the time. @Inject() is the label.
Here's what @Inject() actually stores:
function Inject(token: any): ParameterDecorator {
return (target, _, parameterIndex) => {
const existing = Reflect.getMetadata("custom:inject", target) || [];
existing[parameterIndex] = token;
Reflect.defineMetadata("custom:inject", existing, target);
};
}
And when resolving, the container checks this metadata first, falling back to reflection only when no override exists:
const customTokens = Reflect.getMetadata("custom:inject", target) || [];
const paramTypes = Reflect.getMetadata("design:paramtypes", target) || [];
const tokens = paramTypes.map((type, i) => customTokens[i] ?? type);
So @Inject() doesn't mean "this object is this interface." It means "for this parameter, use this token." That's a crucial distinction.
15. Provider Definitions: useClass, useValue, useFactory
Once you have custom tokens, you need a way to tell the container what each token should resolve to. Provider definitions answer that question.
A provider definition is one record in the registry:
type ProviderDefinition =
| { token: any; useClass: new (...args: any[]) => any }
| { token: any; useValue: any }
| { token: any; useFactory: (container: Container) => any };
There are three forms.
useClass — map a token to an implementation
{
token: USER_REPOSITORY,
useClass: MysqlUserRepository
}
When something asks for USER_REPOSITORY, the container creates a MysqlUserRepository. Your business code depends on the abstract token. The concrete implementation is registered in one place.
Want to switch to Postgres? Change MysqlUserRepository to PostgresUserRepository in that one registration. Zero changes to business code. This is dependency inversion from SOLID — the D — in practice.
useValue — return a value directly
{
token: DATABASE_URL,
useValue: 'postgres://localhost:5432/mydb'
}
No construction. No dependency graph. The container returns this value as-is. Perfect for configuration, API keys, feature flags, environment variables — data, not services.
useFactory — run a function to produce the value
{
token: DATABASE,
useFactory: (container) => {
const url = container.resolve(DATABASE_URL);
return createConnection(url);
}
}
Some things can't be created with new. Database connections need a URL. Redis clients need a host and port. HTTP clients need base URLs and auth headers. A factory handles that setup logic and can itself resolve other dependencies from the container.
16. Circular Dependency Detection
Now for the failure case the container must catch.
class UserService {
constructor(private orderService: OrderService) {}
}
class OrderService {
constructor(private userService: UserService) {}
}
The container tries to resolve UserService, which needs OrderService, which needs UserService, which needs OrderService... infinite recursion. Stack overflow.
Two coworkers: Alice says she'll start after Bob finishes. Bob says he'll start after Alice finishes. Nothing ever happens. That's exactly what this looks like inside the container.
The fix is a "currently resolving" set:
private resolving = new Set<any>();
resolve<T>(token: Token<T>): T {
if (this.resolving.has(token)) {
throw new Error(
`Circular dependency detected while resolving ${token.name}`
);
}
this.resolving.add(token);
// ... resolve dependencies and create instance ...
this.resolving.delete(token);
return instance;
}
When the container starts resolving UserService, it adds it to the set. When it then tries to resolve OrderService and that tries to resolve UserService again — it's already in the set. Error thrown immediately. Clear message.
But here's what actually matters: circular dependencies are almost never a container problem. They're a design problem. The container is just the thing that catches it first.
When UserService and OrderService need each other, ask why. Usually it means both services share some responsibility that belongs in a third, more focused service. Extract that logic, and both services depend on the shared abstraction instead of each other. The cycle disappears, and the design gets cleaner.
Before (circular): After (clean):
UserService ←→ OrderService UserService OrderService
↓ ↓
SharedDomainService
Treat the circular dependency error as design feedback. Something in the service boundary is wrong. The container is just the first thing to tell you.
17. @Module() — Organization, Not Injection
@Module() confuses people because it doesn't create objects or inject anything. So what does it actually do?
It organizes providers into feature boundaries.
@Module({
providers: [UserService, UserRepository],
imports: [DatabaseModule],
exports: [UserService],
})
class UserModule {}
Think of a university department. The Computer Science department doesn't teach classes or grade assignments. It organizes the people who do. The department is the structure, not the actor.
@Module() is the same. It's a folder for your dependency graph.
Under the hood, it stores metadata:
function Module(metadata: { providers?: any[], imports?: any[], exports?: any[] }): ClassDecorator {
return (target) => {
Reflect.defineMetadata("module:providers", metadata.providers ?? [], target);
Reflect.defineMetadata("module:imports", metadata.imports ?? [], target);
Reflect.defineMetadata("module:exports", metadata.exports ?? [], target);
};
}
During bootstrap, the container reads that metadata and registers each provider. The imports field lets you compose modules — the UserModule can pull in everything from DatabaseModule without re-declaring each dependency. The exports field controls what's visible to other modules that import this one.
That module system is how NestJS keeps large applications organized. Instead of one global registry with 500 providers, you have isolated feature modules that explicitly declare their public surface.
18. NestJS and Spring: Same Architecture, Different Syntax
Many Node developers don't realize NestJS didn't invent any of this. Spring solved the same problem in Java 20 years earlier. The architecture is nearly identical — only the syntax differs.
Spring NestJS
──────────────────────────────────────────────
@Component @Injectable()
@Autowired @Inject()
@Configuration + @Bean Provider definition (useFactory)
ApplicationContext Container / ModuleRef
singleton (default scope) Scope.DEFAULT (singleton)
prototype scope Scope.TRANSIENT
request scope Scope.REQUEST
@SpringBootApplication bootstrapModule(AppModule)
NestJS also drew heavily from Angular's DI system for its decorator syntax and module structure. So it's Spring's IoC ideas, expressed with Angular's decorator approach, running on Node.js. Once you see that lineage, a lot of NestJS design decisions make immediate sense.
19. The Complete Flow: From Bootstrap to Resolution
Here's the full picture of what happens when your application starts and when you resolve a service.
At application bootstrap:
bootstrapModule(AppModule)
│
▼
Read @Module metadata
│
▼
Find all providers (including imported modules)
│
▼
Register each provider definition in the registry
│
▼
Container is ready — nothing created yet
At resolution (lazy creation):
container.resolve(UserService)
│
▼
Is it in the singleton cache? → Yes → return cached instance
│ No
▼
Is it currently being resolved? → Yes → Circular dependency error
│ No
▼
Find provider definition in registry
│
▼
Is the class @Injectable()? → No → Error
│ Yes
▼
Read constructor metadata (reflection + @Inject overrides)
│
▼
Recursively resolve each dependency
│
▼
Create instance with resolved dependencies
│
▼
Store in singleton cache
│
▼
Remove from "currently resolving" set
│
▼
Return instance
That's the entire machine. Every NestJS application runs through some version of this flow at startup, and again each time a new dependency is requested.
20. Putting It All Together
Here's a full example with every concept in play:
import "reflect-metadata";
const USER_REPOSITORY = Symbol("USER_REPOSITORY");
const DATABASE_URL = Symbol("DATABASE_URL");
@Injectable()
class MysqlUserRepository {
constructor(@Inject(DATABASE_URL) private url: string) {
console.log(`Connecting to: ${url}`);
}
findById(id: string) {
return { id, name: "Tanzim" };
}
}
@Injectable()
class UserService {
constructor(@Inject(USER_REPOSITORY) private repo: MysqlUserRepository) {}
getUser(id: string) {
return this.repo.findById(id);
}
}
@Module({
providers: [
{ token: DATABASE_URL, useValue: "postgres://localhost:5432/mydb" },
{ token: USER_REPOSITORY, useClass: MysqlUserRepository },
UserService,
],
})
class UserModule {}
// Bootstrap and resolve
const container = new Container();
container.loadModule(UserModule);
const service = container.resolve(UserService);
console.log(service.getUser("123")); // { id: "123", name: "Tanzim" }
Resolution path:
UserService
↓
@Inject(USER_REPOSITORY)
↓
useClass → MysqlUserRepository
↓
@Inject(DATABASE_URL)
↓
useValue → "postgres://localhost:5432/mydb"
One call to container.resolve(UserService) and the entire chain builds itself.
What This Means for Debugging Real Problems
Before building a container, when something went wrong with dependency injection I'd read the error message and start guessing. "Maybe I forgot @Injectable(). Maybe there's a circular dependency. Maybe the module isn't imported."
After? I read the error and know exactly what the container was doing when it failed.
"Nest can't resolve dependencies of UserService (?)" — The ? means the container inspected the constructor, found a parameter, and the metadata was either missing or unresolvable. The fix is almost always a missing @Inject() on an interface-typed parameter, or a missing emitDecoratorMetadata flag.
"A circular dependency has been detected (UserService -> OrderService -> UserService)" — The container added UserService to its resolving set, then tried to resolve OrderService, which tried to re-resolve UserService, and found it already in the set. The error trace shows the exact cycle. Don't use forwardRef() as the default fix — first ask whether the services should depend on a shared abstraction instead.
"Cannot determine the module associated with a component in the current context" — The container resolved a class that wasn't registered in any module the current module can see. Either the provider is missing from providers, or the module that owns it isn't listed in imports, or the owning module doesn't export the provider.
These aren't random errors anymore. Each one maps to a specific point in the resolution flow described above.
What Changes After You Build This
Before going through this exercise, I read NestJS internals and saw unfamiliar words:
InstanceWrapper
Injector
Provider
Module
NestContainer
Scope
After? Each one maps directly to something I built. InstanceWrapper is the container's internal representation of a provider definition plus its singleton cache slot. Injector is the recursive resolver. NestContainer is the registry. Module is the metadata grouping with import/export resolution.
Spring works the same way under different names. BeanDefinition is the provider definition. BeanFactory is the container. ApplicationContext extends it with module-level features. @Component is @Injectable(). @Autowired does what @Inject() does.
The names differ. The architecture doesn't.
The Real Lesson
Frameworks aren't magic. They're well-engineered solutions to a problem that every large application eventually faces:
How do you create, connect, and manage hundreds of objects without turning your startup code into an unmaintainable mess?
The answer evolved into what we now call an IoC container, and the key ideas are:
- Tokens identify dependencies without coupling to concrete implementations
-
Provider definitions describe how to create each dependency (
useClass,useValue,useFactory) - Reflection lets the container discover dependency relationships automatically
-
@Inject()overrides reflection when types disappear at runtime (interfaces, primitives) - Scopes control object lifetime independently from object creation
- Modules organize providers into feature boundaries with explicit public interfaces
- Circular detection catches bad design before it causes a stack overflow
Two mental models worth keeping:
Creation ≠ Lifetime
Token ≠ Implementation
Creation is how something is built (useClass, useFactory). Lifetime is how long it lives (singleton, transient). They're independent decisions that frameworks often conflate.
A token is an identifier. An implementation is what gets created when you ask for it. Separating them is what makes it possible to swap MysqlUserRepository for PostgresUserRepository without touching a single line of business code.
Once these ideas click, you stop reading NestJS or Spring as a collection of decorators. You start reading it as a runtime that solves object lifecycle management at scale. And that's when you go from someone who uses the framework to someone who can reason about it, debug it, and extend it.
The best way to actually get there: build it yourself. Start with a map. Add factory registration. Add singleton caching. Add reflection-based injection. Add circular detection. Each step takes 30 minutes, and each step teaches you something the framework documentation will never explain.
Published by a developer who got confused by a circular dependency error in production and decided to understand the system instead of just fixing the immediate bug.