Dependency Injection Container en TypeScript
Construye un DI container liviano que resuelve dependencias de clases automaticamente, habilitando aplicaciones testeables y debilmente acopladas sin frameworks pesados
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Dependency Injection Container en TypeScript
Implementa un container de dependency injection liviano en TypeScript que resuelve dependencias de clases automaticamente a traves de decorators o metadata de constructores. Este pattern desacopla la creacion de objetos de la logica de negocio, haciendo el codigo testeable, modular y mas facil de refactorizar sin frameworks pesados.
Cuando Usar Esto
- Las clases tienen cadenas de dependencias profundas que hacen la construccion manual tediosa
- Necesitas swapear implementaciones para testing (mocks, stubs)
- El manejo de ciclo de vida de la aplicacion requiere singletons, instancias scoped y disposal
Problema
Un servicio depende de un repositorio, que depende de una conexion a base de datos, que depende de un config loader. Consulta Dependency Injection Pattern para ejemplos independientes del lenguaje. Crear objetos manualmente genera codigo fragil y dificil de testear.
Solucion
1. Container con 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()}`);
// Retorna singleton cacheado si esta disponible
if (entry.singleton) return entry.singleton as T;
// Resuelve dependencias recursivamente
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 con 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. Definiciones de Servicios
// 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 de Aplicacion
// 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());
Como Funciona
- Container almacena registrations mapeando tokens a implementaciones
- Reflect Metadata captura tipos de parametros del constructor en compile time
- @Injectable marca clases que el container puede instanciar
- @Inject sobreescribe tokens de parametros para interfaces o clases abstractas
- resolve crea instancias recursivamente, cacheando singletons
Variacion: 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;
}
}
Consideraciones de Produccion
- Usa
tsyringeoinversifypara produccion en lugar de un container custom - Habilita
emitDecoratorMetadataentsconfig.jsonpara metadata de Reflect - Dispon instancias scoped apropiadamente para prevenir memory leaks en apps de larga duracion
Errores Comunes
- Dependencias circulares que causan recursion infinita durante resolution
- Olvidar llamar
connect()o metodos de inicializacion despues de resolution - Registrar clases concretas cuando se necesitan interfaces o abstracciones
FAQ
P: En que se diferencia de Service Locator? R: Service Locator pide un registro global por dependencias. DI inyecta dependencias a traves de constructores, haciendolas explicitas y testeables. Consulta Dependency Injection Pattern para cobertura mas amplia.
P: Puedo usar esto sin decorators?
R: Si. Usa una factory function o registration manual con arrays de dependencias explicitas: container.register(UserService, { deps: [UserRepository] }).
¿Es este patrón adecuado para proyectos pequeños?
Para proyectos pequeños con pocos componentes, este patrón puede añadir complejidad innecesaria. Empieza simple e introduce el patrón cuando sientas el problema que resuelve.
¿Cómo se compara este patrón con alternativas?
Cada patrón hace diferentes trade-offs. Revisa la tabla de variantes arriba y considera tus restricciones específicas: tamaño del equipo, requisitos de rendimiento y planes de escalado.
¿Puedo aplicar este patrón parcialmente?
Sí. Muchos equipos adoptan patrones incrementalmente. Empieza con la idea central y añade sofisticación según sea necesario. El patrón es una guía, no un blueprint estricto.
Temas Avanzados
Escenario: DI Container para Microservicio
// DI container minimal en 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;
}
}
// Uso: registrar servicios
const container = new DIContainer();
// Singleton: una instancia para toda la app
container.registerSingleton("Database", () => new PostgreSQLConnection({
host: "localhost", port: 5432, max: 20
}));
// Singleton: logger compartido
container.registerSingleton("Logger", () => new WinstonLogger({
level: "info", format: "json"
}));
// Transient: nueva instancia cada vez
container.registerTransient("UserRepository", () => {
const db = container.resolve<DatabaseConnection>("Database");
const logger = container.resolve<Logger>("Logger");
return new UserRepository(db, logger);
});
// Transient: nueva instancia cada request
container.registerTransient("UserService", () => {
const repo = container.resolve<UserRepository>("UserRepository");
return new UserService(repo);
});
// Resolver en el 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);
});
// Tipos de DI
| Tipo | Descripcion | Ejemplo |
|------|-------------|---------|
| Constructor | Deps en constructor | constructor(db: DB) |
| Setter | Deps via setter | service.setDB(db) |
| Interface | Deps via interfaz | @Injectable() |
| Property | Deps en propiedades | @Inject() |
Lecciones:
- DI desacopla la creacion de dependencias del uso
- Singleton para recursos compartidos (DB, logger, cache)
- Transient para objetos por-request (repos, services)
- Constructor injection es la mas segura (deps obligatorias)
- En tests, registra mocks en el container
- Frameworks: tsyringe, InversifyJS, NestJS DI
### Como testeo con DI?
En tests, crea un container separado y registra mocks. Usa registerSingleton para reemplazar DB con una mock, Logger con un spy. Resuelve el servicio bajo test: sus dependencias seran los mocks. Esto permite testear unitario sin tocar DB real. Para integration tests, usa el container real con Testcontainers para DB. Recursos Relacionados
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