StackPractices
intermediate Por Mathias Paulenko

Inyección de Dependencias: Contenedores en 4 Lenguajes

Implementa inyección de dependencias para escribir código testeable, desacoplado y mantenible en múltiples lenguajes y frameworks.

Visión General

La Inyección de Dependencias (DI) es un patrón de diseño donde los objetos reciben sus dependencias desde fuera en lugar de construirlas internamente. Desacopla componentes, hace el código testeable sin mocks y permite componer servicios con flexibilidad.

Esta receta es el lado de la implementación: cómo cablear inyección por constructor a mano, cuándo un contenedor justifica su presencia, y un contenedor de ~60 líneas que puedes adaptar — más el setup de tests donde la DI da sus frutos. Para la teoría del patrón, sus compromisos y el UML, mira la página de Patrón de Inyección de Dependencias.

Cuándo Usar

  • Cuando escribes tests unitarios que sustituyen servicios reales por dobles de prueba
  • Cuando construyes aplicaciones modulares donde los componentes no deberían conocer implementaciones concretas
  • Cuando manejas grafos de objetos con dependencias transitivas
  • Cuando implementas arquitecturas de plugins o patrones de estrategia

Cuándo NO Usar

  • Scripts y prototipos — el cableado cuesta más que el acoplamiento que elimina
  • Consumidores de una sola dependencia — pasar un argumento por un contenedor es ceremonia sin beneficio
  • Código que solo tendrá una implementación — abstráelo cuando llegue la segunda, no antes

Solución

Los tres primeros ejemplos cubren los estilos de cableado que más verás: inyección por constructor a mano (el 90 % de los casos), inyección por propiedad para dependencias opcionales, y un contenedor de framework haciendo lo mismo mediante anotaciones. Los tres últimos son las partes que la mayoría de los artículos omiten — un contenedor lo bastante pequeño para poseerlo, ciclos de vida con ámbito para el estado por petición, y el contenedor de test donde todo el ejercicio da sus frutos.

Inyección por Constructor (TypeScript)

interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

class UserService {
  constructor(
    private emailService: EmailService,
    private userRepository: UserRepository
  ) {}

  async register(email: string, password: string) {
    const user = await this.userRepository.create({ email, password });
    await this.emailService.send(email, 'Bienvenido', '¡Gracias por registrarte!');
    return user;
  }
}

// Cableado en producción
const userService = new UserService(
  new SendGridEmailService(),
  new PostgresUserRepository()
);

// Cableado en tests
const userServiceTest = new UserService(
  new FakeEmailService(),
  new InMemoryUserRepository()
);

Inyección por Propiedad (Python)

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None: ...

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(f"[LOG] {message}")

class OrderProcessor:
    logger: Logger = ConsoleLogger()  # Default

    def process(self, order: dict) -> None:
        self.logger.log(f"Procesando orden {order['id']}")
        # ...

Contenedor de DI (Java con Spring)

@Service
public class PaymentService {
    private final PaymentGateway gateway;
    private final FraudChecker fraudChecker;

    public PaymentService(PaymentGateway gateway, FraudChecker fraudChecker) {
        this.gateway = gateway;
        this.fraudChecker = fraudChecker;
    }
}

@Configuration
public class AppConfig {
    @Bean
    public PaymentGateway paymentGateway() {
        return new StripeGateway();
    }
}

Contenedor de DI Ligero (TypeScript)

type Factory<T> = () => T;

class DIContainer {
  private factories: Map<string, Factory<any>> = new Map();
  private singletons: Map<string, any> = new Map();
  private scoped: Map<string, any> = new Map();

  registerTransient<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, factory);
  }

  registerSingleton<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, () => {
      if (!this.singletons.has(key)) {
        this.singletons.set(key, factory());
      }
      return this.singletons.get(key);
    });
  }

  registerScoped<T>(key: string, factory: Factory<T>): void {
    this.factories.set(key, () => {
      if (!this.scoped.has(key)) {
        this.scoped.set(key, factory());
      }
      return this.scoped.get(key);
    });
  }

  resolve<T>(key: string): T {
    const factory = this.factories.get(key);
    if (!factory) {
      throw new Error(`No service registered for key: ${key}`);
    }
    return factory();
  }

  beginScope(): void {
    this.scoped.clear();
  }

  endScope(): void {
    this.scoped.clear();
  }
}

// Registro en el composition root
const container = new DIContainer();

container.registerSingleton('db', () => new PostgresPool({ connectionString: process.env.DB_URL }));
container.registerSingleton('emailService', () => new SendGridEmailService(process.env.SENDGRID_KEY));
container.registerScoped('userRepository', () => new PostgresUserRepository(container.resolve('db')));
container.registerScoped('userService', () =>
  new UserService(container.resolve('emailService'), container.resolve('userRepository'))
);

// Uso por petición
container.beginScope();
const userService = container.resolve<UserService>('userService');
await userService.register('user@example.com', 'password');
container.endScope();

Ciclo de Vida con Ámbito y Context Managers (Python)

from contextlib import contextmanager
from typing import TypeVar, Callable, Dict

T = TypeVar('T')

class DIContainer:
    def __init__(self):
        self._factories: Dict[str, Callable] = {}
        self._singletons: Dict[str, object] = {}
        self._scoped: Dict[str, object] = {}

    def register_singleton(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = lambda: self._singletons.setdefault(key, factory())

    def register_scoped(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = lambda: self._scoped.setdefault(key, factory())

    def register_transient(self, key: str, factory: Callable[[], T]) -> None:
        self._factories[key] = factory

    def resolve(self, key: str) -> T:
        if key not in self._factories:
            raise KeyError(f'No service registered for: {key}')
        return self._factories[key]()

    @contextmanager
    def scope(self):
        """Context manager para el ámbito scoped."""
        self._scoped.clear()
        try:
            yield self
        finally:
            self._scoped.clear()

# Uso — con ámbito por petición
container = DIContainer()
container.register_singleton('db', lambda: create_engine('postgresql://localhost/app'))
container.register_scoped('session', lambda: Session(container.resolve('db')))
container.register_transient('user_service', lambda: UserService(container.resolve('session')))

with container.scope() as scoped:
    service = scoped.resolve('user_service')
    service.register('user@example.com', 'password')

Patrones de Testing con DI (TypeScript)

class TestContainer extends DIContainer {
  constructor() {
    super();
    // Sobreescribe servicios reales con fakes
    this.registerSingleton('db', () => new InMemoryDatabase());
    this.registerSingleton('emailService', () => new FakeEmailService());
    this.registerScoped('userRepository', () => new InMemoryUserRepository());
    this.registerScoped('userService', () =>
      new UserService(this.resolve('emailService'), this.resolve('userRepository'))
    );
  }
}

describe('UserService', () => {
  let container: TestContainer;
  let userService: UserService;
  let emailService: FakeEmailService;

  beforeEach(() => {
    container = new TestContainer();
    container.beginScope();
    userService = container.resolve('userService');
    emailService = container.resolve('emailService');
  });

  afterEach(() => {
    container.endScope();
  });

  it('envía email de bienvenida al registrar', async () => {
    await userService.register('user@example.com', 'password');
    expect(emailService.sentEmails).toHaveLength(1);
    expect(emailService.sentEmails[0].to).toBe('user@example.com');
    expect(emailService.sentEmails[0].subject).toBe('Bienvenido');
  });

  it('persiste el usuario en el repositorio', async () => {
    const user = await userService.register('user@example.com', 'password');
    const repo = container.resolve('userRepository') as InMemoryUserRepository;
    expect(repo.users).toHaveLength(1);
    expect(repo.users[0].email).toBe('user@example.com');
  });
});

Cómo Funciona

La DI invierte el control: los componentes dejan de buscar o construir sus dependencias — el contenedor o el llamador se las entrega. Con eso consigues:

  1. Testeabilidad: intercambia servicios reales por fakes sin tocar al consumidor
  2. Flexibilidad: cambia implementaciones en un solo sitio (el registro), no en cada consumidor
  3. Gestión de ciclo de vida: los contenedores poseen singletons, instancias con ámbito y la liberación de recursos
  4. Soporte AOP: decoradores e interceptores envuelven los servicios inyectados de forma transparente

Un contenedor no es más que dos mapas y unas reglas. El registro almacena una factory por clave más una etiqueta de ciclo de vida. La resolución busca la factory y aplica el ciclo de vida: transient la llama siempre, singleton cachea el primer resultado para siempre, scoped lo cachea dentro del ámbito actual (una petición, un test, una unidad de trabajo). El ámbito es solo un segundo mapa que limpias con beginScope/endScope.

La parte sutil es el orden: las factories suelen resolver otras claves — userService resuelve emailService y userRepository al construirse — así que el orden de registro no importa pero el grafo de resolución sí. Valida el grafo completo al arranque (itera todas las claves y resuélvelas en seco) y los registros faltantes fallarán rápido en lugar de explotar a mitad de una petición.

¿Por qué tres ciclos de vida y no dos? Porque los grafos de objetos mezclan ritmos de cambio: un pool de conexiones pertenece a la app (singleton), una sesión de BD pertenece a la petición (scoped), y un objeto comando pertenece a la llamada (transient). Equivocar la etiqueta es el bug clásico de DI — una sesión por petición registrada como singleton sirve los datos de ayer a los usuarios de hoy.

Dos cosas que este contenedor mínimo omite, útiles antes de adaptarlo: la liberación de recursos — un pool singleton necesita close() al apagar, y los contenedores reales ejecutan un hook de dispose para ello — y las factories asíncronas — cargar config y abrir conexiones es async, así que los contenedores de producción aceptan registros async y esperan la resolución.

El bug de ciclo de vida más desagradable es la dependencia cautiva: un singleton que captura una instancia con ámbito y la sobrevive. Supón que reportCache se registra como singleton y su factory resuelve la session con ámbito de petición — la sesión de la primera petición queda atrapada dentro de la caché y cada petición posterior consulta en silencio con una sesión vieja (o cerrada). La regla: las dependencias solo apuntan “hacia abajo” — un servicio scoped puede resolver un singleton, nunca al revés. Contenedores de producción como ASP.NET Core lo detectan y lanzan CaptiveDependencyException; en un contenedor casero, verificar esa dirección en tests es tu red de seguridad.

MomentoQué correQué puede salir mal
RegistroGuarda la factory, no la instanciaLos efectos secundarios se ejecutan aunque el servicio no se use
Validación al arranqueResuelve en seco cada clave una vezSin validación = registros faltantes que explotan a mitad de petición
ResoluciónEjecuta factory + aplica ciclo de vidaResolver en bucle caliente; singleton capturando un scoped
Fin de ámbitoLimpia el mapa scopedInstancias scoped que se filtran entre peticiones si se omite endScope

¿Cableado manual o contenedor?

El cableado manual — new UserService(new FakeEmailService(), new InMemoryRepo()) — gana en transparencia: no hay magia, el IDE muestra cada dependencia, y para apps de menos de ~15 servicios sigue siendo legible. Usa un contenedor cuando el grafo se vuelve transitivo (un servicio a cinco niveles no se cablea en línea), cuando necesitas semántica de ciclo de vida (sesiones de BD con ámbito de petición, reset por test), o cuando el equipo reinventa el mismo cableado una y otra vez. Frameworks como Spring, NestJS o ASP.NET Core ya tomaron la decisión por ti — son contenedores en sí mismos.

Variantes

EnfoqueCaso de UsoCompromiso
ConstructorDependencias obligatoriasMás explícito; mejor para testing
Propiedad/SetterDependencias opcionalesPuede crear objetos parcialmente inicializados
MétodoDependencias por llamadaVerboso; usado para inyección de estrategia
Service LocatorCódigo legacyOculta dependencias; más difícil de testear

Buenas Prácticas

  • Prefiere inyección por constructor: hace las dependencias explícitas e inmutables
  • Evita service locators: ocultan dependencias y dificultan los tests
  • Usa interfaces/protocolos: depende de abstracciones, no de tipos concretos. Consulta Factory Pattern para abstracciones de creación de objetos.
  • Mantén roots de composición superficiales: cablea dependencias en el punto de entrada de la aplicación
  • Evita la obsesión con primitivos: envuelve valores de config en value objects (ApiKey, Timeout)
  • Resuelve en el borde: llama a container.resolve() en controllers/handlers y pasa instancias planas hacia abajo — el código de dominio nunca debería importar el contenedor

Errores Comunes

  1. Explosión de constructores: más de 5 parámetros señala una abstracción faltante
  2. Fugas del contenedor: pasar el contenedor de DI a los servicios anula el propósito — convierte la DI de nuevo en un service locator
  3. Acoplamiento al framework: usa anotaciones estándar (@Inject) cuando sea posible para que cambiar de contenedor no reescriba el código de negocio
  4. Ignorar el ciclo de vida: servicios scoped resueltos como singletons filtran estado entre peticiones — el clásico bug de “el usuario A ve los datos del usuario B”
  5. Dependencias circulares: refactoriza en eventos o un mediador si A depende de B y B de A
  6. Registrar efectos secundarios en factories: una factory que abre una conexión al registrarse la ejecuta aunque el servicio nunca se use — mantén las factories perezosas

Lecturas Adicionales

Preguntas frecuentes

¿La DI solo es para lenguajes orientados a objetos?

No. Los lenguajes funcionales logran el mismo desacoplamiento mediante funciones de orden superior y aplicación parcial — una función que recibe sus "dependencias" como parámetros es inyección por constructor con menos ceremonia.

¿Cuándo usar un contenedor de DI vs. cableado manual?

Cableado manual para apps pequeñas (menos de ~15 servicios) o cuando valoras la ausencia total de magia. Contenedores cuando el grafo se vuelve transitivo, cuando importa el ciclo de vida (singleton/scoped), o cuando un framework ya te da uno.

¿La DI afecta la performance?

Insignificante en runtime — un singleton se resuelve con una búsqueda en un Map. Resuelve una vez en el composition root o en el borde de la petición, nunca por llamada a método en un bucle caliente.

¿Cómo manejo dependencias circulares en DI?

Las dependencias circulares (A necesita B, B necesita A) suelen indicar un problema de diseño. Rompe el ciclo extrayendo la lógica compartida en un tercer servicio C, usando eventos o un mediador para una dirección, o introduciendo resolución perezosa donde un lado recibe una factory en lugar de la instancia:

// Romper un ciclo con una factory
container.registerSingleton('serviceA', () => new ServiceA(container.resolve('serviceB')));
container.registerSingleton('serviceB', () => new ServiceB(() => container.resolve('serviceA')));
¿Esta solución está lista para producción?

Sí. La inyección por constructor es cómo funcionan todos los frameworks modernos (Spring, NestJS, ASP.NET Core, Dagger). El contenedor ligero refleja las tripas de InversifyJS, el ciclo de vida con ámbito coincide con cómo FastAPI maneja sesiones por petición, y un contenedor de test dedicado es práctica estándar.

¿Cuáles son las características de rendimiento de un contenedor?

La resolución de singleton es una búsqueda en Map tras la primera creación — O(1). Transient llama a la factory cada vez, así que lo que importa es el costo de la factory. La validación al arranque añade una pasada puntual por todos los registros. Mantén resolve() fuera de las rutas calientes por petición: resuelve una vez en el borde del handler y pasa las instancias hacia abajo.

¿Cómo depuro problemas de DI?

Registra cada resolución con su clave y el tipo resultante; para dependencias cautivas, registra el ciclo de vida junto a la clave. La mayoría de los contenedores nombran la cadena de dependencias en el error de dependencia circular — léela. Ejecuta la validación del grafo al arranque para detectar registros faltantes antes de que llegue el tráfico. En tests, comprueba que el contenedor de test registra las mismas claves que el de producción.