Spring Bean Scopes: Singleton con Prototypes
Introducción
En el tutorial anterior sobre scopes de beans, cubrí un problema común: cuando inyectas un bean Prototype en un Singleton, el prototype se comporta como singleton. ¿Por qué? Porque Spring inyecta las dependencias cuando crea el Singleton, lo cual ocurre exactamente una vez.
Este artículo presenta cuatro soluciones para obtener un comportamiento Prototype real dentro de un Singleton:
| Enfoque | Mejor para | Compensación |
|---|---|---|
| ObjectProvider | La mayoría de casos | Requiere cambios de código |
| ObjectFactory | Escenarios simples | Menos funcionalidades |
| @Lookup | Beans escaneados por componentes | No funciona con @Bean |
| Scoped Proxy | Scope Request/Session | Nueva instancia por llamada a método |
Este es el código del problema:
@Test
public void getAutowiredSample_na_shouldBeSameInstance() {
// Given
ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
// When
Sample autowiredSample1 = singleton1.getAutowiredSample();
Sample autowiredSample2 = singleton2.getAutowiredSample();
// Then
assertEquals(singleton1, singleton2);
assertEquals(autowiredSample1, autowiredSample2);
assertEquals(autowiredSample1.getUuid(), autowiredSample2.getUuid());
}A pesar de que Sample está definido con scope Prototype, ambas peticiones devuelven la misma instancia.
El prototype se inyectó una vez cuando se creó el Singleton, y esa es la instancia que siempre obtendrás.
Anotación @Lookup
La anotación @Lookup indica a Spring que sobrescriba un método en tiempo de ejecución, delegando a BeanFactory.getBean().
Cada llamada devuelve una instancia nueva cuando el bean destino tiene scope Prototype.
Este es el patrón de uso:
@Lookup
public Sample getSampleUsingLookup() {
return null;
}Advertencia
@Lookup solo funciona con beans descubiertos mediante escaneo de componentes (@Component y sus especializaciones).
No funcionará con métodos @Bean porque Spring necesita crear una subclase del bean para sobrescribir el método.
Este test demuestra @Lookup funcionando correctamente en un bean @Component:
@Test
public void getSampleUsingLookupInComponent_na_shouldBeDifferent() {
// Given
SingletonWithLookupPrototype singleton1 = applicationContext.getBean(SingletonWithLookupPrototype.class);
SingletonWithLookupPrototype singleton2 = applicationContext.getBean(SingletonWithLookupPrototype.class);
// When
Sample lookedupSample1 = singleton1.getSampleUsingLookup();
Sample lookedupSample2 = singleton2.getSampleUsingLookup();
// Then
assertEquals(singleton1, singleton2);
assertNotNull(singleton1);
assertNotNull(singleton2);
assertNotEquals(lookedupSample1, lookedupSample2);
assertNotEquals(lookedupSample1.getUuid(), lookedupSample2.getUuid());
}Las instancias singleton son idénticas (singleton1 == singleton2), pero cada llamada a getSampleUsingLookup() devuelve una instancia fresca de Sample.
Sin embargo, si defines tu bean usando @Bean en lugar de @Component, el método @Lookup no será sobrescrito:
@Bean
@Scope(SCOPE_SINGLETON)
public ClassWithSamplePrototypes singletonWithSamplePrototypes(Sample autowiredSample, ObjectFactory<Sample> sampleObjectFactory) {
return new ClassWithSamplePrototypes(autowiredSample, sampleObjectFactory);
}El método @Lookup devuelve null porque Spring no creó una subclase del bean para sobrescribirlo:
@Test
public void getSampleUsingLookupInRegularBean_na_shouldBeNull() {
// Given
ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
// When
Sample lookedupSample1 = singleton1.getSampleUsingLookup();
Sample lookedupSample2 = singleton2.getSampleUsingLookup();
// Then
assertEquals(singleton1, singleton2);
assertNull(lookedupSample1);
assertNull(lookedupSample2);
}Utilizando ApplicationContext
Otro enfoque es inyectar ApplicationContext en el Singleton y llamar a getBean() cuando necesites una nueva instancia Prototype:
/** ... **/
public class ClassWithSamplePrototypes implements ApplicationContextAware {
/** ... **/
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
/** ... **/
public Sample getSampleUsingApplicationContext() {
return applicationContext.getBean(Sample.class);
}
/** ... **/
}Podemos verificar que funciona con un test:
@Test
public void getSampleUsingApplicationContext_na_shouldBeDifferent() {
// Given
ClassWithSamplePrototypes singleton1 = applicationContext.getBean(ClassWithSamplePrototypes.class);
ClassWithSamplePrototypes singleton2 = applicationContext.getBean(ClassWithSamplePrototypes.class);
// When
Sample applicationContextSample1 = singleton1.getSampleUsingApplicationContext();
Sample applicationContextSample2 = singleton2.getSampleUsingApplicationContext();
// Then
assertEquals(singleton1, singleton2);
assertNotEquals(applicationContextSample1, applicationContextSample2);
assertNotEquals(applicationContextSample1.getUuid(), applicationContextSample2.getUuid());
}Este enfoque funciona, pero acopla tu código directamente al contenedor de Spring.
Las dependencias se solicitan en lugar de inyectarse, lo que contradice la inversión de control.
Para mejor testeabilidad y menor acoplamiento, considera usar ObjectProvider u ObjectFactory en su lugar.
ObjectFactory y ObjectProvider (recomendado)
La solución más limpia es inyectar una factoría en lugar del bean directamente.
Spring proporciona ObjectFactory y su extensión más potente ObjectProvider.
Consejo
ObjectProvider es la opción preferida desde Spring 4.3.
Añade métodos como getIfAvailable(), getIfUnique() y stream() que hacen mucho más limpio el trabajo con beans opcionales o múltiples.
ObjectFactory (enfoque básico)
ObjectFactory es una interfaz funcional simple con un único método: getObject().
Inyéctala en lugar del bean directamente, y Spring proporcionará una factoría que delega al contenedor:
public class SingletonWithObjectFactory {
private final ObjectFactory<Sample> sampleFactory;
public SingletonWithObjectFactory(ObjectFactory<Sample> sampleFactory) {
this.sampleFactory = sampleFactory;
}
public Sample getNewSample() {
return sampleFactory.getObject();
}
}Cada llamada a sampleFactory.getObject() devuelve una instancia nueva cuando el bean destino tiene scope Prototype.
ObjectProvider (enfoque preferido)
ObjectProvider extiende ObjectFactory con métodos de conveniencia adicionales:
public class SingletonWithObjectProvider {
private final ObjectProvider<Sample> sampleProvider;
public SingletonWithObjectProvider(ObjectProvider<Sample> sampleProvider) {
this.sampleProvider = sampleProvider;
}
public Sample getNewSample() {
return sampleProvider.getObject();
}
// ObjectProvider tiene métodos adicionales útiles:
public Sample getOrDefault(Sample defaultSample) {
return sampleProvider.getIfAvailable(() -> defaultSample);
}
}¿Por qué preferir ObjectProvider?
getIfAvailable(): Devuelvenullo un valor por defecto si el bean no existegetIfUnique(): Devuelve el bean solo si existe exactamente un candidatostream(): Itera sobre todos los beans coincidentes- Soporta dependencias opcionales sin lanzar excepciones
En lugar de inyectar el bean directamente, inyectas una interfaz de factoría.
Cada llamada a getObject() delega al contenedor, devolviendo una instancia fresca para beans Prototype.
Este es el test:
@Test
public void getSampleUsingObjectProvider_na_shouldBeDifferent() {
// Given
SingletonWithObjectProvider singleton1 = context.getBean(SingletonWithObjectProvider.class);
SingletonWithObjectProvider singleton2 = context.getBean(SingletonWithObjectProvider.class);
// When
Sample sample1 = singleton1.getNewSample();
Sample sample2 = singleton2.getNewSample();
// Then
assertEquals(singleton1, singleton2);
assertNotEquals(sample1, sample2);
assertNotEquals(sample1.getUuid(), sample2.getUuid());
}La belleza de este enfoque es la testeabilidad. Puedes mockear fácilmente la factoría en tests unitarios sin Spring:
new SingletonWithObjectFactory(() -> new Sample());Scoped Proxy
Con scoped proxies, Spring inyecta un proxy CGLIB en lugar del bean real. El proxy intercepta las llamadas a métodos y delega a una instancia fresca cada vez.
Esto funciona de forma transparente; la clase consumidora no necesita ningún cambio:
@Configuration
public class AppConfig {
@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
public Sample sample() {
return new Sample();
}
@Bean
public SingletonWithProxy singletonWithProxy(Sample sample) {
return new SingletonWithProxy(sample);
}
}La clase singleton no necesita ningún tratamiento especial:
public class SingletonWithProxy {
private final Sample sample;
public SingletonWithProxy(Sample sample) {
this.sample = sample;
}
public String getSampleUuid() {
// Cada llamada al proxy crea una nueva instancia prototype
return sample.getUuid();
}
}Ventajas:
- Cero cambios de código en la clase consumidora
- Configuración simple basada en anotaciones
Desventajas:
- Cada llamada a método crea una nueva instancia (puede ser excesivo para Prototype)
- Requiere CGLIB (o usa
ScopedProxyMode.INTERFACESpara proxies JDK)
Consejo
Los scoped proxies brillan con beans de scope Request y Session, donde el proxy resuelve transparentemente la instancia correcta para el contexto HTTP actual.
Preguntas frecuentes
¿Qué enfoque debería usar?
Empieza con ObjectProvider.
Es el enfoque más flexible, testeable e idiomático.
Usa scoped proxies para beans Request/Session donde quieras resolución transparente.
¿Funcionará @Lookup con métodos @Bean?
No. Spring necesita crear una subclase del bean para sobrescribir el método @Lookup, lo cual solo ocurre con beans escaneados por componentes.
Para definiciones @Bean, usa ObjectProvider en su lugar.
¿Cuál es el impacto en rendimiento de estos enfoques?
Todos los enfoques añaden una sobrecarga mínima.
ObjectProvider y ObjectFactory implican una llamada a getBean().
Los scoped proxies añaden intercepción de métodos vía CGLIB.
En la práctica, la diferencia es insignificante a menos que estés llamando miles de veces por segundo.
¿Puedo usar estas técnicas con beans de scope Request o Session?
Sí, y los scoped proxies son particularmente útiles aquí. Resuelven automáticamente el bean correcto para la petición HTTP o sesión actual sin código de búsqueda explícito.
Conclusión
Cuando inyectas un bean Prototype en un Singleton, el comportamiento por defecto de Spring anula el propósito del scope Prototype. Aquí está el resumen:
| Enfoque | Cuándo usar |
|---|---|
| ObjectProvider | Opción por defecto: limpio, testeable, flexible |
| ObjectFactory | Cuando solo necesitas getObject() |
| @Lookup | Beans escaneados por componentes donde quieres código mínimo |
| Scoped Proxy | Beans Request/Session que necesitan resolución transparente |
Para la mayoría de casos, mi recomendación es ObjectProvider.
Mantiene tu código testeable, no te acopla a las interioridades de Spring, y proporciona métodos útiles para dependencias opcionales.
Para más información sobre patrones de inyección y testing de aplicaciones Spring, consulta mis otros tutoriales.
El código fuente completo con ejemplos ejecutables con JBang está disponible en GitHub.
