Dependency Injection sin misterio: un contenedor de objetos simple para JavaScript moderno
Fecha de publicación: 2023-07-31IoC reduce el acoplamiento del código JavaScript: un módulo no importa estáticamente una implementación concreta, sino que recibe los objetos necesarios desde fuera. Este es un recorrido didáctico hacia un contenedor de objetos pequeño. Se refiere a JavaScript ES6+ sin transpilación, no a decoradores TypeScript.
El objetivo no es una biblioteca universal lista para usar, sino comprender la mecánica: cargar módulos ES, crear dependencias e insertarlas en el lugar adecuado. El contenedor de demostración ocupa unas 35 líneas.
1. Composition Root
Con control directo, el servicio importa su logger:
import logger from './logger.js';
export default class Service {
exec(opts) { logger.info(JSON.stringify(opts)); }
}
Con inversión, el servicio recibe la dependencia por constructor:
export default class Service {
constructor(logger) {
this.exec = (opts) => logger.info(JSON.stringify(opts));
}
}
Alguien sigue teniendo que importar y crear los objetos. Ese lugar es el Composition Root:
import logger from './logger.js';
import Service from './service.js';
const service = new Service(logger);
service.exec({ name: 'Composition Root' });
2. Fábricas
Para simplificar la demostración, cada módulo exporta una fábrica asíncrona que recibe dependencias y produce el resultado:
export default async function factory(logger) {
return (opts) => logger.info(JSON.stringify(opts));
}
En código real un export puede ser clase, función u objeto; este acuerdo solo aclara el siguiente paso.
3. Especificación de dependencias
Los nombres de argumentos comunes pueden cambiar con minificación. Por eso se pasan dependencias en un solo objeto:
function factory({ logger, config }) { /* ... */ }
Al principio se puede usar temporalmente la ruta del módulo como clave:
export default async function factory({ ['./logger.js']: logger }) {
return (opts) => logger.info(JSON.stringify(opts));
}
Así el contenedor recibe la información que debe analizar.
4. Analizador de especificación
El ejemplo simplificado convierte una función en texto, extrae parámetros con una expresión regular y obtiene las claves. Es un recurso didáctico, no una recomendación de producción: en un contenedor real el contrato debe declararse mediante metadatos explícitos y fiables.
5. Contenedor
El contenedor importa recursivamente módulos, encuentra dependencias, construye la especificación y guarda resultados en caché:
const cache = {};
async function get(key) {
if (cache[key]) return cache[key];
const { default: factory } = await import(key);
const spec = {};
for (const path of parse(factory.toString())) spec[path] = await get(path);
return (cache[key] = await factory(spec));
}
Ahora el Composition Root se reduce a una línea:
const service = await container.get('./service.js');
6. Resolver y enlace tardío
Las rutas en la especificación aún atan el código a detalles. El paso final deja únicamente abstracciones:
export default async function factory({ logger, config }) {
return (opts) => logger.info(`${config.appName}: ${JSON.stringify(opts)}`);
}
La correspondencia entre abstracción e implementación se mueve a un mapa de root:
container.setMap({
service: './service.js',
logger: './logger.js',
config: './config.js',
});
const service = await container.get('service');
Eso es enlace tardío: al iniciar se puede elegir un logger de consola o archivo sin reescribir el servicio. El código pasa de ser una estructura «soldada» a una «atornillada»: un módulo se separa y reutiliza con más facilidad.
En un proyecto grande nadie mantiene manualmente un mapa para cada nombre. Las convenciones convierten Vendor_Package_Mod en una ruta como node_modules/@vendor/package/src/Mod.js; el identificador incluso puede incluir ciclo de vida o ámbito.
Conclusión
DI resulta especialmente útil con muchos archivos y paquetes: reduce acoplamiento y mejora pruebas. En frontend tradicional los bundlers suelen ocultar módulos individuales, pero en una PWA Service Worker puede descargar y cachear conjuntos de archivos ES. Así el mismo principio de enlace funciona en Node.js y navegador. Para producción conviene usar un contenedor maduro como @teqfw/di, no el parser didáctico del artículo.
Fragmentos adicionales de código fuente
export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
}
import fLogger from ‘./logger.js’;
import fService from ‘./service.js’; const logger = await fLogger();
const serv = await fService({[‘./logger.js’]: logger});
serv({name: ‘The Spec’});
function Factory(
{
}
) { }
function parser (def) {
const res = [];
const parts = /function Factory({(.)})./s.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
res.push(path); } } return res; }; const paths = parser(factory.toString());
const deps = {}; const FN = /function Factory({(.)})./s;
function parser(def) {
const res = [];
const parts = FN.exec(def);
if (parts?.[1]) {
const deps = parts[1].split(‘,’);
for (const dep of deps) {
const left = dep.split(‘:’)[0];
const path = left.trim()
res.push(path);
}
}
return res;
}
async function get(key) {
if (deps[key]) return deps[key];
else {
const {default: factory} = await import(key);
const def = factory.toString();
const paths = parser(def);
const spec = {};
for (const path of paths)
spec[path] = await get(path);
const res = factory(spec);
deps[key] = res;
return res;
}
}
export default {get};
import container from ‘./container.js’; const serv = await container.get(‘./service.js’);
serv({name: ‘The Object Container’});
import logger from ‘./logger.js’; export default function {
logger.info(Service is running with: ${JSON.stringify(opts)});
}
export default async function Factory({[‘./logger.js’]: logger}) {
return function (opts) {
logger.info(Service is running with: ${JSON.stringify(opts)});
};
} In both cases, we use “details” (speaking in terms of the Dependency Inversion Principle). In this step, we remove the details from the dependency specification and leave only abstractions:
export default async function Factory({logger, config}) {
return function (opts) {
logger.info(Service '${config.appName}' is running with: ${JSON.stringify(opts)});
};
}
import container from ‘./container.js’; const map = {
};
container.setMap(map);
const serv = await container.get(‘service’);
serv({name: ‘The Resolver’});