Dependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
Implement a lightweight dependency injection container in TypeScript that resolves class dependencies automatically through decorators or constructor metadata. This pattern decouples object creation from business logic, making code testable, modular, and easier to refactor without heavy frameworks.
When to Use This
- Classes have deep dependency chains that make manual construction tedious
- You need to swap implementations for testing (mocks, stubs)
- Application lifecycle management requires singletons, scoped instances, and disposal
Problem
A service depends on a repository, which depends on a database connection, which depends on a config loader. See Dependency Injection Pattern for language-agnostic examples. Creating objects manually creates brittle, hard-to-test code.
Solution
1. Container with Token Registration
// di/Container.ts
type Constructor<T> = new (...args: unknown[]) => T;
class Container {
private registry = new Map<symbol, { impl: Constructor<unknown>; singleton?: unknown }>();
register<T>(token: symbol, impl: Constructor<T>): this {
this.registry.set(token, { impl });
return this;
}
resolve<T>(token: symbol): T {
const entry = this.registry.get(token);
if (!entry) throw new Error(`No registration for token: ${token.toString()}`);
// Return cached singleton if available
if (entry.singleton) return entry.singleton as T;
// Resolve dependencies recursively
const params = Reflect.getMetadata('design:paramtypes', entry.impl) || [];
const deps = params.map((param: symbol) => this.resolve(param));
const instance = new (entry.impl as Constructor<T>)(...deps);
entry.singleton = instance;
return instance;
}
}
2. Injectable Decorator with Metadata
// di/Injectable.ts
import 'reflect-metadata';
const INJECTABLE_KEY = Symbol('injectable');
function Injectable<T extends Constructor<unknown>>(target: T): T {
Reflect.defineMetadata(INJECTABLE_KEY, true, target);
return target;
}
function Inject(token: symbol) {
return function (target: unknown, _propertyKey: string | symbol, parameterIndex: number) {
const existing = Reflect.getMetadata('design:paramtypes', target) || [];
existing[parameterIndex] = token;
Reflect.defineMetadata('design:paramtypes', existing, target);
};
}
3. Service Definitions
// services/Database.ts
const DB_TOKEN = Symbol('Database');
@Injectable
class Database {
private connection: unknown;
connect(): void {
this.connection = { status: 'connected' };
}
query(sql: string): unknown[] {
return [{ id: 1, name: 'Alice' }];
}
}
// services/UserRepository.ts
const REPO_TOKEN = Symbol('UserRepository');
@Injectable
class UserRepository {
constructor(@Inject(DB_TOKEN) private db: Database) {}
findAll(): unknown[] {
return this.db.query('SELECT * FROM users');
}
}
// services/UserService.ts
const SERVICE_TOKEN = Symbol('UserService');
@Injectable
class UserService {
constructor(@Inject(REPO_TOKEN) private repo: UserRepository) {}
getUsers(): unknown[] {
return this.repo.findAll();
}
}
4. Bootstrap Application
// main.ts
const container = new Container();
container.register(DB_TOKEN, Database);
container.register(REPO_TOKEN, UserRepository);
container.register(SERVICE_TOKEN, UserService);
const userService = container.resolve<UserService>(SERVICE_TOKEN);
console.log(userService.getUsers());
How It Works
- Container stores registrations mapping tokens to implementations
- Reflect Metadata captures constructor parameter types at compile time
- @Injectable marks classes that the container can instantiate
- @Inject overrides parameter tokens for interfaces or abstract classes
- resolve creates instances recursively, caching singletons
Variation: Scoped Lifetime
// di/ScopedContainer.ts
class ScopedContainer {
private parent: Container;
private scoped = new Map<symbol, unknown>();
resolve<T>(token: symbol): T {
if (this.scoped.has(token)) return this.scoped.get(token) as T;
const instance = this.parent.resolve<T>(token);
this.scoped.set(token, instance);
return instance;
}
}
Production Considerations
- Use
tsyringeorinversifyfor production instead of a custom container - Enable
emitDecoratorMetadataintsconfig.jsonfor Reflect metadata - Dispose scoped instances properly to prevent memory leaks in long-lived apps
Common Mistakes
- Circular dependencies that cause infinite recursion during resolution
- Forgetting to call
connect()or initialization methods after resolution - Registering concrete classes when interfaces or abstractions are needed
Troubleshooting
- Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
- Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
- Tight coupling after refactoring: check that interfaces are stable and dependencies point inward.
- Tests break when the design changes: favor stable contracts over internal structure.
- Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the dependency-injection and typescript guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply dependency injection container in typescript when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Advanced Topics
Scenario: DI Container for Microservice
// Minimal DI container in TypeScript
type Constructor<T = unknown> = new (...args: unknown[]) => T;
class DIContainer {
private services = new Map<string, { factory: () => unknown; singleton: boolean; instance?: unknown }>();
registerTransient<T>(token: string, factory: () => T): void {
this.services.set(token, { factory, singleton: false });
}
registerSingleton<T>(token: string, factory: () => T): void {
this.services.set(token, { factory, singleton: true });
}
resolve<T>(token: string): T {
const service = this.services.get(token);
if (!service) throw new Error(`Service not found: ${token}`);
if (service.singleton) {
if (!service.instance) {
service.instance = service.factory();
}
return service.instance as T;
}
return service.factory() as T;
}
}
// Usage: register services
const container = new DIContainer();
// Singleton: one instance for the entire app
container.registerSingleton("Database", () => new PostgreSQLConnection({
host: "localhost", port: 5432, max: 20
}));
// Singleton: shared logger
container.registerSingleton("Logger", () => new WinstonLogger({
level: "info", format: "json"
}));
// Transient: new instance each time
container.registerTransient("UserRepository", () => {
const db = container.resolve<DatabaseConnection>("Database");
const logger = container.resolve<Logger>("Logger");
return new UserRepository(db, logger);
});
// Transient: new instance per request
container.registerTransient("UserService", () => {
const repo = container.resolve<UserRepository>("UserRepository");
return new UserService(repo);
});
// Resolve in handler
app.get("/api/users/:id", (req, res) => {
const userService = container.resolve<UserService>("UserService");
const user = await userService.findById(req.params.id);
res.json(user);
});
// DI types
| Type | Description | Example |
|------|-------------|---------|
| Constructor | Deps in constructor | constructor(db: DB) |
| Setter | Deps via setter | service.setDB(db) |
| Interface | Deps via interface | @Injectable() |
| Property | Deps in properties | @Inject() |
Lessons:
- DI decouples dependency creation from usage
- Singleton for shared resources (DB, logger, cache)
- Transient for per-request objects (repos, services)
- Constructor injection is safest (mandatory deps)
- In tests, register mocks in the container
- Frameworks: tsyringe, InversifyJS, NestJS DI
### How do I test with DI?
In tests, create a separate container and register mocks. Use registerSingleton to replace DB with a mock, Logger with a spy. Resolve the service under test: its dependencies will be the mocks. This enables unit testing without touching real DB. For integration tests, use the real container with Testcontainers for DB.
## Common Production Pitfalls
- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern's performance and error propagation. Frequently Asked Questions
How is this different from the Service Locator?
Service Locator asks a global registry for dependencies. DI injects dependencies through constructors, making them explicit and testable. See Dependency Injection Pattern for broader coverage.
Can I use this without decorators?
Yes. Use a factory function or manual registration with explicit dependency arrays: container.register(UserService, { deps: [UserRepository] }).
Related Resources
Singleton Pattern
Ensure a class has only one instance and provide global access to it. A creational design pattern for controlled object creation.
PatternFactory Pattern
Create objects without specifying the exact class to instantiate. A creational design pattern for flexible object creation.
RecipeWrite Unit Tests with Mocks and Stubs
How to isolate code under test using mock objects, stubs, and spies to replace external dependencies like databases, APIs, and file systems.
PatternAbstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface
PatternBridge Pattern for Decoupling UI Components from Themes
Separate an abstraction from its implementation so both can vary independently using the Bridge pattern for pluggable UI themes and rendering engines
PatternCommand Pattern with Undo/Redo in TypeScript
Implement the Command pattern to encapsulate requests as objects, enabling undo/redo operations, request queuing, and operation logging