Description
Affects: <Spring Framework version 5.3.31>
Spring discriminates public beans by implicit bean name and injected parameter name. If multiple public beans match an unqualified injection parameter, Spring matches the parameter name to the implicit bean name that it derives from the @Bean
function name.
However, we find different behavior when the beans involved are Kotlin internal
. Spring fails to use implicit bean names and injection parameter names to discriminate between multiple Kotlin internal
beans that match an injection parameter type. Instead, NoUniqueBeanDefinitionException
is thrown.
Our project has many Kotlin @Bean
functions declared internal
to restrict their visibility to the declaring module. Many of those beans are of internal
types because we want visibility of those types restricted to the declaring module. When a bean's type is internal
, the @Bean
function must also be internal
.
Our developers found that, to make Spring discriminate between multiple such internal
beans of the same type, we must provide both explicit @Bean
name Strings and injection parameter qualifier Strings (e.g. @Named("someBeanName")
).
We find it counterintuitive that Kotlin internal
bean implicit names and injection behave differently than public beans. Preferably, they should behave the same. Else, document the different behavior. Apologies if such documentation exists; we have not found it.
Minimal reproducible example:
SpringInternalBeanFunctionBugReproducerTest.kt
:
package springinternalbeanfunctionbugreproducer
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import javax.inject.Named
/**
* Demonstrates that Spring 2.7.18 fails to use implicit bean names
* and injected parameter names to discriminate between multiple
* Kotlin 1.9.21 `internal` beans of the same type.
*
* When multiple `internal` beans match an injected parameter type,
* we found it necessary to provide explicit `@Bean` name Strings
* AND qualifiers on the injected parameters to make Spring discriminate between
* the beans.
*
* Public beans do not require that. Prefer that `internal` beans behave
* the same, else perhaps Spring should document the different behavior.
*/
@SpringBootTest
internal class SpringInternalBeanFunctionBugReproducerTest {
companion object {
interface PublicInterface
class PublicClass : PublicInterface
internal interface InternalInterface1
internal class InternalClass1 : InternalInterface1
internal interface InternalInterface2
internal class InternalClass2 : InternalInterface2
}
@Configuration
class TestConfig {
@Bean
fun implicitNamePublicBeanA(): PublicInterface = PublicClass()
@Bean
fun implicitNamePublicBeanB(): PublicInterface = PublicClass()
@Bean("beanA")
internal fun explicitNameInternalBeanA(): InternalInterface1 = InternalClass1()
@Bean("beanB")
internal fun explicitNameInternalBeanB(): InternalInterface1 = InternalClass1()
@Bean
internal fun implicitNameInternalBeanC(): InternalInterface2 = InternalClass2()
@Bean
internal fun implicitNameInternalBeanD(): InternalInterface2 = InternalClass2()
}
@Test
fun `test public beans with implicit names with unqualified injection`(
@Autowired
implicitNamePublicBeanA: PublicInterface?,
@Autowired
implicitNamePublicBeanB: PublicInterface?,
) {
assertThat(implicitNamePublicBeanA).isInstanceOf(PublicClass::class.java)
assertThat(implicitNamePublicBeanB).isInstanceOf(PublicClass::class.java)
// Because Spring matches the bean function name to the injected parameter name,
// we expect two different beans provided by the two different bean functions.
assertThat(implicitNamePublicBeanA).isNotSameAs(implicitNamePublicBeanB)
}
@Test
fun `test public beans with implicit names with qualified injection`(
@Autowired
@Named("implicitNamePublicBeanA")
implicitNamePublicBeanA: PublicInterface?,
@Autowired
@Named("implicitNamePublicBeanB")
implicitNamePublicBeanB: PublicInterface?,
) {
assertThat(implicitNamePublicBeanA).isInstanceOf(PublicClass::class.java)
assertThat(implicitNamePublicBeanB).isInstanceOf(PublicClass::class.java)
// Because Spring matches the explicit @Bean name to the @Named parameter name,
// we expect two different beans provided by the two different bean functions.
assertThat(implicitNamePublicBeanA).isNotSameAs(implicitNamePublicBeanB)
}
@Test
fun `test internal bean with implicit name with unqualified injection`(
@Autowired
implicitNameInternalBeanC: InternalInterface2?,
) {
// This test fails because Spring fails to resolve the injected parameter.
// Spring finds 2 beans of type InternalInterface2
// and does not use the bean function names as implicit bean names.
// Thus, NoUniqueBeanDefinitionException is thrown.
// Because Spring should match the bean function name to the injected parameter name,
// expect that implicitNameInternalBeanC is not null.
assertThat(implicitNameInternalBeanC).isNotNull
}
@Test
fun `test internal beans with implicit names with unqualified injection`(
@Autowired
implicitNameInternalBeanC: InternalInterface2?,
@Autowired
implicitNameInternalBeanD: InternalInterface2?,
) {
// This test fails because Spring fails to resolve the injected parameter.
// Spring finds 2 beans of type InternalInterface2
// and does not use the bean function names as implicit bean names.
// Thus, NoUniqueBeanDefinitionException is thrown.
assertThat(implicitNameInternalBeanC).isNotNull
assertThat(implicitNameInternalBeanD).isNotNull
// Because Spring should match the bean function name to the injected parameter name,
// we expect two different beans provided by the two different bean functions.
assertThat(implicitNameInternalBeanC).isNotSameAs(implicitNameInternalBeanD)
}
@Test
fun `test internal beans with implicit names with qualified injection`(
@Autowired
@Named("implicitNameInternalBeanC")
implicitNameInternalBeanC: InternalInterface2?,
@Autowired
@Named("implicitNameInternalBeanD")
implicitNameInternalBeanD: InternalInterface2?,
) {
// This test fails because Spring fails to resolve the injected parameter.
// Spring finds 2 beans of type InternalInterface2
// and does not use the bean function names as implicit bean names.
// Thus, NoUniqueBeanDefinitionException is thrown.
assertThat(implicitNameInternalBeanC).isNotNull
assertThat(implicitNameInternalBeanD).isNotNull
// Because Spring should match the bean function name to the @Named parameter name,
// we expect two different beans provided by the two different bean functions.
assertThat(implicitNameInternalBeanC).isNotSameAs(implicitNameInternalBeanD)
}
@Test
fun `test internal beans with explicit names with qualified injection`(
@Autowired
@Named("beanA")
explicitNameInternalBeanA: InternalInterface1?,
@Autowired
@Named("beanB")
explicitNameInternalBeanB: InternalInterface1?,
) {
// With multiple internal beans of a requested type,
// we found that we must supply explicit @Bean names on the bean functions
// AND injection qualifiers such as @Named()
// to get Spring to discriminate between the different beans.
// Because we do that in this test, it passes.
assertThat(explicitNameInternalBeanA).isInstanceOf(InternalClass1::class.java)
assertThat(explicitNameInternalBeanB).isInstanceOf(InternalClass1::class.java)
// Because Spring matches the explicit @Bean name to the @Named parameter name,
// we expect two different beans provided by the two different bean functions.
assertThat(explicitNameInternalBeanA).isNotSameAs(explicitNameInternalBeanB)
}
}
build.gradle
:
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'io.spring.dependency-management' version '1.1.4'
id 'org.jetbrains.kotlin.jvm' version '1.9.21'
id 'org.jetbrains.kotlin.plugin.spring' version '1.9.21'
id 'org.springframework.boot' version '2.7.18'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '11'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'javax.inject:javax.inject:1'
testImplementation 'org.assertj:assertj-core:3.20.2'
}
tasks.withType(KotlinCompile) {
kotlinOptions {
freeCompilerArgs += '-Xjsr305=strict'
jvmTarget = '11'
}
}
tasks.named('test') {
useJUnitPlatform()
}