I have a spring boot - Kotlin project, which I am upgrading from Spring 5, Java 8 to Spring 6, Java 21
build.gradle.kts
plugins {
kotlin("jvm") version "2.1.20"
id("org.springframework.boot") version "3.4.4"
id("org.jetbrains.kotlin.plugin.spring") version "2.1.20"
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation(kotlin("reflect"))
}
MyProperties.kt
@ConfigurationProperties(prefix = "my.test")
@Component
class MyProperties {
var customs: List<CustomA> = listOf()
var bases: List<Base> = listOf()
open class Base(
var type: String = ""
)
class CustomA(
var y: String = "",
var z: String = "",
) : Base()
}
Run the application by bootRun
task, with these env variables:
MY_TEST_BASES_0_TYPE=value4;MY_TEST_CUSTOMS_0_TYPE=value3;MY_TEST_CUSTOMS_0_Y=value1;MY_TEST_CUSTOMS_0_Z=value2
I would expect that the myProperties.customs[0].type
would return value3
, but it would return an empty string instead.
The problem only happens if the kotlin("reflect")
dependency exists and does not occur in Spring 5, Java 8.
It looks like the base class's properties are not bound at all. If I modify the MyProperties
class as this, the type
property will bind correctly
open class Base(
open var type: String = ""
)
class CustomA(
override var type: String = "",
var y: String = "",
var z: String = "",
) : Base()
Comment From: philwebb
Could you please provide a complete sample application that works with earlier version and fails with the later one. Please either attach a zip file or provide a link to a GitHub project.
Comment From: vominhtri231
Here is the Github project that indicates the problem: https://github.com/vominhtri231/PropertyNotMap
Comment From: quaff
I confirm it's a regression.
The Binder
in v2.7.8 instantiate CustomA
with default constructor parameters, then bind properties via setters (include setters from super classes).
The Binder
in v3.4.4 instantiate CustomA
with provided configuration properties, no setter invoked later, I think invoking setters in super classes should fix it, GH-42276 is related.
Comment From: wilkinsona
The change in behavior is due to 3.0 switching to implicit constructor binding.
The Kotlin class has two constructors (CustomA()
and CustomA(String, String)
). Viewed from Java, this class isn't eligible for constructor binding as it has two constructors and neither is annotated with @ConstructorBinding
. When the kotlin("reflect")
dependency is present, this allows Kotlin's primary constructor to be identified. It's CustomA(String, String)
and it's incorrectly identified as being suitable for constructor binding.
@vominhtri231, we'll see what we can do to get this working out of the box. In the meantime, there are a couple of ways in which you can work around the change in behavior.
You can use @Autowired
to indicate that the constructor should not be used for property binding:
@ConfigurationProperties(prefix = "my.test")
@Component
class MyProperties {
var customs: List<CustomA> = listOf()
var bases: List<Base> = listOf()
open class Base(
var type: String = ""
)
class CustomA @Autowired constructor(
var y: String = "",
var z: String = "",
) : Base()
}
Alternatively, you can declare the class with only a default constructor:
@ConfigurationProperties(prefix = "my.test")
@Component
class MyProperties {
var customs: List<CustomA> = listOf()
var bases: List<Base> = listOf()
open class Base(
var type: String = ""
)
class CustomA : Base() {
var y: String = ""
var z: String = ""
}
}
I think invoking setters in super classes should fix it
Thanks for the suggestion but this won't restore 2.x's behavior:
- it would affect Java apps and this problem is Kotlin specific
- it wouldn't use the default constructor whereas 2.x does use it
- it will result in some properties being bound twice (once via the constructor and once via setters)
Comment From: wilkinsona
On further thought, this may be working as best it can given the concept of a primary constructor in Kotlin. If we started ignoring them and fell back to pure setter-based binding, it may cause a regression elsewhere by no longer calling the primary constructor.
We don't describe in the documentation how primary constructors are treated and we probably should. Right now, I am leaning towards documenting the current behavior and recommending the used @Autowired
or a default primary constructor to opt into pure setter-based binding.
Let's see what the rest of the team thinks.
Comment From: philwebb
Using @Autowired
is a bit clunky in this case because the type isn't actually a bean and there's nothing to auto-wire. I do think we should document that the primary constructor should be no-args if you want setter based binder. I think another option we've discussed in the past is having a stronger signal that constructor binding should not be used. I can't remember how we were going to do that, perhaps a new attribute on @ConfigurationProperties
.
Comment From: quaff
@Autowired
doesn't work if the class is top level @ConfigurationProperties
bean but not nested properties.
@ConfigurationProperties(prefix = "my.test")
class MyProperties @Autowired constructor (
var y: String = "",
var z: String = ""
) : Base()
Error creating bean with name 'my.test-tri.test.MyProperties': Invalid autowire-marked constructor: public tri.test.MyProperties(java.lang.String,java.lang.String). Found constructor with 'required' Autowired annotation already: public tri.test.MyProperties()
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'my.test-tri.test.MyProperties': Invalid autowire-marked constructor: public tri.test.MyProperties(java.lang.String,java.lang.String). Found constructor with 'required' Autowired annotation already: public tri.test.MyProperties()
at app//org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.determineCandidateConstructors(AutowiredAnnotationBeanPostProcessor.java:414)
at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.determineConstructorsFromBeanPostProcessors(AbstractAutowireCapableBeanFactory.java:1320)
at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1215)
at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:563)
at app//org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:523)
at app//org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339)
at app//org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:347)
at app//org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337)
at app//org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.instantiateSingleton(DefaultListableBeanFactory.java:1155)
at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingleton(DefaultListableBeanFactory.java:1121)
at app//org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:1056)
at app//org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:987)
at app//org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:627)
at app//org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752)
at app//org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439)
at app//org.springframework.boot.SpringApplication.run(SpringApplication.java:318)