LogoCésar Alberca

Entendiendo la Inyección de Dependencias

2026-02-18T14:30:00.000Z

En el número anterior de este boletín hablamos sobre Middlewares Avanzados. Introdujimos el patrón de diseño Cadena de ResponsabilidadSe abre en una nueva pestaña para manejar múltiples middlewares. También introdujimos el concepto de Casos de Uso, Repositorios, Transformadores y otras piezas de software. ¿Qué tienen todas en común? Todas son instanciables.

A medida que el proyecto crece, nos encontramos con más y más clases, y para usar clases, necesitamos crear instancias de ellas. Podríamos encontrarnos manejando las instancias en diferentes lugares así:

import { type FC } from 'react' const useCaseService = new UseCaseService() const httpClient = new HttpClient() const destinationRepository = new DestinationRepository(httpClient) const destinationOrderer = new DestinationOrderer() const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer) export const Comp: FC = () => { return <button onClick={() => useCaseService.execute(getDestinationsQry)}>Click here</button> }

Este patrón no es realmente escalable, ¿vamos a seguir creando y creando las instancias una y otra vez?

Siempre que me encuentro repitiendo mucho del mismo código, busco formas de abstraerlo en piezas reutilizables, y eso es algo con lo que la IA tiene problemas para acertar.

Quizás podríamos exportar desde la misma declaración de clase la instancia:

import { destinationRepository } from './destination-repository' import { destinationOrderer } from './destination-orderer' export class GetDestinationsQry implements UseCase<void, Destination[]> { ... } export const getDestinationsQry = new GetDestinationsQry(destinationRepository, destinationOrderer)

¿Te parece extraño que estemos recibiendo en el constructor las dependencias de la clase? Eso se llama el principio de Inversión de Dependencias, y está relacionado con la Inyección de Dependencias. Te invito a seguir leyendo para descubrir más sobre cómo estos dos principios están relacionados.

Esto podría "arreglar" el problema de duplicar instancias; sin embargo, tenemos varias limitaciones:

  1. Las instancias están hard-coded, por lo que perdemos flexibilidad al cambiar las instancias.
  2. Al importar la clase, entonces se crea la instancia, por lo que perdemos control sobre cuándo se crean las instancias.
  3. ¿Qué pasa si necesitamos varias instancias de la misma clase? ¿Qué pasa si necesitamos cambiar las dependencias durante la ejecución? ¡No hay una forma escalable de soportar estas y más características!

Cuando creamos la instancia en la clase, la estamos contaminando. ¿Por qué? Porque estamos pasando de algo más abstracto (una declaración de clase) a algo concreto (una instancia de clase).

Ahora que hemos descubierto el problema, encontremos una solución. Pero primero, necesitamos hablar sobre la Inversión de Dependencias y cómo encaja en el gran esquema de las cosas.

#El Principio de Inversión de Dependencias

A lo largo de los números anteriores del boletín hemos visto un patrón que podría haber sido pasado por alto:

export class GetDestinationsQry implements UseCase<void, Destination[]> { constructor( private readonly destinationRepository: DestinationRepository, private readonly destinationOrderer: DestinationOrderer, ) {} async handle(): Promise<Destination[]> { return this.destinationOrderer.order( await this.destinationRepository.findAll() ) } }

Cuando declaramos una clase que tiene colaboradores, los recibimos a través del constructor de la clase en lugar de importarlos y usarlos directamente. ¿Por qué? Bueno, todo se trata de flexibilidad.

¿Recuerdas si DestinationRepository es una clase o una interfaz? Bueno, no nos importa ¡y eso es bueno! Solo sabemos que tiene un método llamado findAll. Esto significa que podemos intercambiar la instancia, y mientras el método de DestinationRepository se llame findAll estamos bien.

¡DestinationRepository era de hecho una interfaz! Quizás quieras revisar este patrón en el número del boletín donde hablamos sobre el Patrón Repository.

En esencia, el Principio de Inversión de Dependencias nos permite intercambiar colaboradores siempre que sigan el contrato.

¿Cuándo sería el caso en que podríamos intercambiar las instancias? Durante las pruebas. El Principio de Inversión de Dependencias es sin duda el patrón más importante a seguir si quieres que tus pruebas sean fáciles y reproducibles.

Si tu código es difícil de probar significa que tu código no es lo suficientemente flexible.

Revisemos la prueba para GetDestinationsQry:

import { describe, expect, it } from 'vitest' import { GetDestinationsQry } from './get-destinations.qry' import type { DestinationRepository } from '@/features/destination/domain/destination.repository' import { DestinationMother } from '@/features/destination/tests/destination.mother' import { instance, mock, when } from '@typestrong/ts-mockito' import { DestinationOrderer } from '@/features/destination/destination-list/domain/destination-orderer' describe('GetDestinationsQry', () => { it('should get destinations', async () => { const { getDestinationsQry, destinationRepository } = setup() when(destinationRepository.findAll()).thenResolve([DestinationMother.europe()]) const result = await getDestinationsQry.handle() expect(result).toEqual([DestinationMother.europe()]) }) it('should get empty array if there are no destinations', async () => { const { getDestinationsQry, destinationRepository } = setup() when(destinationRepository.findAll()).thenResolve([]) const result = await getDestinationsQry.handle() expect(result).toEqual([]) }) }) function setup() { const destinationRepository = mock<DestinationRepository>() const getDestinationsQry = new GetDestinationsQry(instance(destinationRepository), new DestinationOrderer()) return { destinationRepository, getDestinationsQry } }

Para pruebas unitarias encuentro que ts-mockitoSe abre en una nueva pestaña es muy agradable para trabajar.

Como puedes ver en la función setup creamos fácilmente los mocks o incluso pasamos los colaboradores reales para crear pruebas de integración.

¿Te preguntas qué es un Mother en Desarrollo de Software? Aquí puedes leer más sobre el Patrón Mother.

No hay necesidad de crear mocks de módulosSe abre en una nueva pestaña que tienden a ser frágiles y no son type-safe.

Habiendo visto el Principio de Inversión de Dependencias ahora podemos explicar cómo el Contenedor de Inyección de Dependencias sinergiza sobre él.

#Contenedor de Inyección de Dependencias

El Contenedor de Inyección de Dependencias tiene la responsabilidad de mantener todas nuestras instancias. Para que sea útil, necesitamos dos cosas:

  1. Registrar instancias
  2. Recuperar instancias

Creemos una interfaz para representar tales operaciones:

export interface Container { register<Instance extends WithInjectionToken<AnyConstructor>>(instance: Instance): void get<Instance extends WithInjectionToken<AnyConstructor>>(key: Instance): InstanceType<Instance> }

Como queremos que el contenedor esté completamente tipado agregué algunos tipos de utilidad. Revisémoslos:

export type InjectionToken = symbol export type WithInjectionToken<T extends AnyConstructor> = T & { readonly ID: InjectionToken } // Este es un tipo muy dinámico que representa cualquier tipo de clase instanciable export type AnyConstructor<T = unknown> = abstract new (...args: any[]) => T

¡Woah! ¿Qué significa symbol? Bueno, necesitamos un identificador único para almacenar clases o incluso valores en el contenedor. También es un nativo de JavaScriptSe abre en una nueva pestaña.

Podrías sentirte tentado a usar nombres de clase como identificadores, ten en cuenta que los empaquetadores tienden a mangle los nombres de clase para acortarlos, lo que esencialmente romperá el Contenedor de Inyección de Dependencias en producción.

¿Cómo vamos a hacer eso? Bueno, así:

export class GetDestinationsQry implements Query<Destination[]> { static readonly ID: InjectionToken = Symbol('GetDestinationsQry') ... }

Entonces, ahora, cada vez que queremos registrar una clase en el contenedor, debemos agregar un ID a la clase. Con eso ahora necesitamos implementar el container:

const globalForApp = globalThis as unknown as { container?: Container } export class AppContainer implements Container { private readonly registry = new Map<InjectionToken, unknown>() static getInstance(): Container { // Reutilizar instancia global si existe globalForApp.celestia ??= new AppContainer() return globalForApp.container } get<Instance extends WithInjectionToken<AnyConstructor>>(key: Instance): InstanceType<Instance> { const token = key.ID if (!this.registry.has(token)) { throw new Error(`Instance with key '${token.toString()}' not found.`) } return this.registry.get(token) as InstanceType<Instance> } register<Instance extends object>(instance: Instance): void { const ctor = instance.constructor as WithInjectionToken<AnyConstructor> if (!('ID' in ctor) || typeof ctor.ID !== 'symbol') { const name = 'name' in ctor ? ctor.name : 'Unknown' throw new Error(`Missing static ID in ${name}`) } this.registry.set(ctor.ID, instance) } } export const container = AppContainer.getInstance()

Este container sigue el Patrón SingletonSe abre en una nueva pestaña, y es una de las muy pocas veces que exporto un valor instanciado de la clase. Para mí, tiene sentido aquí ya que el contenedor es donde voy a almacenar todas las instancias.

También creo un valor global en casos donde podría necesitar acceder al contenedor fuera del Sistema de Módulos ESM, como desde un widget u otros casos de uso avanzados.

Con todo esto configurado, ahora es momento de registrar las dependencias.

#Registrando dependencias

Cuando se crea el contenedor, deberíamos registrar automáticamente artifacts, repositories y use cases. Podemos hacer eso a través del constructor:

export class AppContainer implements Container { private constructor() { this.registerArtifacts() this.registerRepositories() this.registerUseCases() } private registerArtifacts() { const eventEmitter = new EventEmitter() const emptyMiddleware = new EmptyMiddleware() const loggerMiddleware = new LoggerMiddleware() const errorMiddleware = new ErrorMiddleware(eventEmitter) const successMiddleware = new SuccessMiddleware(eventEmitter) const confirmMiddleware = new ConfirmMiddleware(eventEmitter) const middlewares = [ confirmMiddleware, errorMiddleware, loggerMiddleware, successMiddleware ] const useCaseService = new UseCaseService(middlewares, this) this.register(eventEmitter) this.register(emptyMiddleware) this.register(loggerMiddleware) this.register(errorMiddleware) this.register(confirmMiddleware) this.register(successMiddleware) this.register(useCaseService) } private registerRepositories() { const httpClient = this.get(HttpClient) const dateTransformer = this.get(DateTransformer) const destinationApiRepository = new DestinationApiRepository(httpClient, dateTransformer) this.register(destinationApiRepository) } private registerUseCases() { const destinationOrderer = new DestinationOrderer() const destinationApiRepository = this.get(DestinationApiRepository) const getDestinationsQry = new GetDestinationsQry(destinationApiRepository, destinationOrderer) this.register(destinationOrderer) this.register(getDestinationsQry) } }

Hacemos el constructor privado para limitar cómo se crean las instancias. En este caso es a través del método factory estático getInstance(). Si la instancia no está creada, la creará por nosotros.

Prefiero dividir el registro de instancias en diferentes métodos ya que es más fácil de manejar. Si el contenedor crece demasiado, podríamos considerar hacer múltiples contenedores. Ahora es cuestión de recuperar las dependencias.

#Recuperando dependencias

Ahora, la última parte: recuperar las dependencias. Volviendo al primer ejemplo reescribiríamos el componente de la siguiente manera:

import { type FC } from 'react' export const Comp: FC = () => { const useCaseService = container.get(UseCaseService) const getDestinationsQry = container.get(GetDestinationsQry) return <button onClick={() => useCaseService.execute(getDestinationsQry)}>Click here</button> }

O mejor aún, podríamos mover la lógica de acceso al contenedor ahora al UseCaseService:

export class UseCaseService { static readonly ID: InjectionToken = Symbol('UseCaseService') constructor( private readonly middlewares: Middleware[], private readonly container: Container, ) {} execute<T extends UseCase>( useCaseClass: WithInjectionToken<AnyConstructor<T>>, params?: UseCaseParams<T>, options?: UseCaseOptions, ): Promise<UseCaseReturn<T>> { // ¡Aquí! const useCase = this.container.get(useCaseClass) const requiredOptions = options ?? { logLevel: 'info', } let next = UseCaseHandler.create({ useCase, // ¡Y aquí! middleware: this.container.get(EmptyMiddleware), options: requiredOptions, }) for (let i = this.middlewares.length - 1; i >= 0; i--) { next = UseCaseHandler.create({ useCase: next, middleware: this.middlewares[i]!, options: requiredOptions }) } return next.handle(params) as Promise<UseCaseReturn<T>> } }

Con este cambio ahora podemos simplificar el componente:

import { type FC } from 'react' export const Comp: FC = () => { const useCaseService = container.get(UseCaseService) return <button onClick={() => useCaseService.execute(GetDestinationsQry)}>Click here</button> }

¿Qué tan genial es eso?

#Conclusión

Con este Contenedor de Inyección de Dependencias seremos capaces de registrar instancias y recuperarlas. Esto es invaluable para cambiar dependencias durante la ejecución, cambiando vastamente la arquitectura general de nuestras aplicaciones.

  1. ¿Necesitas mockear dependencias al ejecutar pruebas? Carga un TestContainer en lugar del de producción.
  2. ¿Necesitas tener diferentes dependencias en una app multi-tenant, entornos o cualquier otro valor dinámico? Puedes usar variables de entorno para cargar diferentes dependencias o cualquier otra lógica personalizada.
  3. ¿Necesitas retrasar la creación de instancias, crear contextos de jerarquía de multi-inyección o casos de uso más avanzados? Puedes hacerlo extendiendo el contenedor.

Espero que con este número quede claro que un Contenedor de Inyección de Dependencias se empareja bien con el Principio de Inversión de Dependencias y cómo ambos proporcionan escalabilidad y mantenibilidad mientras hacen las pruebas más fáciles.

¿Cómo mejorarías el contenedor? ¡Siéntete libre de responder a este email y te responderé!