Affects: Spring Boot 3.3.2, Spring 6.1.11

When using a value class property in @ConfigurationProperties with a default value and not providing its value in application configuration leads to an error during binding of configuration. (Tested with Kotlin 1.9.25.)

Reproducer:

package com.example

import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@JvmInline
value class EntityId(val value: Int)

@ConfigurationProperties(prefix = "test")
data class AppConfig(val entityId: EntityId = EntityId(1))

@SpringBootApplication
@EnableConfigurationProperties(AppConfig::class)
class TestApp {

  @Bean
  fun runner(config: AppConfig) = CommandLineRunner {
    println("Value: ${config.entityId}")
  }
}

fun main(args: Array<String>) {
  runApplication<TestApp>(*args)
}

Running this fails with:

2024-08-02 18:17:05.389 [restartedMain] DEBUG LoggingFailureAnalysisReporter {} : Application failed to start due to an exception

org.springframework.boot.context.properties.bind.BindException: Failed to bind properties under 'test' to com.example.AppConfig
    at org.springframework.boot.context.properties.bind.Binder.handleBindError(Binder.java:391)
    at org.springframework.boot.context.properties.bind.Binder.bind(Binder.java:354)
    at org.springframework.boot.context.properties.bind.Binder.bind(Binder.java:339)
    at org.springframework.boot.context.properties.bind.Binder.bindOrCreate(Binder.java:331)
    at org.springframework.boot.context.properties.bind.Binder.bindOrCreate(Binder.java:316)
    at org.springframework.boot.context.properties.ConfigurationPropertiesBinder.bindOrCreate(ConfigurationPropertiesBinder.java:101)
    at org.springframework.boot.context.properties.ConstructorBound.from(ConstructorBound.java:44)
    at org.springframework.boot.context.properties.ConfigurationPropertiesBeanRegistrar.lambda$createBeanDefinition$1(ConfigurationPropertiesBeanRegistrar.java:97)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.obtainInstanceFromSupplier(AbstractAutowireCapableBeanFactory.java:1277)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.obtainInstanceFromSupplier(DefaultListableBeanFactory.java:951)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.obtainFromSupplier(AbstractAutowireCapableBeanFactory.java:1237)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1180)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:562)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200)
    at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1443)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1353)
    at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:904)
    at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:782)
    at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:542)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1355)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1185)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:562)
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:522)
    at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:337)
    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:335)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:200)
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:975)
    at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:971)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:625)
    at org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext.refresh(ReactiveWebServerApplicationContext.java:66)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:754)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:456)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:335)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1363)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1352)
    at com.example.TestAppKt.main(TestApp.kt:30)
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
    at java.base/java.lang.reflect.Method.invoke(Method.java:580)
    at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:50)
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.AppConfig]: Illegal arguments for constructor
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:218)
    at org.springframework.boot.context.properties.bind.ValueObjectBinder$ValueObject.instantiate(ValueObjectBinder.java:196)
    at org.springframework.boot.context.properties.bind.ValueObjectBinder.create(ValueObjectBinder.java:105)
    at org.springframework.boot.context.properties.bind.Binder.lambda$handleBindResult$0(Binder.java:366)
    at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
    at java.base/java.util.AbstractList$RandomAccessSpliterator.tryAdvance(AbstractList.java:708)
    at java.base/java.util.stream.ReferencePipeline.forEachWithCancel(ReferencePipeline.java:129)
    at java.base/java.util.stream.AbstractPipeline.copyIntoWithCancel(AbstractPipeline.java:527)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:513)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.FindOps$FindOp.evaluateSequential(FindOps.java:150)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.ReferencePipeline.findFirst(ReferencePipeline.java:647)
    at org.springframework.boot.context.properties.bind.Binder.fromDataObjectBinders(Binder.java:488)
    at org.springframework.boot.context.properties.bind.Binder.handleBindResult(Binder.java:365)
    at org.springframework.boot.context.properties.bind.Binder.bind(Binder.java:351)
    ... 43 common frames omitted
Caused by: java.lang.IllegalArgumentException: java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, Object, boolean)" is null
    at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:70)
    at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
    at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:486)
    at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:904)
    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:189)
    ... 58 common frames omitted
Caused by: java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, Object, boolean)" is null
    at java.base/sun.invoke.util.ValueConversions.unboxInteger(ValueConversions.java:81)
    at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
    ... 62 common frames omitted

2024-08-02 18:17:05.392 [restartedMain] ERROR LoggingFailureAnalysisReporter {} : 

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to bind properties under 'test' to com.example.AppConfig:

    Reason: java.lang.NullPointerException: Cannot invoke "java.lang.Number.intValue()" because the return value of "sun.invoke.util.ValueConversions.primitiveConversion(sun.invoke.util.Wrapper, Object, boolean)" is null

Action:

Update your application's configuration

Ideally, this would work just like it works with defaults for non-value classes.

Comment From: FredoNook

This issue also happens when value class in configuration properties and any properties have default values, not just value class property:

@JvmInline
value class EntityId(val value: Int)

@ConfigurationProperties(prefix = "test")
data class AppConfig(
    val entityId: EntityId,
    val anotherProperty: String = "default value",
)

In this case if entityId present in property sources but anotherProperty not, happens NPE on binding anotherProperty. If replace value class in this case by common class then everything works fine.

Comment From: apankowski

Reproducer for @FredoNook's case:

package com.example

import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@JvmInline
value class EntityId(val value: Int)

@ConfigurationProperties(prefix = "test")
data class AppConfig(
  val entityId: EntityId,
  val anotherProperty: String = "default value",
)

@SpringBootApplication
@EnableConfigurationProperties(AppConfig::class)
class TestApp {

  @Bean
  fun runner(config: AppConfig) = CommandLineRunner {
    println("Value: ${config.entityId}")
  }
}

fun main(args: Array<String>) {
  System.setProperty(
    "SPRING_APPLICATION_JSON",
    """
    {
      "test": {
        "entityId": 1
      }
    }
    """.trimIndent()
  )
  runApplication<TestApp>(*args)
}

Comment From: wilkinsona

As far as I can tell, there's nothing that we can do about this as it doesn't appear to be possible to create an instance of AppConfigwith the default value for entity ID. This appears to be the case both with reflection and when trying to use AppConfig directly.

Viewed from Java, AppConfig has three constructors:

public com.example.gh41693.AppConfig(int,kotlin.jvm.internal.DefaultConstructorMarker)
public com.example.gh41693.AppConfig(int,int,kotlin.jvm.internal.DefaultConstructorMarker)
private com.example.gh41693.AppConfig(int)

Only one of these, the first, returns a KFunction from ReflectJvmMapping.getKotlinFunction(Constructor). This KFunction has a single parameter which is an EntityId. It does not accept null as an argument for this parameter so there appears to be no way to create an AppConfig without providing an EntityId with a value.

As noted above, this works fine for a non-value class:

data class EntityId(val value: Int)

@ConfigurationProperties(prefix = "test")
data class AppConfig(val entityId: EntityId = EntityId(1))

In this case AppConfig now has a public zero-args constructor that can be used to create an instance, either through reflection or by calling new AppConfig().

In summary, this problem appears to be a limitation of how a Kotlin value class is mapped into Java. You may want to open a Kotlin issue to see if support for working with value classes from Java can be improved. Until then, I don't think there's anything that we can do here other than documenting the limitation. We can use this issue to add something here.