Dependency Injection at Scale
🧩 Dependency Injection at Scale
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
Can't test without a real database and a real mail server.
Constructor Injected
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:
Wiring it up at the composition root:
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
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.
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.
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.
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.
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.