This was raised by Philipp Paland on Gitter when trying to use records for @ConfigurationProperties. I believe it will apply to any final @ConfigurationProperties class that has @Validated on it.

While we don't require a proxy to perform configuration property validation, MethodValidationPostProcessor finds @Validated on the bean and tries to create a proxy. When the configuration property class is final, this fails. You can work around the problem by excluding ValidationAutoConfiguration and just declaring a validator:

package com.example.recordvalidation;

import javax.validation.constraints.NotBlank;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.context.properties.ConstructorBinding;
import org.springframework.boot.validation.MessageInterpolatorFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Role;
import org.springframework.validation.annotation.Validated;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@ConfigurationPropertiesScan
@SpringBootApplication(exclude = org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration.class)
public class RecordValidationApplication {

    public static void main(String[] args) {
        SpringApplication.run(RecordValidationApplication.class, args);
    }

    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    public static LocalValidatorFactoryBean defaultValidator() {
        LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
        MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
        factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
        return factoryBean;
    }

    @ConfigurationProperties(prefix = "myprops")
    @ConstructorBinding
    @Validated
    public record MyProps(@NotBlank String baseUrl) {}

}

This retains configuration property validation at the cost of method validation anywhere in the app. It would be nice if we could somehow keep both and just have the final @ConfigurationProperties class excluded from method validation.

Comment From: philipp-paland

Thanks for providing the workaround :)

Comment From: snicoll

After some brainstorming with @jhoeller, it looks like we unfortunately created an additional use-case for @Validated that overlaps with the ones of the core framework, that is:

  • @Validated at class-level to trigger method validation (the post-processor is optional but auto-configured in Spring Boot)
  • @Validated at parameter-level to trigger validation, typically to validate input arguments in MVC

Our binder looks for @Validated at class-level and triggers validation that looks more like the second use case and definitely not the first one.

There are a few workarounds to be considered:

  • Use a @ConfigurationProperties-specific way to trigger validation (breaking change)
  • Auto-configure an extension of MethodValidationPostProcessor that overrides isEligible and ignores @ConfigurationProperties-annotated types by default

Comment From: snicoll

We've decided to add a validated attribute to @ConfigurationProperties and recommend users to move to that. That won't be a breaking change as we'd be still looking at @Validated (perhaps in a deprecated fashion?)

Comment From: wilkinsona

My recollection is that we discussed that approach, but decided against it as it would be inconsistent with how users expect to enable validation by using @Validated. Instead, I think we decided to look at introducing an extension of MethodValidationPostProcessor that can ignore ConfigurationProperties-annotated types. To avoid the post-processor having knowledge of @ConfigurationProperties, @philwebb was keen to have come sort of pluggable filtering mechanism.

Comment From: philwebb

My recollection is the same as @wilkinsona

Comment From: Thrillpool

Noting for the convenience of people googling errors, as I just spent a couple of hours rediscovering this issue (or rather something similar) and solution, at which point I immediately found this github issue. this error may manifest as

Cause: java.lang.ClassCastException: class SomeClassOfYours$$SpringCGLIB$$0 cannot be cast to class org.springframework.cglib.proxy.Factory (SomeClassOfYours$$SpringCGLIB$$0 and org.springframework.cglib.proxy.Factory are in unnamed module of loader 'app')

And the same workaround resolves things