Skip to content

Document Kotlin internal modifier impact on @Bean #31985

Closed
@richmeyer7

Description

@richmeyer7

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()
}

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)theme: kotlinAn issue related to Kotlin supporttype: documentationA documentation task

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions