Dependency Injection at Scale

TypeScript Real World Applications — Dependency Injection at Scale
TypeScript Real World Applications
Course 3 · Chapter 1 · Dependency Injection at Scale

🧩 Dependency Injection at Scale

You've mastered the type system — now it's time to build applications that survive contact with a real team and a real codebase. Dependency Injection (DI) is the pattern that keeps large TypeScript applications testable and swappable instead of a tangle of hardcoded new calls. This chapter builds a type-safe DI container from scratch, then shows how the same ideas map onto production libraries.

The Problem: Tight Coupling

Small scripts get away with instantiating dependencies directly. Large applications don't — every hardcoded new is a hidden dependency that's hard to test, hard to swap, and hard to trace:

Hardcoded vs Injected Dependencies

Tightly Coupled
class OrderService { private db = new PostgresDatabase(); private mailer = new SmtpMailer(); placeOrder(order: Order) { this.db.save(order); this.mailer.send(order.email); } }

Can't test without a real database and a real mail server.

Constructor Injected
class OrderService { constructor( private db: Database, private mailer: Mailer ) {} placeOrder(order: Order) { this.db.save(order); this.mailer.send(order.email); } }

Pass a fake Database and Mailer in tests — no real infrastructure needed.

Constructor Injection

Dependencies arrive as constructor parameters. The class never knows or cares how they were built.

Interface, Not Implementation

Depend on Database the interface, not PostgresDatabase the class — swap implementations freely.

Service Locator (Anti-Pattern)

Pulling dependencies from a global registry inside a class hides what it actually needs. Prefer passing them in explicitly.

Composition Root

One place (usually your app's entry point) wires every dependency together. Everywhere else just receives what it needs.

Building a Type-Safe DI Container

A DI container is a registry that knows how to build things. The core challenge in TypeScript is keeping the registry type-safe — resolving a token should return the correct type, not any:

// A token carries the type it resolves to, purely at compile time class Token<T> { constructor(public readonly name: string) {} } type Factory<T> = () => T; class Container { private factories = new Map<Token<unknown>, Factory<unknown>>(); register<T>(token: Token<T>, factory: Factory<T>): void { this.factories.set(token, factory); } resolve<T>(token: Token<T>): T { const factory = this.factories.get(token) as Factory<T> | undefined; if (!factory) { throw new Error(`No registration for ${token.name}`); } return factory(); } }

Wiring it up at the composition root:

const DatabaseToken = new Token<Database>("Database"); const MailerToken = new Token<Mailer>("Mailer"); const container = new Container(); container.register(DatabaseToken, () => new PostgresDatabase()); container.register(MailerToken, () => new SmtpMailer()); const orderService = new OrderService( container.resolve(DatabaseToken), // typed as Database container.resolve(MailerToken) // typed as Mailer );

Decorator-Based DI (InversifyJS / tsyringe Style)

Hand-rolled containers work, but at scale most teams reach for a decorator-based library — building on the decorators you saw in Course 2. The shape looks like this:

Decorator-Style Injection

@injectable() class PostgresDatabase implements Database { save(order: Order) { /* ... */ } } @injectable() class OrderService { constructor( @inject("Database") private db: Database, @inject("Mailer") private mailer: Mailer ) {} } // The container reads the decorator metadata and resolves the graph for you const orderService = container.resolve(OrderService);

The trade-off: decorators need reflect-metadata and a container library, but they eliminate the manual wiring you saw above — the container walks the constructor's dependency list for you.

🕸️ Managing Complex Object Graphs

Lifetimes: Singleton vs Transient

Once you have more than a handful of services, how long an instance lives matters as much as how it's built:

Singleton

One instance shared everywhere — a database connection pool, a config object, a logger.

Transient

A fresh instance every time it's resolved — request-scoped services, per-operation helpers.

type Lifetime = "singleton" | "transient"; class Container { private factories = new Map<Token<unknown>, { factory: Factory<unknown>; lifetime: Lifetime }>(); private singletons = new Map<Token<unknown>, unknown>(); register<T>(token: Token<T>, factory: Factory<T>, lifetime: Lifetime = "transient") { this.factories.set(token, { factory, lifetime }); } resolve<T>(token: Token<T>): T { const entry = this.factories.get(token) as { factory: Factory<T>; lifetime: Lifetime } | undefined; if (!entry) throw new Error(`No registration for ${token.name}`); if (entry.lifetime === "singleton") { if (!this.singletons.has(token)) { this.singletons.set(token, entry.factory()); } return this.singletons.get(token) as T; } return entry.factory(); } }

Module Initialization Order

A container doesn't remove the need to think about order — it just makes the order explicit and centralized instead of scattered across import side-effects:

Lazy Resolution

Factories don't run until resolve() is called — so registration order in the composition root usually doesn't matter.

Circular Dependencies

A depends on B, B depends on A — the container throws instead of infinite-looping. Break the cycle by extracting a shared interface or an event.

Async Initialization

Some services (a DB connection, a config fetch) need to be awaited before they're ready — register an async factory and await container.resolve() at startup.

Single Composition Root

Wire everything in one place (main.ts / app.ts), not scattered register() calls throughout the codebase.

💻 Coding Challenges

Challenge 1: Refactor to Constructor Injection

Take a class that hardcodes new Logger() and new HttpClient() internally, and refactor it to receive both through its constructor, typed against interfaces rather than concrete classes.

Goal: Practice extracting interfaces and removing hidden new calls.

→ Solution

Challenge 2: Build a Minimal Container

Implement a Container class with generic Token<T>, register(), and resolve(), then use it to wire up two services with a shared dependency.

Goal: Understand how tokens keep resolve() type-safe without casting to any.

→ Solution

Challenge 3: Add Singleton Lifetimes

Extend your container from Challenge 2 to support a "singleton" lifetime alongside the default "transient" one, and verify a singleton resolves to the same instance every time.

Goal: Practice caching instances and reasoning about object lifetime in a container.

→ Solution

⚠️ Gotcha: Don't Reach for a Library Too Early

A full DI framework (InversifyJS, tsyringe, NestJS's built-in container) pays off once you have dozens of services and real testing pain. For a small app, a hand-rolled container like the one above — or even just passing dependencies through plain constructors — is often enough. Add the framework when the manual wiring actually starts to hurt, not before.

🎯 What's Next

With a type-safe way to wire dependencies together, the next chapter puts it to use: Testing in TypeScript — unit testing with Jest, mocking the interfaces you just designed, and writing type-safe test helpers.