Note: This issue is caused by the solution to the very similar issue #33409 (see below) --> I tried to write this description in the same style.

We are in the process of migrating to Spring Boot 3.0 and we noticed that nested record properties with default values can no longer be bound.

Given a configuration class...

@Configuration
@ConfigurationProperties(prefix = "example")
public class ExampleProperties {

    @NestedConfigurationProperty
    private NestedProperty nested = new NestedProperty("Default", "default");

    public NestedProperty getNested() {
        return nested;
    }

    public void setNested(NestedProperty nested) {
        this.nested = nested;
    }
}

... and the NestedProperty, which is a record:

public record NestedProperty(String name, String value) {}

Test (using Spock):

@SpringBootTest(properties = ['example.nested.name=testName', 'example.nested.value=testValue'])
class DemoApplicationSpec extends Specification {

    @Inject
    ExampleProperties exampleProperties

    def 'TestProperties'() {
        expect:
        exampleProperties.nested.name() == 'testName'
        exampleProperties.nested.value() == 'testValue'
    }
}

The test runs successfully using Spring Boot 2.7.8, but fails on 3.0.2. The reason is this if-statement that was introduced by @philwebb with this commit to fix gh-33409. The idea for this change was to force setter binding instead of constructor binding for JavaBeans if the target value already exists (by throwing away the "deduced bind constructor"), to match the JavaBeans binding behavior in Spring 2.x.

Unfortunately, this change does not really make sense for records, as records are immutable and hence setter binding cannot be used. I would expect Spring 3.x to distinguish between the two use-cases (setter binding for JavaBeans vs. constructor binding for records) just like Spring 2.x did.

Workaround until this is fixed

As a workaround, using constructor binding for records can be enforced using @ConstructorBinding on a (theoretically redundant) additional constructor:

public record NestedProperty(String name, String value) {

    @ConstructorBinding
    public NestedProperty(String name, String value) {
        this.name = name;
        this.value = value;
    }
}

Now, the binding constructor is not considered "deduced" any more and is used as expected.

Comment From: philwebb

Thanks for the detailed analysis, it really helped!

Comment From: winne42

Wow, that was blazingly fast! Thanks so much for your work, @philwebb ! ♥