In spring-cloud-kubernetes we have support for configdata, as such, much of the underlying code uses snippets likes these:

binder.bind("spring.cloud.kubernetes.config", ConfigMapConfigProperties.class)

which works.

But I am trying to change some code to move configs to records, so I have code that does this, for example:

AbstractConfigProperties.RetryProperties properties = binder.bind("spring.cloud.kubernetes.config.retry", AbstractConfigProperties.RetryProperties.class)
            .orElseGet(AbstractConfigProperties.RetryProperties::new);

where AbstractConfigProperties.RetryProperties is a record. Unfortunately, this does not work and binding does not happen.


My limited knowledge of Binder functionality does not allow me to figure out how to do it, and if it is possible at all. :( I can't provide a simple example here, but I could push the branch that I am currently working on and tell what test to run in order to reproduce this.

I am also not sure the name of the issue is appropriate, but after a few hours of debug I am inclined to think that JavaBeanBinder is the one responsible for this.

Thank you for looking into this.

Comment From: wilkinsona

Binding to records is performed by the value object binder and should work – we have tests that verify that it does. Without a minimal sample and with only "does not work" as a description of the failure we aren't going to be able to help you. If you would like us to spend some more time investigating, please spend some time providing a complete yet minimal sample that reproduces the problem. You can share it with us by pushing it to a separate repository on GitHub or by zipping it up and attaching it to this issue.

Comment From: philwebb

You might also want to try the bindOrCreate method rather than using .orElseGet(AbstractConfigProperties.RetryProperties::new). That method will deal with @DefaultValue annotations.

Comment From: wind57

I am sorry, you are right, I should have provided an example to begin with.

here is one. I am not sure if I am entirely doing things correctly...

If I comment the constructor in SimpleServiceTest:

    public SimpleRecord() {
        this(5);
    }

the test passes.

Comment From: wind57

I've also added one more example in that repo, with a nested record. This might need to be a separate issue, not sure.

Comment From: philwebb

The problem in your sample is caused because you're trying to use the low-level Binder to directly bind @ConfigurationProperties classes and there's no org.springframework.boot.context.properties.bind.BindConstructorProvider instance telling the binder which constructor to use.

The simplest fix for your sample is to inject the SimpleRecord bean directly into your service rather than trying to bind it. The bean will have been created by the ConfigurationPropertiesBeanRegistrar which will use the ConfigurationPropertiesBindConstructorProvider to identify the @ConstructorBinding annotated constructor.

If you do want to use the Binder directly for whatever reason you'll need to write your own BindConstructorProvider (at least for Spring Boot 2.7.x, things might be easier in Spring Boot 3.0).

Here's an updated SimpleService implementation that uses a BindConstructorProvider that just picks the first constructor for records:

@Component
public class SimpleService {

    @Autowired
    private ApplicationContext applicationContext;

    @Autowired
    private Environment environment;

    public SimpleRecord simple() {
        Iterable<ConfigurationPropertySource> configurationPropertySources = ConfigurationPropertySources
                .from(((ConfigurableEnvironment) environment).getPropertySources());
        PropertySourcesPlaceholdersResolver placeholdersResolver = new PropertySourcesPlaceholdersResolver(environment);
        Consumer<PropertyEditorRegistry> propertyEditorInitializer = ((ConfigurableApplicationContext) applicationContext)
                .getBeanFactory()::copyRegisteredEditorsTo;
        BindConstructorProvider constructorProvider = (bindable, isNestedConstructorBinding) -> {
            Class<?> type = bindable.getType().resolve(Object.class);
            return type.isRecord() ? type.getConstructors()[0] : null;
        };
        Binder binder = new Binder(configurationPropertySources, placeholdersResolver,
                ApplicationConversionService.getSharedInstance(), propertyEditorInitializer, null, constructorProvider);
        return binder.bindOrCreate("retry", SimpleRecord.class);
    }

}

Comment From: wind57

I'll take a look at the sample, thank you.

The thing is we use this in spring-cloud-kubernetes (where I am a minor contributor), see here.

So we do have boot-3... I do not want to sound impolite, but can you may be show how to do it (or a hint where to look) for spring-boot-3? I very much appreciate your effort here. thank you.

Comment From: philwebb

Ohh, I see the Binder here is obtained from the ConfigDataLocationResolverContext. That makes things quite tricky because you can't set the BindConstructorProvider.

can you may be show how to do it (or a hint where to look) for spring-boot-3?

The DefaultBindConstructorProvider in Spring Boot 3 will return a single record based constructor. One you're on Spring Boot 3.0, I think that simple records with a single constructor will work.

Flagging this one for further team discussion as we need to decide if we want to support a way to use a custom BindConstructorProvider with an existing Binder.

Comment From: wind57

you're right, this does work for a record with a single constructor, but we have a different problem other there.

The record is nested inside ConfigMapConfigProperties, and that does not work - even if I change the record to have a single constructor. What I means is this:

ConfigMapConfigProperties.RetryProperties retryProperties = binder.bindOrCreate("spring.cloud.kubernetes.config.retry", ConfigMapConfigProperties.RetryProperties.class);

will correctly create the defaults and inject all the properties I have defined under spring.cloud.kubernetes.config.retry.

While this:

ConfigMapConfigProperties properties = binder.bindOrCreate("spring.cloud.kubernetes.config", ConfigMapConfigProperties.class);

will only apply defaults, without injecting any properties I have defined, for the Retry record.

That btw is what I was trying to show in the sample I provided, with NestedService.

I hope I make sense here.

P.S. I can workaround this, but it isn't elegant at all, imho.

Comment From: philwebb

We spoke about this today and we're going to look to see if we can move @ConstructorBinding to the bind package and align the default behavior.

Comment From: wilkinsona

The annotation processor is still looking for org.springframework.boot.context.properties.ConstructorBinding.