All articles

// Knowledge.log — 技術記事

@ConditionalOnProperty without matchIfMissing: the bean that vanishes when the key is absent

Spring Boot 4.1.1, JDK 25: matchIfMissing=false plus an absent key starts the app green with no bean. ApplicationContextRunner proves it; two stable fixes.

The feature was supposed to be on. The bean carries @ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true"), and nobody put app.feature.enabled into that environment's YAML. The application starts with nothing in the log, and health is green. Still, FeatureService isn't in the context. The problem shows up later, far from its cause, at the first injection point or call that expected the bean.

An app that boots green without the main bean of its feature is the kind of success nobody celebrates twice.

The goal here is to turn that behavior into a test: an ApplicationContextRunner that shows when the bean exists, when it doesn't, and the two stable ways to make sure it shows up. This is not the same as the neighboring cases. With nested ConfigurationProperties validation, startup can fail. With a Duration bound with the wrong unit in YAML, the context starts with the wrong value. Here the context starts just fine, and the bean is simply not there.

Environment and what you can reproduce

  • Spring Boot 4.1.1, the current stable release of the 4.1 line.
  • Temurin 25.0.4 and Maven 3.9.12. According to the system requirements, Boot 4.1.1 requires at least Java 17 and is compatible up to and including Java 26.
  • Dependencies: just spring-boot-starter and spring-boot-starter-test. There's no web starter and no HTTP server. AssertJ already comes with the test starter, so don't declare assertj-core again.

In the evidence project, mvn -q test finished with 9 tests, 0 failures, 0 errors and 0 skipped: 7 in FeatureConfigTests and 2 in FeatureYamlSpringApplicationTests.

The conditional bean

The configuration has a single opt-in bean:

@Configuration(proxyBeanMethods = false)
public class FeatureConfig {

    @Bean
    @ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true")
    FeatureService featureService() {
        return new FeatureService();
    }

}

Look at the attribute that isn't there. In the 4.1.1 ConditionalOnProperty javadoc, matchIfMissing defaults to false: "By default missing attributes do not match." If the key doesn't exist, the condition fails and the bean isn't registered. The context doesn't treat that as an error, because skipping beans is exactly what @Conditional is for.

The matrix with ApplicationContextRunner

ApplicationContextRunner starts a non-web context with only the configuration you give it. One runner lets you vary the properties and check the result each time:

class FeatureConfigTests {

    private final ApplicationContextRunner runner = new ApplicationContextRunner()
            .withUserConfiguration(FeatureConfig.class);

    @Test
    void absentKeyDoesNotRegisterBeanAndContextStillStarts() {
        this.runner.run((context) -> {
            assertThat(context).hasNotFailed();
            assertThat(context).doesNotHaveBean(FeatureService.class);
        });
    }

    @Test
    void havingValueTrueRegistersSingleBean() {
        this.runner.withPropertyValues("app.feature.enabled=true").run((context) -> {
            assertThat(context).hasNotFailed();
            assertThat(context).hasSingleBean(FeatureService.class);
        });
    }

}

That assertThat is org.assertj.core.api.Assertions.assertThat(context). The hasSingleBean, doesNotHaveBean and hasNotFailed methods come from ApplicationContextAssert. Don't use context.assertThat(), which is deprecated. The pair that matters is hasNotFailed + doesNotHaveBean. It tells "the context broke" apart from "the context started and the bean was never registered", and the second case is exactly what you see in production.

The six scenarios run through the runner came out like this:

ConfigurationFeatureService beanContext
key absent, matchIfMissing default (false), havingValue="true"absent (doesNotHaveBean)started (hasNotFailed)
app.feature.enabled=truehasSingleBeanstarted
app.feature.enabled=falseabsentstarted
matchIfMissing=true, key absenthasSingleBeanstarted
app.feature.enabled= (empty string)absentstarted
app.feature.enabled=TRUEhasSingleBeanstarted

None of the rows brought the context down. In the three rows where the bean doesn't appear (missing key, false, empty string), the application would start normally.

Why the missing key doesn't match

The OnPropertyCondition code in 4.1.1 is short. The full key is prefix + name. If the Environment contains the key, its value is compared against havingValue. If it doesn't, the outcome depends only on matchIfMissing: false means no match, true means match.

The comparison is the part that catches people off guard:

  • If havingValue is not empty, the rule is havingValue.equalsIgnoreCase(value).
  • If havingValue is empty, the rule is "the value is not false".

That's why TRUE matches "true" and the empty string doesn't. havingValue looks like a boolean, but it's a case-insensitive string comparison. TRUE gets through. Whoever configures yes is in for a long bean hunt.

With real YAML, no server

The runner uses withPropertyValues, which puts into the Environment the same key the YAML would. To check against an actual file, the project starts a non-web SpringApplication with src/test/resources/feature-on.yml:

app:
  feature:
    enabled: true
@Test
void yamlTrueRegistersBeanWithoutWebServer() {
    AssertableApplicationContext context = AssertableApplicationContext.get(() -> {
        SpringApplication application = new SpringApplication(FeatureConfig.class);
        application.setWebApplicationType(WebApplicationType.NONE);
        return application.run("--spring.config.location=classpath:feature-on.yml");
    });
    try {
        assertThat(context).hasNotFailed();
        assertThat(context).hasSingleBean(FeatureService.class);
    }
    finally {
        context.close();
    }
}

With the YAML in place you get hasSingleBean. The sibling test points at optional:classpath:does-not-exist.yml, which is the same as an environment where nobody set the key. The context comes up and the assertion is doesNotHaveBean. That's the first row of the table again, this time from a configuration file.

The two stable exits

There are only two ways to guarantee the bean.

1. On by default: matchIfMissing = true.

@ConditionalOnProperty(name = "app.feature.enabled", havingValue = "true", matchIfMissing = true)

Without the key, the bean is registered. To turn it off, someone writes false.

2. Explicit opt-in: the key is present in the YAML. Put app.feature.enabled: true in the environment's file. The annotation stays opt-in, and each environment's configuration takes over the responsibility.

DevDojo's recommendation depends on how the product is meant to behave. If the feature should be on when nobody configured anything, use matchIfMissing = true and keep the default in code, right next to the bean. If it really is opt-in, leave the annotation as it is and write a test that expects the bean under the environment's real configuration. That test fails when the key disappears, and the build is a better place to find out than production. What doesn't work is treating the feature as on by default while the annotation is still opt-in.

Pitfalls

  • havingValue is a string. "true" and "TRUE" both match (tested). Under the equalsIgnoreCase rule, values like yes, 1 or on don't match "true". Those three aren't in the executed matrix, but the same rule applies.
  • The default havingValue is "", not "true". Without havingValue, the condition becomes "key present and not false", so any value at all, like foo, matches. If you want boolean semantics, ConditionalOnBooleanProperty exists since 3.5. Its havingValue is a boolean that defaults to true. Its matchIfMissing is also false.
  • matchIfMissing = true doesn't beat an explicit false. If the key exists, its value is compared as usual. app.feature.enabled=false turns the bean off even with matchIfMissing = true.
  • prefix + name. prefix = "app.feature" with name = "enabled" becomes app.feature.enabled, and the implementation adds the dot for you. Don't put a dot on both sides.
  • Multiple names are ANDed. Every key has to pass. One missing key (with matchIfMissing = false) is enough to keep the bean out.

Next step

Choose one of the exits for each flag: matchIfMissing = true if the product default is on, or a required key in the YAML if the feature is opt-in. Then, in the module that owns the flag, put both assertions on the same runner: hasSingleBean with the configuration that turns the feature on, and doesNotHaveBean with the key absent, each paired with hasNotFailed. It's only a few lines, and it covers the case where the app boots green without the bean.

javaspring-boot

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles