Skip to content

@Conditional inheritance on nested @Configuration is sensitive to discovery path #36820

@Dennis-Mircea

Description

@Dennis-Mircea

Use case

A common pattern when organizing related @Configuration classes is to group them as static nested classes inside a containing @Configuration, with a @Conditional on the outer that is intended to gate the whole group:

@Configuration
@Conditional(MyFeatureCondition.class)
class MyFeatureConfig {

    @Configuration
    static class DataAccess {
        @Bean DataSource dataSource() { ... }
    }

    @Configuration
    static class Web {
        @Bean WebFilter myFilter() { ... }
    }
}

The developer's expectation: if MyFeatureCondition does not match, none of the nested configurations register their beans.

In practice this works when MyFeatureConfig is registered directly or imported via @Import, because the parser's recursion into member classes sets up an importedBy reference on each inner class, and the existing inheritance mechanism in ConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator.shouldSkip (spring-context/.../ConfigurationClassBeanDefinitionReader.java:509-531) walks that chain at REGISTER_BEAN phase.

It silently breaks down when MyFeatureConfig happens to live in a package reached by @ComponentScan. The scan picks up both MyFeatureConfig and its nested classes as independent bean-definition candidates. The outer is correctly skipped by its condition, but each nested @Configuration is parsed using only its own metadata: it has no importedBy reference, so no inheritance applies, and its beans register regardless of whether MyFeatureCondition matched. The same lexical arrangement therefore behaves differently depending purely on how the inner happens to be discovered, with no diagnostic and no documentation that flags discovery path as relevant.

This surfaces in production code as "my bean appeared even though the feature was disabled" or, in tests, as fixtures that work when registered directly but stop working when scanned. Spring Boot's auto-configuration codebase is full of nested @Configuration classes for exactly this kind of grouping, which is why every Spring Boot auto-config defensively redeclares conditions on each nested class.

What I have tried

  1. Redeclaring the conditions on every nested @Configuration. This is the current idiomatic workaround and matches the pattern used everywhere in Spring Boot's module/*. It works, but it scales poorly when there are many nested classes, and the duplication is easy to drift on (someone adds a new nested class and forgets the condition; the bug becomes silent again).
  2. Extracting the conditions into a custom meta-annotation (@ConditionalOnMyFeature) and applying it to outer + each inner. Reduces line-by-line duplication but does not address the underlying discovery-path-dependent asymmetry; the inner classes still need to carry the marker.
  3. Reshaping the outer's condition to PARSE_CONFIGURATION phase so the parser's processMemberClasses recursion skips the inners. Works only for inners reached via member-class recursion, and stops working as soon as the inner is also reachable via @ComponentScan.
  4. Investigated ConfigurationClassParser and ConfigurationClassBeanDefinitionReader directly. Confirmed that the existing inheritance is real but importedBy-keyed (see :509-531 quoted above and :731-749 in the parser); confirmed that @ComponentScan and direct register(...) leave importedBy empty and silently escape the inheritance; confirmed there is no equivalent inheritance at PARSE_CONFIGURATION phase beyond the implicit gating of processMemberClasses.

What I am proposing

Extend the two existing inheritance points to also walk the lexical enclosing chain (via MetadataReaderFactory) when configClass.isImported() is false. Surgical, additive, and intentionally bounded:

  • For configs that are imported (via @Import or processMemberClasses), the existing importedBy-based mechanism continues to drive inheritance, and the lexical walk is intentionally skipped.
  • For configs that are not imported (via @ComponentScan or direct register(...)), the lexical enclosing chain is walked and any vetoing condition on any enclosing class skips the inner.

This explicitly does not change @Import(Outer.Inner.class) semantics: an inner imported by a third party still does not pick up Outer's conditions, preserving the case where nested classes are deliberately used as a namespacing tactic with @Import.

The change is ~75 lines across three files (ConfigurationClassParser, ConfigurationClassBeanDefinitionReader, ConfigurationClassPostProcessor, the last only to wire the parser's MetadataReaderFactory through to the reader so the new walk has access to bytecode metadata for enclosing classes). Plus a one-paragraph addition to the @Conditional Javadoc NOTE to document the behavior (the existing NOTE at Conditional.java:46-52 is silent on enclosing-class inheritance, even though importedBy-based inheritance has existed for years).

Stability evidence

The proposed extension was applied and validated end-to-end. Every existing test passes; there are zero regressions.

Validation Result
./gradlew check on the full Spring Framework tree all green
./gradlew :spring-context:test --tests "*Aot*" --tests "*AOT*" all green
./gradlew :spring-test:test --tests "*Aot*" --tests "*AOT*" all green
./gradlew :spring-beans:test --tests "*Aot*" --tests "*AOT*" all green
Spring Boot test suite, run against a 7.0.8-SNAPSHOT Framework with this patch in ~/.m2/repository all green (~10 min run)

Seven new tests in EnclosingConditionConfigurationTests (five core scenarios plus two edge cases: enclosing-interface @Conditional, and mixed-phase interaction where the inner declares its own matching condition but the outer's vetoes it) all pass with the extension applied. Without the extension, six of the seven fail; the one that passes is the deliberate control case where the outer's condition matches. This is a clean differential signal that the new tests measure exactly the new behavior and nothing else.

The !isImported() scoping is what makes this stable: every existing importedBy-keyed path is left untouched, the new lexical walk activates only for paths that have no existing inheritance, and the @ComponentScan defensive guard (Gh23206Tests) keeps its current importedBy matching plus the additive lexical walk for the non-imported case.

Relation to prior discussion

Closely related to #37111 Nested static configuration class doesn't use the context of the surrounding configuration class, which was raised against Spring Boot and closed as status: invalid. That issue suggested wholesale inheritance of all conditions onto all nested classes, which would have changed the meaning of every nested @Configuration in the ecosystem; rejecting it was reasonable.

This proposal narrows the framing in three concrete ways:

  1. Inheritance already exists in Spring Framework for processMemberClasses and @Import discovery paths via importedBy. The framework has already opted into enclosing-class condition inheritance; it just has not extended that opt-in to all discovery paths. The change closes a discovery-path-dependent asymmetry rather than introducing a new inheritance model.
  2. @Import(Outer.Inner.class) semantics are intentionally preserved. Projects that deliberately use nested classes as @Import namespacing targets are unaffected.
  3. Zero regressions against the full Spring Framework test suite and the Spring Boot test suite (validated locally). The redeclare pattern in downstream code continues to work; redeclared conditions become redundant where the enclosing already gates, but harmless.

If the team is sympathetic to the framing but uncomfortable with a silent semantic change at the 7.x line, an opt-in flag is a straightforward fallback: an Environment property such as spring.context.nested-configuration.inherit-enclosing-conditions read once in the parser and reader would default to current behavior and require explicit opt-in. Happy to draft that variant if it would tip the decision.

If the answer is "no, even narrowed," a documentation-only update to Conditional.java extending the existing inheritance NOTE to also explicitly cover the enclosing-class case (already included in the patch) would still close a real gap for users.

Pull request

A pull request with the full implementation, the seven new tests, and the Javadoc update is being opened in parallel with this issue: #36819 The PR carries the same content as this issue plus the actual diff, which is the easiest way to see the change and the test support together. Reviewers can evaluate the proposal end-to-end without needing to wait for further code.

Affected file:line references (spring-context main)

  • ConfigurationClassParser.java:248-251 current PARSE_CONFIGURATION gate (no enclosing walk).
  • ConfigurationClassParser.java:408-436 processMemberClasses (sets importedBy = outer on recursion).
  • ConfigurationClassParser.java:731-749 collectRegisterBeanConditions + getEnclosingConfigurationClass (existing importedBy-keyed inheritance for the @ComponentScan guard).
  • ConfigurationClassBeanDefinitionReader.java:505-531 TrackedConditionEvaluator.shouldSkip (existing importedBy-keyed REGISTER_BEAN inheritance).
  • Conditional.java:46-52 Javadoc NOTE disclaiming @Conditional inheritance; currently scoped to superclasses and overridden methods only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    in: coreIssues in core modules (aop, beans, core, context, expression)status: waiting-for-triageAn issue we've not yet triaged or decided on

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions