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. 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.
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] }).