Skip to main content
spring internals

Bean Definition Registration and the Component Scanning Pipeline

8 min read Chapter 5 of 78

Bean Definition Registration and the Component Scanning Pipeline

The Annotation

@ComponentScan looks simple. You put it on a configuration class, optionally specify a base package, and Spring finds your @Component, @Service, @Repository, and @Controller classes. The mental model most developers carry is “Spring searches for annotations and creates beans.” That model is wrong in three ways: Spring does not search for annotations (it reads bytecode metadata), it does not create beans (it creates bean definitions), and the search scope is more nuanced than “everything in this package.”

The Mechanism

Component scanning is a two-step pipeline. Step one: find candidate classes on the classpath. Step two: convert each candidate into a ScannedGenericBeanDefinition and register it in the DefaultListableBeanFactory. The machinery lives in ClassPathBeanDefinitionScanner and its parent class ClassPathScanningCandidateComponentProvider.

ClassPathScanningCandidateComponentProvider: The File Scanner

When @ComponentScan(basePackages = "com.saas") is processed, Spring converts the package name to a resource pattern: classpath*:com/saas/**/*.class. It then uses PathMatchingResourcePatternResolver to find every .class file under that package hierarchy in every JAR, directory, and classpath entry.

For the SaaS backend with 200 application classes, 15 JARs from Spring Boot starters, and another 40 from transitive dependencies, this resolution might touch thousands of .class files. Spring does not load all of them into the JVM. This is critical for startup performance.

ASM-Based Metadata Reading

Spring uses the ASM bytecode library to read class metadata without loading the class through the ClassLoader. The class SimpleMetadataReaderFactory creates MetadataReader instances that parse the .class file’s bytecode directly. This gives Spring access to:

  • Class name, superclass, interfaces
  • All annotations on the class (including meta-annotations)
  • All annotations on methods
  • Whether the class is abstract, final, an interface, an annotation

The key class is SimpleAnnotationMetadataReadingVisitor, which implements ASM’s ClassVisitor interface. It visits the bytecode structure and extracts annotation attributes without triggering static initializers, without resolving dependencies, and without loading the class.

Why does this matter? Because loading a class has side effects. Static initializers run. Dependent classes must be loadable. If a scanned class references a library that is not on the classpath (an optional dependency, a test-scoped dependency), loading it would throw NoClassDefFoundError. ASM-based reading avoids this entirely.

// Spring reads this class's annotations via ASM bytecode parsing.
// The class is NOT loaded by the ClassLoader during scanning.
// Only when Spring instantiates the bean (phase 2) does class loading occur.
@Service
public class TenantProvisioningService {

    private final TenantRepository tenantRepository;
    private final SchemaManager schemaManager;

    public TenantProvisioningService(TenantRepository tenantRepository,
                                      SchemaManager schemaManager) {
        this.tenantRepository = tenantRepository;
        this.schemaManager = schemaManager;
    }

    public Tenant provision(String tenantName) {
        var tenant = Tenant.create(tenantName);
        schemaManager.createSchema(tenant.schemaName());
        return tenantRepository.save(tenant);
    }
}

During scanning, Spring knows this class has @Service (which is meta-annotated with @Component). It knows the class name. It does not know about TenantRepository or SchemaManager yet, and it does not need to. Those dependencies are resolved during instantiation, not scanning.

TypeFilter: The Candidate Selection Logic

After finding a .class file and reading its metadata, Spring applies inclusion and exclusion filters to decide whether this class is a component candidate.

The default include filter is AnnotationTypeFilter(Component.class). Since @Service, @Repository, and @Controller are all meta-annotated with @Component, this single filter catches all stereotype annotations.

Filters are applied in order:

  1. Check all exclude filters. If any match, the class is rejected immediately.
  2. Check all include filters. If any match, the class is a candidate.
  3. If no include filter matches, the class is rejected.

Custom filters let you include classes that have no Spring annotations at all:

@Configuration
@ComponentScan(
    basePackages = "com.saas.domain",
    includeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        classes = DomainService.class
    )
)
public class DomainConfig {
    // Scans com.saas.domain and registers any class that implements
    // DomainService, even without @Component or @Service
}

The FilterType enum provides five strategies:

  • ANNOTATION: Match classes with a specific annotation (default)
  • ASSIGNABLE_TYPE: Match classes assignable to a specific type
  • ASPECTJ: Match using an AspectJ type pattern expression
  • REGEX: Match the fully qualified class name against a regex
  • CUSTOM: Provide a TypeFilter implementation

For the SaaS backend, ASSIGNABLE_TYPE filters are useful for registering domain service interfaces without polluting the domain layer with Spring annotations.

ClassPathBeanDefinitionScanner: From Candidate to BeanDefinition

Once a candidate passes the filter, ClassPathBeanDefinitionScanner.doScan() converts it into a ScannedGenericBeanDefinition. This involves:

  1. Create the definition. Instantiate ScannedGenericBeanDefinition with the MetadataReader. The bean class name is set from the bytecode metadata.

  2. Apply scope metadata. The ScopeMetadataResolver checks for @Scope annotations. Default is singleton. If the class has @Scope("request") or @Scope("prototype"), the definition records that.

  3. Generate the bean name. AnnotationBeanNameGenerator checks for an explicit name in @Component("myName"). If none is specified, it derives the name from the short class name with the first letter lowercased: TenantProvisioningService becomes tenantProvisioningService.

  4. Process common definition annotations. AnnotationConfigUtils.processCommonDefinitionAnnotations() reads @Lazy, @Primary, @DependsOn, @Role, and @Description from the class and sets corresponding properties on the BeanDefinition.

  5. Check for duplicates. If a bean definition with the same name already exists in the registry, Spring checks compatibility. If the existing definition came from a different source (different class), Spring throws ConflictingBeanDefinitionException. If it came from the same class (scanned twice due to overlapping packages), it skips the duplicate.

  6. Register. Call BeanDefinitionRegistry.registerBeanDefinition(beanName, beanDefinition) on the DefaultListableBeanFactory.

How @ComponentScan Base Packages Are Resolved

When @ComponentScan has no basePackages attribute, Spring uses the package of the annotated class. This is why @SpringBootApplication (which includes @ComponentScan) is conventionally placed in the root package of the application: it scans everything beneath it.

basePackages accepts an array:

@ComponentScan(basePackages = {
    "com.saas.tenant",
    "com.saas.billing",
    "com.saas.notification"
})

There is also basePackageClasses, which is type-safe:

@ComponentScan(basePackageClasses = {
    TenantConfig.class,
    BillingConfig.class,
    NotificationConfig.class
})

Spring uses the package of each specified class as a scan root. This avoids string typos and survives refactoring.

Multiple @ComponentScan annotations (or @ComponentScans) are cumulative. Each scan operation adds to the same BeanDefinitionRegistry. If two scans overlap (both cover com.saas), the duplicate detection in step 5 prevents double registration.

The Failure Mode

The SaaS backend has a multi-module Maven structure:

saas-parent/
├── saas-api/          (com.saas.api)
├── saas-domain/       (com.saas.domain)
├── saas-billing/      (com.saas.billing)
└── saas-notification/ (com.saas.notification)

The main application class is in saas-api:

// BROKEN: Application class in com.saas.api
// Default @ComponentScan scans com.saas.api and below.
// Beans in com.saas.domain, com.saas.billing, and com.saas.notification
// are never found.
package com.saas.api;

@SpringBootApplication  // includes @ComponentScan with no basePackages
public class SaasApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaasApplication.class, args);
    }
}

The developer runs the application. The @RestController classes in com.saas.api work fine. The TenantProvisioningService in com.saas.domain is never found. The BillingService in com.saas.billing is never found. Spring does not throw an error at startup, because no bean in the scanned package depends on the unscanned beans (yet). The failure appears later, when a controller tries to inject TenantProvisioningService and gets:

No qualifying bean of type 'com.saas.domain.TenantProvisioningService' available

The developer adds @ComponentScan("com.saas.domain") to the application class. Now com.saas.domain is scanned, but com.saas.api is no longer scanned because explicit basePackages replaces the default package-based scan. The REST controllers disappear.

This cascading failure is common in multi-module projects where the default scan behavior is assumed to be project-wide rather than package-local.

The Correct Pattern

Place the application class in the root package that encompasses all modules, or specify all packages explicitly:

// CORRECT: Application class in com.saas (parent package of all modules)
package com.saas;

@SpringBootApplication
public class SaasApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaasApplication.class, args);
    }
}

This scans com.saas and everything below it: com.saas.api, com.saas.domain, com.saas.billing, com.saas.notification.

If the root package approach is not feasible (organizational constraints, shared libraries with different package roots), use explicit type-safe scan declarations:

// CORRECT: Explicit, type-safe base package classes
package com.saas.api;

@SpringBootApplication
@ComponentScan(basePackageClasses = {
    SaasApplication.class,          // com.saas.api
    DomainMarker.class,             // com.saas.domain
    BillingMarker.class,            // com.saas.billing
    NotificationMarker.class        // com.saas.notification
})
public class SaasApplication {
    public static void main(String[] args) {
        SpringApplication.run(SaasApplication.class, args);
    }
}

Each marker is an empty interface or class in the target package, existing solely to provide a type-safe package reference:

package com.saas.domain;

/**
 * Marker interface for component scanning.
 * Do not add methods or implementations.
 */
public interface DomainMarker {}

This pattern survives package renames (the compiler catches broken references), documents scan boundaries explicitly, and makes it impossible to silently miss a module.

Verification

Use the /actuator/beans endpoint to verify that expected beans are registered. For a faster feedback loop during development, add a startup check:

@Component
public class ScanVerifier implements SmartInitializingSingleton {

    private final ApplicationContext context;

    public ScanVerifier(ApplicationContext context) {
        this.context = context;
    }

    @Override
    public void afterSingletonsInstantiated() {
        var required = List.of(
            TenantProvisioningService.class,
            BillingService.class,
            NotificationDispatcher.class
        );

        for (var type : required) {
            if (context.getBeanNamesForType(type).length == 0) {
                throw new IllegalStateException(
                    "Required bean not found: " + type.getName() +
                    ". Check @ComponentScan base packages.");
            }
        }
    }
}

This fails fast at startup with a clear message. It runs after all singletons are instantiated, so it catches scan misses regardless of whether any other bean depends on the missing one.