This is related to the issues https://github.com/spring-projects/spring-boot/issues/6222 and https://github.com/spring-projects/spring-boot/issues/12148

It affects Spring Boot version 2.2.x

The behavior of configuration and value binding is still not completly consistent. The use of a custom Converter registered as bean or inside a conversionService bean only works in the following cases: - The configuration value will be bound to a configuration bean annotated with @ConfigurationProperties - The configuration value will be injected to bean via @Value annotation if the bean is created after complete refresh of the ApplicationContext.

It does not work, if the configuration value if injected into a bean with @Value if the bean is created during "refresh phase" of the ApplicationContext. This applies for example to all beans created during the initialization of the Tomcat server in a ServletWebServerApplicationContext

I provided a test project which contains multiple test cases showcasing the inconsistent behavior: Test scenario 1: HealthIndicator bean created during refresh with configuration object => SUCCESS Test scenario 2: HealthIndicator bean created during refresh with constructor injection via @Value => FAIL Test scenario 3: @Service bean created after refresh with constructor injection via @Value => SUCCESS

spring-boot-converter-bug.zip

Please also note that the failure of scenario 2 can only be detected in an integration test if a "real" webEnvironment, e.g. RANDOM_PORT, is used. In a mocked web environment, i.e. without actually starting a Tomcat, the problem will not occur.

Comment From: wilkinsona

Thank you for the sample. I have reproduced the problem.

The problem is caused by some eager initialization that's triggered by ServletWebServerApplicationContext retrieving all ServletContextInitializer beans from the context during startup. ServletEndpointRegistrar is one such bean. Its creation triggers endpoint discovery which ultimately leads to creation of all of the health indicators that are used by the health endpoint. Creation of your ConstructorHealthIndicator fails because this is all happening before your custom converter has been registered.

Comment From: wilkinsona

The following will reproduce the problem when added to spring-boot-actuator-autoconfigure:

@Test
void endpointThatRequiresCustomConversionForItsConstruction() {
    new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
            .withConfiguration(AutoConfigurations.of(ServletWebServerFactoryAutoConfiguration.class,
                    EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
                    ServletEndpointManagementContextConfiguration.class, DispatcherServletAutoConfiguration.class))
            .withUserConfiguration(CustomConversionEndpoint.class, CustomConversionConfiguration.class)
            .run((context) -> {
                assertThat(context).hasSingleBean(CustomConversionEndpoint.class);
            });
}

@Component
@Endpoint(id = "customconversion")
static class CustomConversionEndpoint {

    CustomConversionEndpoint(@Value("custom.type") CustomType customType) {

    }

}

@Configuration(proxyBeanMethods = false)
static class CustomConversionConfiguration {

    @Bean
    public ConversionService conversionService() {
        FormattingConversionService conversionService = new DefaultFormattingConversionService();
        conversionService.addConverter(new StringToCustomTypeConverter());
        return conversionService;
    }

}

static class CustomType {

}

static class StringToCustomTypeConverter implements Converter<String, CustomType> {

    @Override
    public CustomType convert(String source) {
        return new CustomType();
    }

}

The test passes if the creation of WebApplicationContextRunner is changed to use the default context type (AnnotationConfigServletWebApplicationContext). This changes the startup ordering due to its use of a mocked servlet context.

Comment From: WalkingGhost1975

@wilkinsona Thanks for your analysis. Yes, the ServletEndpointRegistrar potentially triggers the eager initialization of quite a big tree of beans in this early phase.

We have currently a workaround by manually setting the custom ConversionService to the ConfigurableBeanFactory in the factory method:

        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
        conversionService.addConverter(myCustomConverter);
        beanFactory.setConversionService(conversionService); 

This is working for us but it's is a bit "hacky" and we would be happy to remove this workaround. :-)

Comment From: wilkinsona

The scope of the problem can be narrowed considerably by changing ServletEndpointDiscoverer so that it only triggers initialization of beans annotated with @ServletEndpoint rather than @Endpoint. The test above passes with the following change:

diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java
index bbdf544f66..b4756df202 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java
@@ -16,6 +16,7 @@

 package org.springframework.boot.actuate.endpoint.annotation;

+import java.lang.annotation.Annotation;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -127,7 +128,7 @@ public abstract class EndpointDiscoverer<E extends ExposableEndpoint<O>, O exten
        private Collection<EndpointBean> createEndpointBeans() {
                Map<EndpointId, EndpointBean> byId = new LinkedHashMap<>();
                String[] beanNames = BeanFactoryUtils.beanNamesForAnnotationIncludingAncestors(this.applicationContext,
-                               Endpoint.class);
+                               getEndpointAnnotation());
                for (String beanName : beanNames) {
                        if (!ScopedProxyUtils.isScopedTarget(beanName)) {
                                EndpointBean endpointBean = createEndpointBean(beanName);
@@ -261,6 +262,10 @@ public abstract class EndpointDiscoverer<E extends ExposableEndpoint<O>, O exten
                return true;
        }

+       protected Class<? extends Annotation> getEndpointAnnotation() {
+               return Endpoint.class;
+       }
+
        private boolean isEndpointFiltered(EndpointBean endpointBean) {
                for (EndpointFilter<E> filter : this.filters) {
                        if (!isFilterMatch(filter, endpointBean)) {
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java
index 5c7dc7bd67..cb1508f57d 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ServletEndpointDiscoverer.java
@@ -16,6 +16,7 @@

 package org.springframework.boot.actuate.endpoint.web.annotation;

+import java.lang.annotation.Annotation;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -80,4 +81,9 @@ public class ServletEndpointDiscoverer extends EndpointDiscoverer<ExposableServl
                throw new IllegalStateException("ServletEndpoints must not declare operations");
        }

+       @Override
+       protected Class<? extends Annotation> getEndpointAnnotation() {
+               return ServletEndpoint.class;
+       }
+
 }

Comment From: philwebb

This is a nasty one. I've tried approach here where we defer creating the endpoint beans as late as possible.