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.

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
newpor 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

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
ApplicationContextde Spring o porModuleRefen 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.

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.
