Dependency injection and decorators, the one that earns its place
Most patterns in this module dissolve into TypeScript language features. Dependency Injection (DI) is
the one that does not — it is a genuine architectural pattern that earns its place, and it is the backbone
of NestJS and Angular, the frameworks the Full-Stack course uses. This lesson is what DI is, why it
matters, and how decorators power it — so that when you meet @Injectable() in real code, you understand
the pattern behind it.
The problem: hard-wired dependencies
Consider a service that needs a database and a logger:
class UserService {
private db = new PostgresDatabase("localhost:5432"); // hard-wired
private logger = new ConsoleLogger(); // hard-wired
findUser(id: string) {
this.logger.log(`Finding ${id}`);
return this.db.query(`SELECT ... ${id}`);
}
}
UserService creates its own dependencies with new. This looks fine and is a real problem:
- You cannot test it in isolation. To test
findUser, you would hit a real Postgres atlocalhost:5432— slow, flaky, and requiring a database. You cannot swap in a fake. - It is tightly coupled.
UserServiceis welded toPostgresDatabaseandConsoleLogger. Change the database, or want a different logger, and you editUserService. - It controls its own dependencies' lifecycles. Every
UserServicemakes its own database connection — wasteful when they should share one.
The dependencies being hard-wired inside the class is the disease. DI is the cure.
The pattern: dependencies are provided, not created
Dependency Injection means a class receives its dependencies from outside (usually through its constructor) rather than creating them itself:
interface Database {
query(sql: string): unknown;
}
interface Logger {
log(message: string): void;
}
class UserService {
constructor(
private db: Database, // provided from outside
private logger: Logger, // provided from outside
) {}
findUser(id: string) {
this.logger.log(`Finding ${id}`);
return this.db.query(`SELECT ... ${id}`);
}
}
// the caller "injects" the dependencies:
const service = new UserService(new PostgresDatabase(), new ConsoleLogger());
Now UserService depends on the interfaces Database and Logger, not concrete classes, and receives
them through its constructor. Look at what this fixes:
- Testable in isolation — inject a fake:
new UserService(fakeDb, fakeLogger). No real database needed; you testfindUser's logic against controlled fakes. - Loosely coupled —
UserServiceworks with anyDatabaseimplementation (Postgres, MySQL, an in-memory one for tests) without changing. - Lifecycle controlled by the caller — the caller decides whether services share one database connection.
This is DI in its plain form — no framework required. Depend on interfaces, and receive implementations
through the constructor. That alone is a huge improvement, and the parameter-properties feature
(constructor(private db: Database) from the classes lesson) makes it concise.
The DI container — the framework's job
Manually wiring dependencies (new UserService(new PostgresDatabase(), new ConsoleLogger())) gets tedious
in a large app — a service needs a repository which needs a database which needs a config... A DI
container automates this: you register what implements each interface, and the container constructs
your objects, injecting the right dependencies automatically. This is what NestJS and Angular provide, and
it is where the decorators come in:
// NestJS
@Injectable() // "register this class with the DI container"
class UserService {
constructor(
private db: Database, // NestJS injects the registered Database automatically
private logger: Logger,
) {}
}
@Module({
providers: [UserService, { provide: Database, useClass: PostgresDatabase }, ConsoleLogger],
})
class AppModule {}
@Injectable() marks UserService as something the container can create and inject. The @Module
declares what implements each dependency. Then, when NestJS needs a UserService, it sees the constructor
wants a Database and a Logger, looks up what is registered for each, constructs them (and their
dependencies, recursively), and injects them — you never write new UserService(...). The container does
all the wiring. This is why NestJS code is full of @Injectable() services with dependencies in the
constructor: it is DI, automated by a container, wired by decorators.
The decorators are load-bearing here for a specific reason: TypeScript's emitDecoratorMetadata option
emits the types of constructor parameters at runtime (normally erased), so the container can read "this
constructor needs a Database" and inject the right thing. This is one of the few places type information
survives to runtime, and it is exactly what makes automatic DI possible — the decorators lesson's
emitDecoratorMetadata in action.
Why DI is the pattern that earns its place
Unlike Strategy (a lambda) or Builder (an object), DI is not replaced by a language feature — it is a genuine architectural decision about how your program is structured:
- It makes code testable by allowing fakes to be injected — the single biggest practical benefit.
- It decouples modules, so they depend on contracts (interfaces), not concrete implementations.
- It centralises configuration and lifecycle — one place decides what implements what and how long it lives.
These are structural properties no function or union provides. DI is the pattern to genuinely understand
for TypeScript backend work, because NestJS is built entirely on it and the Full-Stack course assumes it.
When you write a NestJS service, you are doing DI; understanding the pattern is what turns "copy the
@Injectable() boilerplate" into "I know why this class receives its dependencies this way."
The honest caution
DI is powerful and, like any pattern, over-usable. In a small application, manually passing dependencies (plain constructor injection, no container) is often clearer than adopting a whole DI framework — the container is worth it when the dependency graph is large enough that manual wiring hurts. And DI does not mean "an interface for everything" — introduce an interface when you genuinely need to swap implementations (a real database vs a test fake), not speculatively for every class (the YAGNI point from the whole course). Use DI's core idea — receive dependencies, depend on contracts — everywhere; adopt a DI container when the app is big enough to need it, which for a NestJS backend it is by design.
Check your work
The problem DI solves. Hard-wired dependencies (new inside a class) make it untestable, tightly
coupled, and in control of its dependencies' lifecycles.
What Dependency Injection is. A class receives its dependencies from outside (via the constructor) rather than creating them.
What DI fixes. Testability (inject fakes), loose coupling (depend on interfaces), and caller-controlled lifecycle.
The plain form. Depend on interfaces, receive implementations through the constructor — no framework needed.
What a DI container does. Automatically constructs objects and injects their registered dependencies, recursively — NestJS and Angular provide this.
How decorators power it. @Injectable() registers a class; emitDecoratorMetadata emits constructor
parameter types at runtime so the container knows what to inject.
Why DI earns its place. It is an architectural property (testable, decoupled, centrally configured) no language feature replaces — the backbone of NestJS.
The caution. Manual injection suits small apps; adopt a container when the dependency graph is large; add interfaces where you need to swap implementations, not speculatively.
Practice
- Write a
UserServicethatnews its own database and logger. Explain why you cannot unit-testfindUserwithout a real database. - Refactor it to receive
DatabaseandLogger(interfaces) through its constructor. Inject a fake database and testfindUserin isolation. - Confirm you can swap
PostgresDatabasefor an in-memory implementation without changingUserService. - Explain, in two sentences, what a DI container does that manual wiring does not.
- Read the NestJS
@Injectable()example and describe how the container knows what to inject (hint:emitDecoratorMetadata). - Decide, for an app you know, whether it is big enough to justify a DI container or whether manual injection would be clearer.
- Find a class that
news its own dependencies and refactor it to receive them.
Official documentation
- NestJS — Providers and Dependency Injection — DI as the backbone of the framework.
- Angular — Dependency Injection — The other major DI framework.
- TypeScript — Decorators and metadata —
emitDecoratorMetadata, which powers injection. - Martin Fowler — Inversion of Control — The concept behind DI.
Next: when a pattern is the wrong answer.
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