Summary
When the same class is registered under two different bean names — one via an explicit <bean> XML declaration, and another via <context:component-scan> — Spring 6.2.x removes the component-scanned bean during ConfigurationClassPostProcessor.processConfigBeanDefinitions(), resulting in a NoSuchBeanDefinitionException at runtime.
This is a regression. Spring 6.1.x handles this scenario correctly; both beans are retained.
Affected Versions
| Version |
Behaviour |
| 6.1.21 (and all 6.1.x) |
✅ Both beans registered correctly |
| 6.2.0 |
❌ Component-scanned bean removed |
| 6.2.1 |
❌ Component-scanned bean removed |
| 7.0.x (branch HEAD as of this report) |
❌ Still present |
Minimal Reproducer
Class
@Component("demoObject")
public class DemoObject {
// nothing special
}
XML file A (applicationContext-A.xml)
<beans xmlns="http://www.springframework.org/schema/beans" ...>
<!-- Explicit XML declaration under a different name -->
<bean id="myDemoObject" class="com.example.demo.DemoObject"/>
</beans>
XML file B (applicationContext-B.xml)
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context" ...>
<!-- Component scan that registers demoObject -->
<context:component-scan base-package="com.example.demo"/>
</beans>
Main context (applicationContext.xml)
<beans xmlns="http://www.springframework.org/schema/beans" ...>
<import resource="classpath*:META-INF/**/applicationContext-*.xml"/>
<context:annotation-config/>
</beans>
Driver
try (var ctx = new ClassPathXmlApplicationContext("applicationContext.xml")) {
ctx.getBean("myDemoObject"); // ✅ works
ctx.getBean("demoObject"); // ❌ NoSuchBeanDefinitionException in 6.2.x
}
Expected: Both myDemoObject and demoObject are available.
Actual (6.2.x): demoObject is missing → NoSuchBeanDefinitionException.
Root Cause
Commit that introduced the regression
| Field |
Value |
| Hash |
22b41c33bab1f1c609e7948136d233ad233ac3e2 |
| Title |
"Preserve existing imported class over scanned configuration class" |
| Date |
2024-02-20 |
| Closes |
gh-24643 |
| File |
spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java |
How it triggers
Key fact 1 — ConfigurationClass equality is by class name only:
// ConfigurationClass.java
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof ConfigurationClass that &&
getMetadata().getClassName().equals(that.getMetadata().getClassName())));
}
public int hashCode() {
return getMetadata().getClassName().hashCode();
}
Two ConfigurationClass instances wrapping the same Java class are equal, regardless of their bean names.
Key fact 2 — component-scanned beans are flagged scanned=true:
Commit 22b41c33ba introduced a new constructor overload:
// ConfigurationClassParser.java (v6.2+)
private void parse(AnnotatedBeanDefinition beanDef, String beanName) {
processConfigurationClass(
new ConfigurationClass(beanDef.getMetadata(), beanName,
(beanDef instanceof ScannedGenericBeanDefinition)), // scanned=true for @ComponentScan beans
DEFAULT_EXCLUSION_FILTER);
}
Key fact 3 — the new isScanned() branch calls removeBeanDefinition:
// ConfigurationClassParser.processConfigurationClass() — v6.2+ only
ConfigurationClass existingClass = this.configurationClasses.get(configClass);
if (existingClass != null) {
if (configClass.isImported()) {
// merge or ignore
return;
}
else if (configClass.isScanned()) {
// ⚠️ BUG: removes the bean from the registry unconditionally
String beanName = configClass.getBeanName();
if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) {
this.registry.removeBeanDefinition(beanName); // ← "demoObject" is deleted here
}
// "An implicitly scanned bean definition should not override an explicit import."
return;
}
else {
// pre-6.2 path: only removes from in-memory tracking map, not from registry
this.configurationClasses.remove(configClass);
}
}
Step-by-step execution trace for the reproducer:
ConfigurationClassPostProcessor collects config candidates: [myDemoObject, demoObject]
myDemoObject is processed first:
configurationClasses.get(DemoObject_key) → null (first occurrence)
- Stored:
configurationClasses.put(DemoObject_key, ConfigurationClass("myDemoObject", scanned=false))
demoObject is processed second:
configurationClasses.get(DemoObject_key) → finds myDemoObject (same class name!)
existingClass != null → true
configClass.isImported() → false
configClass.isScanned() → true ← hits new code
registry.removeBeanDefinition("demoObject") is called ← bean is deleted
- Method returns early
Why this is incorrect
The comment in the code says: "An implicitly scanned bean definition should not override an explicit import."
The word import is key. The intent of gh-24643 was to handle a specific scenario where a class is both @Import-ed (via another @Configuration class) and component-scanned. In that case, the @Import registration should win.
However, the implementation does not check whether the existing registration is actually an import (existingClass.isImported()). It fires the removal logic whenever the new candidate is scanned — even when the existing registration is a plain, unrelated XML <bean> declaration under a different name.
Because ConfigurationClass.equals() uses class name only, two beans with different names but the same class are incorrectly treated as the same configuration class. When one was scanned, it is removed even though it is a legitimate, independently-named bean.
Behaviour difference vs. 6.1.x
In Spring 6.1.x, the else if (configClass.isScanned()) branch did not exist. The else path only modified the in-memory configurationClasses Map; it never called registry.removeBeanDefinition(). Both bean definitions survived.
Suggested Fix
The condition should additionally check that the existing class was registered as an imported class before proceeding with bean definition removal. If the existing class is a plain XML bean (not imported via @Import), there is no reason to remove the scanned one.
Option A: Guard with existingClass.isImported() (minimal, precise)
// ConfigurationClassParser.processConfigurationClass()
else if (configClass.isScanned()) {
if (existingClass.isImported()) {
// Only remove the scanned duplicate if it conflicts with an @Import registration.
String beanName = configClass.getBeanName();
if (StringUtils.hasLength(beanName) && this.registry.containsBeanDefinition(beanName)) {
this.registry.removeBeanDefinition(beanName);
}
}
// An implicitly scanned bean definition should not override an explicit import.
return;
}
Rationale: Matches the stated intent in the comment. The removal was designed for @Import vs. @ComponentScan conflicts. Plain XML beans are not imports.
Option B: Also guard by checking bean name uniqueness
Even among @Import scenarios, removing a scanned registration only makes sense when there is a genuine name conflict. If the existing class has a different bean name, they might both be legitimate:
else if (configClass.isScanned()) {
if (existingClass.isImported() &&
configClass.getBeanName() != null &&
configClass.getBeanName().equals(existingClass.getBeanName())) {
// Same bean name, genuinely duplicate — remove the scanned one.
String beanName = configClass.getBeanName();
if (this.registry.containsBeanDefinition(beanName)) {
this.registry.removeBeanDefinition(beanName);
}
}
return;
}
Option C: Align ConfigurationClass.equals() to include bean name
A more structural fix: make two ConfigurationClass instances equal only when both class name and bean name match. This would prevent the false collision entirely:
// ConfigurationClass.java
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof ConfigurationClass that &&
getMetadata().getClassName().equals(that.getMetadata().getClassName()) &&
Objects.equals(this.beanName, that.beanName)));
}
public int hashCode() {
return Objects.hash(getMetadata().getClassName(), this.beanName);
}
⚠️ Note: Option C is the most thorough but has the widest blast radius — configurationClasses Map semantics are used throughout the parser. It should be evaluated carefully for side-effects on the original gh-24643 fix and other scenarios (e.g., member classes, import chains).
Recommendation: Start with Option A as it is the least invasive, directly matches the intent of the original commit, and is easy to reason about.
spring-demo.zip
Summary
When the same class is registered under two different bean names — one via an explicit
<bean>XML declaration, and another via<context:component-scan>— Spring 6.2.x removes the component-scanned bean duringConfigurationClassPostProcessor.processConfigBeanDefinitions(), resulting in aNoSuchBeanDefinitionExceptionat runtime.This is a regression. Spring 6.1.x handles this scenario correctly; both beans are retained.
Affected Versions
Minimal Reproducer
Class
XML file A (
applicationContext-A.xml)XML file B (
applicationContext-B.xml)Main context (
applicationContext.xml)Driver
Expected: Both
myDemoObjectanddemoObjectare available.Actual (6.2.x):
demoObjectis missing →NoSuchBeanDefinitionException.Root Cause
Commit that introduced the regression
22b41c33bab1f1c609e7948136d233ad233ac3e2spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.javaHow it triggers
Key fact 1 —
ConfigurationClassequality is by class name only:Two
ConfigurationClassinstances wrapping the same Java class are equal, regardless of their bean names.Key fact 2 — component-scanned beans are flagged
scanned=true:Commit
22b41c33baintroduced a new constructor overload:Key fact 3 — the new
isScanned()branch callsremoveBeanDefinition:Step-by-step execution trace for the reproducer:
ConfigurationClassPostProcessorcollects config candidates:[myDemoObject, demoObject]myDemoObjectis processed first:configurationClasses.get(DemoObject_key)→ null (first occurrence)configurationClasses.put(DemoObject_key, ConfigurationClass("myDemoObject", scanned=false))demoObjectis processed second:configurationClasses.get(DemoObject_key)→ findsmyDemoObject(same class name!)existingClass != null→ trueconfigClass.isImported()→ falseconfigClass.isScanned()→ true ← hits new coderegistry.removeBeanDefinition("demoObject")is called ← bean is deletedWhy this is incorrect
The comment in the code says: "An implicitly scanned bean definition should not override an explicit import."
The word import is key. The intent of gh-24643 was to handle a specific scenario where a class is both
@Import-ed (via another@Configurationclass) and component-scanned. In that case, the@Importregistration should win.However, the implementation does not check whether the existing registration is actually an import (
existingClass.isImported()). It fires the removal logic whenever the new candidate is scanned — even when the existing registration is a plain, unrelated XML<bean>declaration under a different name.Because
ConfigurationClass.equals()uses class name only, two beans with different names but the same class are incorrectly treated as the same configuration class. When one was scanned, it is removed even though it is a legitimate, independently-named bean.Behaviour difference vs. 6.1.x
In Spring 6.1.x, the
else if (configClass.isScanned())branch did not exist. Theelsepath only modified the in-memoryconfigurationClassesMap; it never calledregistry.removeBeanDefinition(). Both bean definitions survived.Suggested Fix
The condition should additionally check that the existing class was registered as an imported class before proceeding with bean definition removal. If the existing class is a plain XML bean (not imported via
@Import), there is no reason to remove the scanned one.Option A: Guard with
existingClass.isImported()(minimal, precise)Rationale: Matches the stated intent in the comment. The removal was designed for
@Importvs.@ComponentScanconflicts. Plain XML beans are not imports.Option B: Also guard by checking bean name uniqueness
Even among
@Importscenarios, removing a scanned registration only makes sense when there is a genuine name conflict. If the existing class has a different bean name, they might both be legitimate:Option C: Align
ConfigurationClass.equals()to include bean nameA more structural fix: make two
ConfigurationClassinstances equal only when both class name and bean name match. This would prevent the false collision entirely:configurationClassesMap semantics are used throughout the parser. It should be evaluated carefully for side-effects on the original gh-24643 fix and other scenarios (e.g., member classes, import chains).Recommendation: Start with Option A as it is the least invasive, directly matches the intent of the original commit, and is easy to reason about.
spring-demo.zip