Software

Inyección de dependencias: guía práctica para equipos tech

Qué cambia cuando las colaboraciones llegan desde fuera, cuándo conviene constructor, setter o método, contenedor frente a inyección manual, y los anti-patrones que convierten el desacoplamiento en magia.

·17 min·Pedro Cailá · Kulturo
Inyección de dependencias: guía práctica para equipos tech

Un OrderService puede parecer sencillo hasta que su constructor instancia directamente un StripeClient, un SmtpMailer y un PostgresRepository. En cuanto llega el primer test unitario, la prueba intenta abrir conexiones reales, leer variables de entorno y configurar servicios externos antes de comprobar una sola regla de negocio.

Ese fallo no significa necesariamente que la lógica esté mal. Significa que la clase mezcla dos responsabilidades incompatibles: crear colaboradores y usarlos. La consecuencia práctica es conocida: cada prueba necesita infraestructura, los errores apuntan a configuración ajena al caso probado y cualquier cambio en una integración obliga a tocar el servicio.

La estrategia de testing automatizado funciona mejor cuando el código bajo prueba puede recibir sustitutos controlados. La inyección de dependencias cierra precisamente esa grieta. Si las colaboraciones llegan desde fuera, el test puede pasar un repositorio falso, un cliente simulado o un mailer que solo registra llamadas. La prueba se vuelve barata y el fallo empieza a señalar la lógica real.

Por qué un servicio acaba imposible de testear

La creación interna ata toda la clase

Este código concentra demasiadas decisiones:

public final class OrderService {
    private final StripeClient stripe;
    private final SmtpMailer mailer;
    private final PostgresRepository repository;

    public OrderService() {
        this.stripe = new StripeClient(System.getenv("STRIPE_KEY"));
        this.mailer = new SmtpMailer();
        this.repository = new PostgresRepository();
    }

    public void place(Order order) {
        repository.save(order);
        stripe.charge(order.total());
        mailer.sendConfirmation(order.email());
    }
}

El método place expresa una secuencia de negocio, pero el constructor también decide cómo se conecta el sistema de pagos, cómo se envía el correo y dónde se persiste la orden. Esas decisiones quedan codificadas en la clase y no pueden sustituirse sin modificarla.

Un test unitario debería poder concentrarse en una pregunta concreta, por ejemplo, si una orden válida se guarda antes de iniciar el cobro. Con la implementación anterior, esa prueba hereda los problemas del entorno: credenciales ausentes, conexiones no disponibles, tiempos de red y estados externos difíciles de controlar.

La primera corrección es separar responsabilidades

La versión desacoplada declara sus necesidades y recibe las implementaciones:

public final class OrderService {
    private final PaymentGateway payments;
    private final Mailer mailer;
    private final OrderRepository orders;

    public OrderService(
        PaymentGateway payments,
        Mailer mailer,
        OrderRepository orders
    ) {
        this.payments = payments;
        this.mailer = mailer;
        this.orders = orders;
    }

    public void place(Order order) {
        orders.save(order);
        payments.charge(order.total());
        mailer.sendConfirmation(order.email());
    }
}

Ahora el test puede construir el servicio con dobles de prueba:

var payments = new FakePaymentGateway();
var mailer = new RecordingMailer();
var orders = new InMemoryOrderRepository();

var service = new OrderService(payments, mailer, orders);

service.place(order);

assertThat(orders.contains(order)).isTrue();
assertThat(payments.chargedAmount()).isEqualTo(order.total());

La clase no necesita saber si trabaja con Stripe, un simulador o una implementación local. Esa decisión pertenece al punto de composición. La promesa de la inyección de dependencias no es “crear objetos más limpio”, sino eliminar dependencias rígidas para que el código sea mantenible y comprobable, una distinción que también recoge la explicación de Microsoft sobre los fundamentos de la inyección de dependencias.

Regla práctica: si para probar una regla de negocio necesitas levantar una base de datos o hablar con un proveedor externo, revisa primero dónde se crean las dependencias.

Qué es la inyección de dependencias y qué cambia

Piensa en un enchufe. Un aparato eléctrico no fabrica la instalación de la vivienda ni decide cómo llega la corriente hasta la pared. Recibe una conexión compatible y se concentra en hacer su trabajo. En software, una clase bien diseñada recibe los colaboradores que necesita en lugar de construirlos dentro de sus métodos o constructores.

La definición técnica es directa: un objeto recibe sus dependencias desde fuera. Puede hacerlo mediante atributos, setters, interfaces o constructores, aunque la inyección por constructor suele ser la opción más clara. En Java y Jakarta EE, la documentación técnica describe este modelo y contempla también un único constructor anotado con @Inject, lo que permite al contenedor participar en la creación del objeto, como explica esta guía de patrones de inyección de dependencias.

Diagrama explicativo sobre el funcionamiento y los beneficios principales del patrón de diseño inyección de dependencias.

Inversión de control sin confundir conceptos

La inversión de control describe quién decide el flujo de creación y coordinación. Sin este principio, cada objeto tiende a crear sus colaboradores y a controlar su ciclo de vida. Con él, una raíz de composición o un contenedor toma esa responsabilidad y entrega a cada componente lo que necesita.

Conviene separar tres papeles:

  • El componente de negocio declara sus dependencias y las utiliza.
  • La implementación concreta proporciona el comportamiento técnico, como persistencia, correo o pagos.
  • La raíz de composición o el contenedor construye el grafo y decide qué implementación encaja en cada puerto.

La inyección de dependencias es un patrón que ayuda a aplicar inversión de control, pero no requiere obligatoriamente un framework. Se puede escribir de forma manual con new en un único punto de arranque, o delegar el cableado a Spring, CDI, .NET, NestJS u otra herramienta.

Dependencia visible frente a búsqueda implícita

Un OrderService(PaymentGateway payments) comunica su necesidad en la firma. Cualquier lector puede identificarla y cualquier test puede suministrarla. Un service locator, en cambio, suele ocultar la búsqueda:

public void place(Order order) {
    var payments = Services.resolve(PaymentGateway.class);
    payments.charge(order.total());
}

Aquí la dependencia no aparece en el constructor. El código compila, pero el grafo real queda escondido tras una llamada global. Eso complica la lectura, la sustitución y la detección de ciclos.

El cambio importante está en el grafo de objetos. En vez de tener un árbol disperso de new repartido por las clases, el sistema concentra la composición en un lugar configurable. El servicio usa una abstracción, mientras que otra parte decide si recibe Stripe, un adaptador local o un doble de prueba.

Formas de inyectar y cuándo usar cada una

La elección de la técnica debe responder a la naturaleza de la dependencia, no a una preferencia estética. En la mayoría de servicios de negocio, el constructor es el punto de partida correcto porque hace visibles las obligaciones del objeto desde el momento en que nace.

Constructor como opción por defecto

public final class InvoiceService {
    private final InvoiceRepository repository;
    private final TaxCalculator taxes;

    public InvoiceService(
        InvoiceRepository repository,
        TaxCalculator taxes
    ) {
        this.repository = repository;
        this.taxes = taxes;
    }
}

Si falta InvoiceRepository, el objeto ni siquiera puede construirse en un estado aparentemente válido. Esa propiedad evita inicializaciones parciales y favorece campos inmutables. También simplifica los tests, porque el fixture muestra todas las colaboraciones relevantes.

La inyección por setter sí tiene un lugar legítimo. Es útil cuando una colaboración es opcional o puede cambiar durante el ciclo de vida del objeto:

public void setLogger(Logger logger) {
    this.logger = logger;
}

No la usaría para un repositorio obligatorio. Un servicio que puede existir durante un tiempo sin su persistencia trasladará el error a una operación posterior y hará más difícil localizar la causa.

La inyección por interfaz consiste en que el objeto exponga un contrato que otra pieza debe implementar. Es especialmente útil en puertos y adaptadores, donde varias implementaciones cumplen la misma capacidad. En la práctica, el contrato suele aparecer como una interfaz y la clase recibe una implementación compatible, no como una técnica aislada del diseño.

La inyección por método es adecuada cuando una dependencia solo participa en una operación concreta:

public void migrate(EntityManager entityManager, MigrationData data) {
    // Usa EntityManager únicamente durante esta operación
}

El método hace explícito que la colaboración no forma parte del estado permanente del objeto. También evita engordar el constructor de una clase que tiene varias operaciones independientes.

Variante Cuándo usarla Riesgo si se abusa
Constructor Dependencias obligatorias y estables Constructores demasiado grandes que indican una clase con demasiadas responsabilidades
Setter Dependencias opcionales o reconfigurables Objetos parcialmente inicializados
Interfaz Contratos con varias implementaciones y puertos de arquitectura Añadir abstracciones sin una variación real
Método Dependencia limitada a una operación Firmas difíciles de leer si se pasan demasiados colaboradores

La referencia técnica sobre variantes de inyección resume una propiedad importante: la inyección puede hacerse sin contenedor, pasando colaboraciones por constructor o métodos. El contenedor coordina el cableado y, según el entorno, también puede encargarse de ciclos de vida y liberación de recursos.

Contenedores frente a inyección manual con ejemplos reales

Un contenedor no convierte automáticamente un diseño acoplado en uno bueno. Su valor aparece cuando el grafo tiene suficientes componentes, ciclos de vida o configuraciones como para que mantener todo el cableado a mano sea más costoso que entender la herramienta.

Java con Spring

Con Spring, una clase puede declarar su rol y recibir el colaborador mediante constructor:

@Service
public final class OrderService {
    private final PaymentGateway payments;

    public OrderService(PaymentGateway payments) {
        this.payments = payments;
    }
}

La configuración de implementaciones queda fuera del servicio. Spring registra los componentes, resuelve el grafo y administra el ciclo de vida según la configuración de la aplicación. La documentación de Microsoft sobre DI en .NET describe el mismo principio en otro ecosistema, registrar servicios al inicio y resolverlos habitualmente por constructor.

La alternativa manual mantiene el control en la raíz:

public static void main(String[] args) {
    PaymentGateway payments = new StripePaymentGateway(config);
    OrderRepository orders = new PostgresOrderRepository(dataSource);
    OrderService service = new OrderService(payments, orders);
}

Para un servicio pequeño, esta forma es transparente y muy fácil de depurar. En una aplicación grande, el volumen de registros, scopes y adaptadores puede justificar un contenedor.

TypeScript con NestJS

NestJS usa providers tipados y módulos para declarar el grafo:

@Injectable()
export class OrderService {
  constructor(private readonly payments: PaymentGateway) {}
}

La inyección manual sería igualmente válida en una raíz de composición:

const payments = new StripePaymentGateway(config);
const orders = new PostgresOrderRepository(pool);
const service = new OrderService(payments, orders);

El provider de NestJS gana cuando hay módulos con ciclos de vida y varias implementaciones configurables. La construcción manual gana cuando el proceso es corto y el equipo necesita seguir el flujo sin metadata ni resolución dinámica.

Python sin magia innecesaria

La opción directa puede ser un dataclass:

from dataclasses import dataclass

@dataclass
class UserService:
    repository: UserRepository
    mailer: Mailer

    def register(self, user):
        self.repository.save(user)
        self.mailer.send_welcome(user.email)

Un contenedor ligero, como dependency-injector o injector, puede centralizar providers y configuración cuando el proyecto lo necesita. Pero en un job breve, añadir resolución dinámica puede complicar el arranque y el debugging sin eliminar un problema real.

Stack Inyección manual Contenedor DI Cuándo gana cada uno
Spring Wiring explícito en main o una factory Beans, scopes y configuración centralizada El contenedor gana con grafos amplios; manual con servicios pequeños
NestJS Instancias creadas en el composition root Providers y módulos tipados NestJS gana con módulos y ciclos de vida; manual en procesos simples
Python __init__ o dataclass dependency-injector o injector Manual por defecto; contenedor cuando la composición se repite y crece

La decisión también afecta a los despliegues. Un contenedor de aplicación puede ser útil dentro de un servicio gestionado en Kubernetes, pero no hace falta introducirlo solo porque el equipo ya usa una plataforma de orquestación.

Un contenedor debe reducir trabajo visible. Si solo cambia cinco llamadas a new por cinco decoradores y una resolución opaca, probablemente has añadido complejidad, no diseño.

Anti-patrones que arruinan un proyecto

La inyección de dependencias falla cuando se usa para ocultar el diseño en lugar de hacerlo explícito. El síntoma suele aparecer en una revisión de código antes que en producción, pero el coste llega después, cuando un test no puede aislar un componente o un cambio de configuración exige rastrear llamadas globales.

Localizadores y creación escondida

Infografía sobre los principales anti-patrones comunes en el diseño de software utilizando inyección de dependencias.

Un service locator detrás de una fachada estática parece cómodo:

var gateway = Services.resolve(PaymentGateway.class);

El problema es que el constructor deja de contar la historia completa. La corrección mínima consiste en extraer la resolución a una factory o a la raíz de composición y pasar el resultado al servicio.

Otro olor claro es new dentro de la lógica:

public Receipt charge(Order order) {
    var client = new ExternalPaymentClient(apiKey);
    return client.charge(order);
}

El test no puede sustituir el cliente sin interceptar construcción, cambiar el código o hacer una llamada real. La solución es introducir un puerto, como PaymentGateway, y registrar la implementación concreta fuera de la clase.

Configuración y estado mal modelados

Cinco parámetros booleanos en un constructor no representan claridad:

new ReportService(true, false, true, false, true);

El lector no sabe qué significa cada posición y el cambio de una opción puede romper llamadas alejadas. Conviene mover esas opciones a un objeto de configuración tipado con nombres explícitos, o separar comportamientos que pertenecen a políticas distintas.

El autowired sobre campos privados también es una mala señal:

@Autowired
private PaymentGateway payments;

La instancia puede existir antes de estar lista, la dependencia no aparece en la firma y el test necesita reflexión o soporte específico del framework. El constructor comunica el contrato completo y permite crear la clase sin arrancar el contenedor.

Por último, la sobreinyección revela una clase que conoce demasiado. Si un colaborador solo se usa en una rama excepcional de un if, quizá debe vivir en una estrategia separada o recibirse por método. Pedirlo siempre aumenta el acoplamiento de todos los consumidores por una necesidad parcial.

La corrección no es reemplazar todas las clases por interfaces. Es mover cada decisión al lugar que puede cambiarla y mantener la lógica de negocio ajena a detalles de construcción.

Métricas y señales para evaluar si la DI está funcionando

Discutir si un proyecto “usa bien” la inyección de dependencias suele producir opiniones opuestas y poca evidencia. Un equipo puede observar su propio repositorio y medir si el patrón reduce fricción o solo ha multiplicado archivos, bindings y puntos de configuración.

Medir el trabajo que antes costaba

Para cada nuevo test de un caso de uso, registra cuánto tarda el equipo en sustituir sus dependencias. Si preparar un doble exige arrancar el framework, construir un contexto completo o manipular variables globales, el diseño sigue escondiendo parte del grafo.

También puedes observar cuántas líneas de wiring hacen falta para añadir un caso de uso y compararlas con el código de negocio. Una proporción creciente de configuración no es un problema por sí misma, pero sí merece revisión cuando el cableado resulta más difícil de entender que la operación que conecta.

La lista siguiente sirve como punto de partida:

  • Aislamiento del test: mide el tiempo necesario para reemplazar una dependencia externa por un fake o mock.
  • Coste de composición: registra el cambio requerido para añadir una implementación alternativa.
  • Ciclos del grafo: revisa los ciclos detectados por el ApplicationContext de Spring o por ModuleRef en NestJS.
  • Arranque: observa cuánto tarda en construirse el contenedor, especialmente en lambdas y jobs breves.
  • Movilidad del código: prueba si un adaptador puede trasladarse a otro módulo sin arrastrar infraestructura inesperada.

Infografía sobre métricas que demuestran los beneficios de implementar la inyección de dependencias en software.

Interpretar las señales sin perseguir una cifra mágica

Un constructor más corto no demuestra desacoplamiento. Una cobertura alta tampoco prueba que los colaboradores estén bien aislados. La señal útil es que el equipo pueda cambiar un adaptador, crear un test enfocado o mover una clase sin modificar lógica que no debería conocer ese detalle.

La inyección de dependencias también tiene un coste de mantenimiento. Cada binding debe localizarse, documentarse y mantenerse coherente con los ciclos de vida. Si el tiempo de arranque aumenta o los errores de resolución aparecen lejos de la composición, el contenedor puede estar ocultando un diseño demasiado complejo.

La métrica principal no es el número de interfaces. Es el esfuerzo que necesita el equipo para cambiar una colaboración sin tocar el caso de uso.

Qué preguntar a un candidato en una entrevista técnica

Una definición memorizada suele sonar correcta: “la inyección de dependencias desacopla clases y facilita los tests”. La entrevista útil empieza después, cuando el candidato debe decidir dónde aplicar el patrón, reconocer sus costes y trabajar sin el framework que conoce.

Preguntas para detectar criterio

A un perfil junior le pediría que explique qué problema resuelve este código y que lo reescriba con constructor:

public class UserService {
    public void create(User user) {
        new PostgresRepository().save(user);
    }
}

La señal no es que pronuncie “inversión de control”. Es que identifique la creación interna, proponga una abstracción razonable y explique cómo probaría el servicio sin conectarse a Postgres.

A un perfil mid le plantearía una elección: “Tienes una dependencia obligatoria, otra opcional y una tercera que solo necesita una operación. ¿Cómo las inyectarías?”. Una respuesta sólida asigna constructor a la obligatoria, setter o configuración a la opcional y método a la puntual. Una respuesta superficial recomienda siempre @Autowired o siempre constructor sin justificar el ciclo de vida.

Para un perfil senior, las preguntas deben poner presión sobre el diseño:

  • ¿Cuándo no usarías un contenedor? Busca que mencione scripts de un solo uso, prototipos descartables o módulos sin dependencias externas, y que valore el coste de arranque y debugging.
  • Diseña un inyector manual en diez líneas. No importa que el código sea incompleto. Importa que separe composition root, implementación y consumidor.
  • ¿Qué harías con un constructor que tiene demasiadas dependencias? La respuesta madura investiga responsabilidades, no crea un contenedor para esconder el problema.
  • ¿Cómo detectarías un service locator disfrazado? Debe buscar accesos globales, factorías estáticas y resolución desde dentro de la clase.
  • ¿Cómo aislarías un cliente de modelo, un prompt y una cola en un servicio async? Aquí interesa si entiende que DI también debe separar componentes no deterministas, observabilidad y runtime dinámico, no solo repositorios CRUD.
Seniority Pregunta clave Señal que revela
Junior ¿Qué dependencia está creada dentro del método y cómo la sustituirías? Comprensión inicial de acoplamiento y testabilidad
Mid ¿Qué técnica elegirías para dependencias obligatorias, opcionales y puntuales? Criterio sobre constructor, setter y método
Senior ¿Cuándo no introducirías un contenedor? Capacidad para valorar coste, contexto y complejidad
Senior ¿Cómo aislarías un cliente de IA y un sistema de observabilidad? Aplicación del patrón fuera del backend clásico
Senior ¿Qué harías ante un grafo con ciclos? Lectura arquitectónica y capacidad de rediseño

La conversación debe centrarse en decisiones observables. El candidato fuerte distingue acoplamiento de conveniencia, reconoce cuándo una interfaz no aporta variación y puede explicar el coste de mantener bindings. El candidato que solo conoce frameworks tiende a convertir cada problema en una anotación.

Para preparar una entrevista técnica completa, conviene complementar estas preguntas con una guía de preguntas para una entrevista técnica, pero la evaluación debe conservar una prueba práctica de diseño. Pedir que modifique código existente revela más que solicitar una definición aislada.

Cuándo aplicar el patrón y qué leer después

Introduce inyección de dependencias cuando una colaboración tiene más de una implementación, cuando los tests necesitan dobles o cuando el equipo debe entender rápidamente cómo se compone un módulo. También merece la pena en límites claros entre dominio, infraestructura, proveedores externos y servicios async, donde sustituir una pieza afecta directamente a la seguridad del despliegue y a la reproducibilidad de las pruebas.

No la fuerces en un script de un solo uso, un prototipo que se va a descartar o un módulo pequeño sin dependencias externas. En esos casos, pasar objetos manualmente puede ser suficiente. Añadir un contenedor antes de tener un problema de composición solo crea una nueva superficie que mantener.

Para profundizar, empieza por Dependency Injection Principles, Practices and Patterns, de Mark Seemann. Después, revisa los capítulos sobre inversión de control de Clean Architecture, de Martin, y contrástalos con la documentación oficial de Spring y NestJS. El siguiente paso práctico es elegir un servicio real, extraer sus creaciones internas a la raíz de composición y medir si el primer test aislado resulta más sencillo.


Si estás contratando perfiles backend, platform o AI/ML y necesitas distinguir experiencia real en desacoplamiento de conocimiento superficial de frameworks, visita Kulturo. Su equipo especializado en recruiting técnico puede ayudarte a definir la prueba, evaluar el razonamiento del candidato y encontrar profesionales capaces de mantener arquitecturas que evolucionan sin esconder complejidad.

Tenemos el profesional que necesitas

Cuéntanos qué perfil buscas y te enviamos candidatos en menos de una semana.

Empieza a contratar