Hello. I'm using Spring Boot 3.3.1 and it seems like that @Autowired is not working on @ConfigurationProperties subclasses and, as result, such sub-properties are not instantiated.

I've the following code:

@Import(ConverterConfig.class)
@Configuration(proxyBeanMethods = false)
public class FooInputConfig {
    @Bean
    public SupportedProvider provider() {
        return FOO;
    }

    @Bean
    public KafkaOutputProperties kafkaOutputProperties(FooInputProperties properties) {
        return properties.getKafkaOutput();
    }

    @Bean
    @ConditionalOnMissingBean
    @ConfigurationProperties(CONFIG_PREFIX)
    public FooInputProperties configurationProperties() {
        return new FooInputProperties();
    }
}

with the properties

@Validated
public class FooInputProperties {
    public static final String CONFIG_PREFIX = "foo";

    @Valid
    @NotNull
    @ResolvableInetSocketAddress(minPort = 20_000, maxPort = 21_000)
    private InetSocketAddress inputAddress;

    @Valid
    @NotNull
    private KafkaOutputProperties kafkaOutput;

    public InetSocketAddress getInputAddress() {
        return inputAddress;
    }

    public void setInputAddress(InetSocketAddress inputAddress) {
        this.inputAddress = inputAddress;
    }

    public KafkaOutputProperties getKafkaOutput() {
        return kafkaOutput;
    }

    public void setKafkaOutput(KafkaOutputProperties kafkaOutput) {
        this.kafkaOutput = kafkaOutput;
    }
}

and the sub-properties:

@Validated
public class KafkaOutputProperties {
    @Min(0)
    private int retries = 10;

    @NotNull
    @DurationMin(millis = 100)
    private Duration retriesBackOff = Duration.ofMillis(100);

    @NotNull
    @DurationMin(millis = 1000)
    private Duration retriesMaxBackOff = Duration.ofMillis(1000);

    @NotNull
    @NotEmpty
    private Set<@ResolvableInetSocketAddress InetSocketAddress> brokers;

    @NotNull
    @NotBlank
    private String topic;

    private final ConversionService conversionService;

    @Autowired
    public KafkaOutputProperties(ConversionService conversionService) {
        this.conversionService = conversionService;
    }

    public int getRetries() {
        return retries;
    }

    public void setRetries(int retries) {
        this.retries = retries;
    }

    public Duration getRetriesBackOff() {
        return retriesBackOff;
    }

    public void setRetriesBackOff(Duration retriesBackOff) {
        this.retriesBackOff = retriesBackOff;
    }

    public Duration getRetriesMaxBackOff() {
        return retriesMaxBackOff;
    }

    public void setRetriesMaxBackOff(Duration retriesMaxBackOff) {
        this.retriesMaxBackOff = retriesMaxBackOff;
    }

    public Set<InetSocketAddress> getBrokers() {
        return brokers;
    }

    public String getBrokersString() {
        return brokers.stream().map(a -> conversionService.convert(a, String.class)).collect(joining(","));
    }

    public void setBrokers(Set<InetSocketAddress> brokers) {
        this.brokers = brokers;
    }

    public void setBrokers(String brokers) {
        this.brokers = stream(brokers.split(",")).map(a -> conversionService.convert(a, InetSocketAddress.class)).collect(toSet());
    }

    public String getTopic() {
        return topic;
    }

    public void setTopic(String topic) {
        this.topic = topic;
    }
}

On startup, the properties that get read from the application.yaml look like FooInputProperties#kafkaOutput is null even if the required properties to construct it are there. In fact, if I remove the constructor:

@Autowired
public KafkaOutputProperties(ConversionService conversionService) {
    this.conversionService = conversionService;
}

then the object holding the sub-properties is properly created and populated. The Spring Boot Maven plugin is enabling AOT (I don't know if it may be correlated somehow):

<plugin>
    <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-maven-plugin</artifactId>
     <version>${spring-boot.version}</version>
     <executions>
        <execution>
             <goals>
                  <goal>process-aot</goal>
                  <goal>process-test-aot</goal>
                   <goal>repackage</goal>
              </goals>
       </execution>
    </executions>
</plugin>

Weird enough, setter (even private) injection works fine:

@Autowired
private void setConversionService(ConversionService conversionService) {
    this.conversionService = conversionService;
}

Comment From: wilkinsona

Configuration property binding doesn't support dependency injection so it is to be expected that trying to autowire the ConversionService into an instance that's created by that binding does not work.

if I remove the constructor […] then the object holding the sub-properties is properly created and populated.

When the constructor is present, the binder ignores KafkaOutputProperties for two reasons:

Removing the constructor means that KafkaOutputProperties is now a JavaBean and an instance can be created by the configuration property binder.

Weird enough, setter (even private) injection works fine

This is due to AutowiredAnnotationBeanPostProcessor which post-processes the KafkaOutputProperties bean and honours any setters annotated with @Autowired:

Autowired Methods

Config methods may have an arbitrary name and any number of arguments; each of those arguments will be autowired with a matching bean in the Spring container. Bean property setter methods are effectively just a special case of such a general config method. Config methods do not have to be public.

I'm going to close this issue as I don't think there's anything that we can do here. We cannot fail fast with the arrangement you're trying to use as ignoring a class with an @Autowired-constructor is documented and expected behavior.

Comment From: cdprete

Hello @wilkinsona. Thanks for the explanation.

On the other end, I've another Spring Boot application (3.2.4) where a config properties class like this below works just fine:

@Validated
@ConfigurationProperties(prefix = PREFIX)
public class DataUsagesProperties implements Validator {
    static final String PREFIX = "com.foo.usages";

    private static final Logger logger = LoggerFactory.getLogger(DataUsagesProperties.class);

    private final List<HostnameProvider> serverNameProviders;

    @NotNull
    private Path inputDirectory;
    @NotNull
    private Path outputDirectory;

    @Valid
    @NotNull
    @NestedConfigurationProperty
    private JobProperties dailyCollectorJob = new JobProperties();
    @Valid
    @NotNull
    @NestedConfigurationProperty
    private JobProperties monthlyAggregatorJob = new JobProperties();
    @Valid
    @NotNull
    @NestedConfigurationProperty
    private JobProperties dailyGapDetectorJob = new JobProperties();

    @Valid
    @NotNull
    @NestedConfigurationProperty
    private StatisticFileProperties filesToScan = new StatisticFileProperties();

    @NotBlank
    private String applicationName = "Foo";

    private String serverName;

    @Autowired
    public DataUsagesProperties(List<HostnameProvider> serverNameProviders) {
        this.serverNameProviders = serverNameProviders;
    }

    // Visible for testing
    @PostConstruct
    void populateServerNameIfMissing() {
        if(isBlank(serverName)) {
            logger.warn("The '%s.server-name' configuration property is not set. A server name will be detected from the hosting system.".formatted(PREFIX));
            serverName = serverNameProviders
                    .stream()
                    .map(HostnameProvider::findHostname)
                    .filter(Objects::nonNull)
                    .findFirst()
                    // This should never happen given the LocalhostFallbackServerNameProvider fallback present
                    .orElseThrow(() -> new IllegalStateException("None of the configured server name providers was able to detect the server name. Consider setting one manually."));
        }
    }

    // Visible for testing
    @PostConstruct
    void createOutputDirectoryIfMissing() throws IOException {
        if(!exists(outputDirectory)) {
            logger.warn("The configured output directory '{}' seems to be missing. It will now be created.", outputDirectory);
            createDirectories(outputDirectory);
        }
    }

    @PostConstruct
    private void normalizePaths() {
        inputDirectory = inputDirectory.normalize();
        outputDirectory = outputDirectory.normalize();
    }

    public Path getInputDirectory() {
        return inputDirectory;
    }

    public void setInputDirectory(Path inputDirectory) {
        this.inputDirectory = inputDirectory;
    }

    public Path getOutputDirectory() {
        return outputDirectory;
    }

    public void setOutputDirectory(Path outputDirectory) {
        this.outputDirectory = outputDirectory;
    }

    public JobProperties getDailyCollectorJob() {
        return dailyCollectorJob;
    }

    public void setDailyCollectorJob(JobProperties dailyCollectorJob) {
        this.dailyCollectorJob = dailyCollectorJob;
    }

    public JobProperties getMonthlyAggregatorJob() {
        return monthlyAggregatorJob;
    }

    public void setMonthlyAggregatorJob(JobProperties monthlyAggregatorJob) {
        this.monthlyAggregatorJob = monthlyAggregatorJob;
    }

    public StatisticFileProperties getFilesToScan() {
        return filesToScan;
    }

    public void setFilesToScan(StatisticFileProperties filesToScan) {
        this.filesToScan = filesToScan;
    }

    public String getApplicationName() {
        return applicationName;
    }

    public void setApplicationName(String applicationName) {
        this.applicationName = applicationName;
    }

    public String getServerName() {
        return serverName;
    }

    public void setServerName(String serverName) {
        this.serverName = serverName;
    }

    public JobProperties getDailyGapDetectorJob() {
        return dailyGapDetectorJob;
    }

    public void setDailyGapDetectorJob(JobProperties dailyGapDetectorJob) {
        this.dailyGapDetectorJob = dailyGapDetectorJob;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return TSBulkDataUsagesProperties.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        var properties = (TSBulkDataUsagesProperties) target;
        validateInputDirectory(properties.getInputDirectory(), errors);
        validateOutputDirectory(properties.getOutputDirectory(), errors);
        validateJobTimes(properties.getDailyCollectorJob().getStartTime(), properties.getMonthlyAggregatorJob().getStartTime(), errors);
    }

    private static void validateInputDirectory(Path inputDirectory, Errors errors) {
        if(inputDirectory != null) {
            final var fieldName = "inputDirectory";
            if (notExists(inputDirectory)) {
                errors.rejectValue(fieldName, "field.not.existing", "The input directory '%s' doesn't exist".formatted(inputDirectory));
            } else if (!isDirectory(inputDirectory)) {
                errors.rejectValue(fieldName, "field.not.directory", "The provided input directory '%s' isn't actually a directory".formatted(inputDirectory));
            } else if (!isReadable(inputDirectory)) {
                errors.rejectValue(fieldName, "field.not.readable", "The input directory '%s' is not readable".formatted(inputDirectory));
            }
        }
    }

    private static void validateOutputDirectory(Path outputDirectory, Errors errors) {
        if(outputDirectory != null && exists(outputDirectory)) {
            final var fieldName = "outputDirectory";
            if (!isDirectory(outputDirectory)) {
                errors.rejectValue(fieldName, "field.not.directory", "The provided output directory '%s' isn't actually a directory".formatted(outputDirectory));
            } else if (!isWritable(outputDirectory)) {
                errors.rejectValue(fieldName, "field.not.writable", "The output directory '%s' is not writable".formatted(outputDirectory));
            }
        }
    }

    private static void validateJobTimes(LocalTime dailyJobTime, LocalTime monthlyJobTime, Errors errors) {
        if(dailyJobTime != null && monthlyJobTime != null) {
            if(!dailyJobTime.isBefore(monthlyJobTime)) {
                errors.rejectValue("dailyCollectorJob.startTime", "field.after.monthly.job", "Consider to schedule the daily collector job BEFORE the monthly aggregator job, otherwise the latter could potentially not catch up the last collected file");
                errors.rejectValue("monthlyAggregatorJob.startTime", "field.before.daily.job", "Consider to schedule the monthly aggregator job AFTER the daily collector job, otherwise it could potentially not catch up the last collected file from the latter");
            }
        }
    }

    @Validated
    public static class JobProperties {
        @NotNull
        @DateTimeFormat(iso = TIME)
        private LocalTime startTime;

        @NotNull
        private boolean enabled = true;

        public LocalTime getStartTime() {
            return startTime;
        }

        public void setStartTime(LocalTime startTime) {
            this.startTime = startTime;
        }

        public boolean isEnabled() {
            return enabled;
        }

        public void setEnabled(boolean enabled) {
            this.enabled = enabled;
        }
    }

    @Validated
    public static class StatisticFileProperties {
        @NotNull
        private TSBulkDataUsagesProperties.StatisticFileProperties.Type type = MARKET_CODE;

        public Type getType() {
            return type;
        }

        public void setType(Type type) {
            this.type = type;
        }

        // The pattern is (for now) hardcoded, but we could decide to make it configurable in the future.
        public Pattern getPattern() {
            return type.getPattern();
        }

        public enum Type {
            MARKET_CODE("mcStats" + Type.COMMON_PATTERN_SUFFIX),
            PRICE("priceStats" + Type.COMMON_PATTERN_SUFFIX);

            private static final String COMMON_PATTERN_SUFFIX = "_mc(\\d+)_\\d{8}-\\d{7}-\\d{1,3}\\.csv";

            private final Pattern pattern;

            Type(String pattern) {
                this.pattern = compile(pattern);
            }

            public Pattern getPattern() {
                return pattern;
            }
        }
    }
}

what's the difference, then?

Comment From: wilkinsona

Both JobProperties and StatisticFileProperties are JavaBeans as they have a default constructor and getter-setter methods. Furthermore, the binder doesn't need to create instances of them as they're already non-null due to the field declarations.

If you have any further questions, please follow up on Stack Overflow. As mentioned in the guidelines for contributing, we prefer to use GitHub issues only for bugs and enhancements.

Comment From: cdprete

java @Autowired public DataUsagesProperties(List<HostnameProvider> serverNameProviders) { this.serverNameProviders = serverNameProviders; }

The outmost constructor has the same structure as the one of KafkaOutputProperties, so shouldn't that fail as well? For the sub-properties, no questions there in that example.

Comment From: wilkinsona

It's impossible to say as you haven't shown how DataUsagesProperties is being declared as a bean.

As I said above, please ask any further questions on Stack Overflow. If you ask a question there, please reduce your example of the problem to the minimum that's required to reproduce it. There's a lot of code that isn't relevant which makes it hard to know exactly what you're asking about at the moment.

Comment From: cdprete

It's impossible to say as you haven't shown how DataUsagesProperties is being declared as a bean.

There is no @Bean. It's loaded through @EnableConfigurationProperties.

Comment From: wilkinsona

This isn't the place for this back-and-forth. Please ask follow-up questions on Stack Overflow and provide a minimal example if you do so.