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
- 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).
- 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.
- 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.
- 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:
- 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.
@Import(Outer.Inner.class) semantics are intentionally preserved. Projects that deliberately use nested classes as @Import namespacing targets are unaffected.
- 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.
Use case
A common pattern when organizing related
@Configurationclasses is to group them asstaticnested classes inside a containing@Configuration, with a@Conditionalon the outer that is intended to gate the whole group:The developer's expectation: if
MyFeatureConditiondoes not match, none of the nested configurations register their beans.In practice this works when
MyFeatureConfigis registered directly or imported via@Import, because the parser's recursion into member classes sets up animportedByreference on each inner class, and the existing inheritance mechanism inConfigurationClassBeanDefinitionReader.TrackedConditionEvaluator.shouldSkip(spring-context/.../ConfigurationClassBeanDefinitionReader.java:509-531) walks that chain at REGISTER_BEAN phase.It silently breaks down when
MyFeatureConfighappens to live in a package reached by@ComponentScan. The scan picks up bothMyFeatureConfigand its nested classes as independent bean-definition candidates. The outer is correctly skipped by its condition, but each nested@Configurationis parsed using only its own metadata: it has noimportedByreference, so no inheritance applies, and its beans register regardless of whetherMyFeatureConditionmatched. 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
@Configurationclasses 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
@Configuration. This is the current idiomatic workaround and matches the pattern used everywhere in Spring Boot'smodule/*. 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).@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.processMemberClassesrecursion 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.ConfigurationClassParserandConfigurationClassBeanDefinitionReaderdirectly. Confirmed that the existing inheritance is real butimportedBy-keyed (see:509-531quoted above and:731-749in the parser); confirmed that@ComponentScanand directregister(...)leaveimportedByempty and silently escape the inheritance; confirmed there is no equivalent inheritance at PARSE_CONFIGURATION phase beyond the implicit gating ofprocessMemberClasses.What I am proposing
Extend the two existing inheritance points to also walk the lexical enclosing chain (via
MetadataReaderFactory) whenconfigClass.isImported()is false. Surgical, additive, and intentionally bounded:@ImportorprocessMemberClasses), the existingimportedBy-based mechanism continues to drive inheritance, and the lexical walk is intentionally skipped.@ComponentScanor directregister(...)), 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'sMetadataReaderFactorythrough to the reader so the new walk has access to bytecode metadata for enclosing classes). Plus a one-paragraph addition to the@ConditionalJavadoc NOTE to document the behavior (the existing NOTE atConditional.java:46-52is silent on enclosing-class inheritance, even thoughimportedBy-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.
./gradlew checkon the full Spring Framework tree./gradlew :spring-context:test --tests "*Aot*" --tests "*AOT*"./gradlew :spring-test:test --tests "*Aot*" --tests "*AOT*"./gradlew :spring-beans:test --tests "*Aot*" --tests "*AOT*"7.0.8-SNAPSHOTFramework with this patch in~/.m2/repositorySeven 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 existingimportedBy-keyed path is left untouched, the new lexical walk activates only for paths that have no existing inheritance, and the@ComponentScandefensive guard (Gh23206Tests) keeps its currentimportedBymatching 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@Configurationin the ecosystem; rejecting it was reasonable.This proposal narrows the framing in three concrete ways:
processMemberClassesand@Importdiscovery paths viaimportedBy. 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.@Import(Outer.Inner.class)semantics are intentionally preserved. Projects that deliberately use nested classes as@Importnamespacing targets are unaffected.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
Environmentproperty such asspring.context.nested-configuration.inherit-enclosing-conditionsread 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.javaextending 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-251current PARSE_CONFIGURATION gate (no enclosing walk).ConfigurationClassParser.java:408-436processMemberClasses(setsimportedBy = outeron recursion).ConfigurationClassParser.java:731-749collectRegisterBeanConditions+getEnclosingConfigurationClass(existingimportedBy-keyed inheritance for the@ComponentScanguard).ConfigurationClassBeanDefinitionReader.java:505-531TrackedConditionEvaluator.shouldSkip(existingimportedBy-keyed REGISTER_BEAN inheritance).Conditional.java:46-52Javadoc NOTE disclaiming@Conditionalinheritance; currently scoped to superclasses and overridden methods only.