RizTech Academy logo
RizTech Academy
Design Patterns in TypeScriptLesson 4 of 530 min

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 at localhost:5432 — slow, flaky, and requiring a database. You cannot swap in a fake.
  • It is tightly coupled. UserService is welded to PostgresDatabase and ConsoleLogger. Change the database, or want a different logger, and you edit UserService.
  • It controls its own dependencies' lifecycles. Every UserService makes 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 test findUser's logic against controlled fakes.
  • Loosely coupled — UserService works with any Database implementation (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

  1. Write a UserService that news its own database and logger. Explain why you cannot unit-test findUser without a real database.
  2. Refactor it to receive Database and Logger (interfaces) through its constructor. Inject a fake database and test findUser in isolation.
  3. Confirm you can swap PostgresDatabase for an in-memory implementation without changing UserService.
  4. Explain, in two sentences, what a DI container does that manual wiring does not.
  5. Read the NestJS @Injectable() example and describe how the container knows what to inject (hint: emitDecoratorMetadata).
  6. Decide, for an app you know, whether it is big enough to justify a DI container or whether manual injection would be clearer.
  7. Find a class that news its own dependencies and refactor it to receive them.

Official documentation

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