Inyección de dependencias sin magia: lo que @Autowired hace por debajo y por qué me importa entenderlo

Este artículo puede incluir enlaces de afiliado. Si compras a través de ellos, me llevo una pequeña comisión sin coste extra para ti. Más información.

Hay una frase que escucho mucho cuando alguien empieza con Spring: «pones @Autowired y ya funciona». Y es verdad. Funciona. Pero esa frase me pone un poco nervioso.

Porque «ya funciona» es exactamente lo que decimos justo antes de pasar tres horas mirando un NullPointerException sin entender por qué ese bean es null si yo lo he anotado bien, joder.

Llevo años escribiendo Java y he llegado a una conclusión incómoda: confiar en una herramienta y entenderla no son lo mismo. Puedes vivir confiando. Pero el día que algo se rompe, solo te salva entender. Y la inyección de dependencias es uno de esos sitios donde la diferencia se nota mucho.

Qué problema resuelve la inyección de dependencias antes de hablar de Spring

Antes de meternos con anotaciones, conviene parar en el problema. Porque la inyección de dependencias no nació con Spring. Spring solo la hizo cómoda.

Imagina una clase que necesita otra para funcionar. Un servicio que envía emails y necesita un cliente SMTP. La forma ingenua es que el servicio se cree su propia dependencia dentro.

public class NotificadorService {
    private final SmtpClient cliente = new SmtpClient("smtp.servidor.com", 587);

    public void enviar(String destino, String mensaje) {
        cliente.send(destino, mensaje);
    }
}

Esto compila y funciona. El problema llega después. Esa clase está atada de por vida a ese SmtpClient concreto, con esa configuración escrita a fuego dentro.

¿Quieres testearla sin enviar correos de verdad? No puedes, salvo trucos feos. ¿Quieres cambiar el cliente por otro en producción? Toca tocar la clase. La dependencia la decide quien menos debería decidirla: la propia clase que la usa.

La idea de invertir el control es sencilla. La clase no se busca sus dependencias. Las recibe desde fuera. Alguien, en algún momento, decide qué cliente concreto le entrega.

public class NotificadorService {
    private final SmtpClient cliente;

    public NotificadorService(SmtpClient cliente) {
        this.cliente = cliente;
    }

    public void enviar(String destino, String mensaje) {
        cliente.send(destino, mensaje);
    }
}

Fíjate que aquí todavía no hay nada de Spring. Ni una anotación. Esto es Java puro. Y ya tenemos inyección de dependencias por constructor.

Lo que Spring aporta es el «alguien» que decide qué entregar y se encarga de construirlo todo en el orden correcto. Ese alguien es el contenedor. Y ahí empieza lo interesante.

Qué hace @Autowired por debajo realmente

Cuando arranca una aplicación Spring, ocurre algo que mucha gente no visualiza: hay un proceso que escanea tus clases, detecta cuáles son beans, y construye un grafo de quién depende de quién.

Ese proceso vive en el ApplicationContext. Es básicamente un mapa gigante de objetos gestionados. Cuando pides un bean, no se crea en ese momento (salvo lazy): ya estaba ahí, montado durante el arranque.

@Autowired no es magia. Es una pista. Le dice al contenedor: «este punto necesita que le inyecten algo, búscalo en el grafo y métemelo aquí».

cinematic dark workshop, glowing wires connecting metal boxes, dependency graph visualized as light threads
Imagen generada con IA

El mecanismo que lo resuelve es un BeanPostProcessor, en concreto el AutowiredAnnotationBeanPostProcessor. Suena a trabalenguas, pero el nombre lo dice todo: procesa beans, busca anotaciones de autowiring, y rellena lo que toca.

Lo hace por reflexión. Mira los campos, constructores y métodos de tu clase, encuentra los marcados, busca un bean compatible por tipo y lo asigna. Si encuentra uno, perfecto. Si encuentra varios, ahí empiezan los problemas que veremos luego.

La diferencia entre los tres tipos de inyección

Hay tres formas de inyectar, y no son equivalentes. En el artículo What are the Different Ways @Autowired Annotation Can Be Used in Spring Boot? de dev.to las repasan bien, y coincido en casi todo, aunque yo soy más tajante con una de ellas.

La inyección por campo es la que todos hemos usado al empezar porque es la más corta de escribir.

@Service
public class PedidoService {
    @Autowired
    private PagoService pagoService;
}

Es cómoda y es la que yo evito siempre que puedo. ¿Por qué? Porque ese campo no puede ser final, porque para testear sin Spring tienes que recurrir a reflexión, y porque oculta cuántas dependencias tiene la clase.

Una clase con ocho @Autowired de campo no parece mal a simple vista. La misma clase con un constructor de ocho parámetros te grita a la cara que algo huele mal. Y eso es bueno. El dolor visible es información.

La inyección por setter tiene su hueco en dependencias opcionales, pero es rara de ver bien usada. La que defiendo es la del constructor.

@Service
public class PedidoService {
    private final PagoService pagoService;

    public PedidoService(PagoService pagoService) {
        this.pagoService = pagoService;
    }
}

Aquí el campo es final, la dependencia es obligatoria por diseño, y el objeto nunca existe en estado incompleto. Si falta algo, falla al arrancar, no a las dos de la mañana en producción.

Y lo mejor: desde Spring 4.3, si la clase tiene un solo constructor, ni siquiera necesitas poner @Autowired. El contenedor lo deduce. Menos ruido, mismo resultado.

El día que entender el mecanismo me ahorró la noche

Voy a contar una concreta porque ilustra el punto mejor que cualquier teoría. Hace un par de años, en un proyecto con varias implementaciones de una misma interfaz, arrancaba la aplicación y petaba.

El error decía algo así como que había más de un bean candidato del tipo que pedía. Dos clases implementaban la misma interfaz y Spring no sabía cuál inyectar. Por tipo, ambas valían.

Si solo supiera «poner @Autowired y rezar», me habría quedado mirando la pantalla. Pero como entendía que la resolución es por tipo primero y por nombre después, supe exactamente dónde mirar.

La solución pasa por dar una pista extra. O marcas uno como @Primary, o usas @Qualifier para nombrar explícitamente cuál quieres en cada punto.

public PedidoService(@Qualifier("pagoTarjeta") PagoService pagoService) {
    this.pagoService = pagoService;
}

Diez segundos de solución. Pero esos diez segundos vienen de entender qué hace el contenedor cuando hay ambigüedad. Sin ese mapa mental, ese error es un muro. Con él, es un cartel que te indica el camino.

Esa noche me fui a dormir a una hora decente. Y Bella, que duerme conmigo y tiene casi ocho años de experiencia juzgándome desde los pies de la cama, no tuvo que aguantarme dando vueltas. Pequeñas victorias.

«Cualquier tonto puede escribir código que un ordenador entienda. Los buenos programadores escriben código que los humanos pueden entender.»

— Martin Fowler

Lo traigo aquí porque la inyección por constructor va justo de eso. No es que el ordenador necesite el constructor. Funciona igual con campos. Lo necesitamos nosotros, para leer la clase y entender de qué depende sin abrir su tripa entera.

Confiar en el framework no es lo mismo que entenderlo

Hay una tentación grande en nuestro oficio que es tratar el framework como una caja negra que hace cosas buenas. Y los frameworks modernos invitan a eso. Spring Boot es casi mágico de lo bien que esconde la complejidad.

El problema no es usar la abstracción. El problema es no saber que hay una abstracción debajo. Porque las abstracciones tienen fugas. Siempre. Y el día que la tuya gotea, necesitas saber qué hay al otro lado del muro.

Esto me pasa también escribiendo, por cierto. Cuando algo funciona y no sé por qué, no me fío del todo. Prefiero entender el mecanismo antes de apoyar peso encima.

Entender el contenedor de Spring cambia cómo programas. Empiezas a ver el arranque de la aplicación como lo que es: un montaje cuidadoso de objetos en un orden concreto, resolviendo dependencias entre ellos.

Y entonces decisiones que parecían arbitrarias cobran sentido. Por qué la inyección circular es un problema. Por qué un bean @Scope("prototype") dentro de un singleton no hace lo que crees. Por qué el orden de inicialización importa.

La inyección circular, un ejemplo de por qué importa

Dos beans que dependen el uno del otro. A necesita a B en su constructor, y B necesita a A en el suyo. Si entiendes que el contenedor construye objetos en orden, ves el problema al instante.

Para crear A necesita B ya hecho. Para crear B necesita A ya hecho. Es la pescadilla mordiéndose la cola. Con inyección por constructor, Spring no puede resolverlo y falla al arrancar. Y eso, otra vez, es bueno.

Con inyección por campo, en cambio, a veces se las apaña con proxies y trucos, y te deja seguir adelante con un diseño que en realidad está mal pensado. Te oculta el síntoma sin curar la enfermedad.</


Foto: gold and black leather textile por Josh Redd en Unsplash.

¿Quieres más?

Recibe los nuevos artículos sobre gatos, escritura y código. Sin frecuencia fija, sin paja de IA, sin spam.

Suscribirme

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio