Skip to content

Regression in 6.2.0+: ConfigurationClassParser incorrectly removes component-scanned bean when the same class is also registered under a different name via XML #36835

@sammyhk

Description

@sammyhk

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:

  1. ConfigurationClassPostProcessor collects config candidates: [myDemoObject, demoObject]
  2. myDemoObject is processed first:
    • configurationClasses.get(DemoObject_key)null (first occurrence)
    • Stored: configurationClasses.put(DemoObject_key, ConfigurationClass("myDemoObject", scanned=false))
  3. 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

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)status: backportedAn issue that has been backported to maintenance branchestype: regressionA bug that is also a regression

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions