DEV Community

Silver_dev
Silver_dev

Posted on

Spring Boot Under the Hood. Part 4: @SpringBootApplication — Three Annotations, Three Mechanisms

We've reached the point where the BeanDefinitionLoader (Part 3) has registered the BeanDefinition for your main class. But that's where the magic of @SpringBootApplication only begins — the class itself is marked with this annotation, and it kicks off three independent mechanisms. Let's break down each one.

4.1. @SpringBootApplication — a Composed Annotation

Let's look at its definition (Spring Boot 3.x):

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { 
    @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
    @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) 
})
public @interface SpringBootApplication {
    // aliases for @ComponentScan
    @AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
    String[] scanBasePackages() default {};

    @AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
    Class<?>[] scanBasePackageClasses() default {};

    // aliases for @EnableAutoConfiguration
    @AliasFor(annotation = EnableAutoConfiguration.class)
    Class<?>[] exclude() default {};

    @AliasFor(annotation = EnableAutoConfiguration.class)
    String[] excludeName() default {};
}
Enter fullscreen mode Exit fullscreen mode

Key point: @SpringBootApplication is not "one annotation that does everything" — it's three separate annotations, each kicking off its own mechanism. And each of them is processed at a different time, by different classes.

4.2. The Three Mechanisms and When They Fire

Annotation What it does When it fires Processed by
@SpringBootConfiguration Marks the class as @Configuration ConfigurationClassPostProcessor ConfigurationClassParser
@ComponentScan Looks for @Components in the package ConfigurationClassPostProcessor ComponentScanAnnotationParser
@EnableAutoConfiguration Loads auto-configurations ConfigurationClassPostProcessor AutoConfigurationImportSelector

All three are processed inside ConfigurationClassPostProcessor — a BeanFactoryPostProcessor invoked by refreshContext() during invokeBeanFactoryPostProcessors(). That is, before regular beans are created.

The diagram:

refreshContext()
  └─ AbstractApplicationContext.refresh()
        └─ invokeBeanFactoryPostProcessors()
              └─ ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry()
                    └─ ConfigurationClassParser.parse()
                          ├─ @SpringBootConfiguration → registerAsConfigClass()
                          ├─ @ComponentScan         → doProcessConfigurationClass() → scan()
                          └─ @EnableAutoConfiguration → AutoConfigurationImportSelector
Enter fullscreen mode Exit fullscreen mode

4.3. @SpringBootConfiguration — "I'm a Configuration"

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
@Indexed
public @interface SpringBootConfiguration {
    @AliasFor(annotation = Configuration.class)
    boolean proxyBeanMethods() default true;
}
Enter fullscreen mode Exit fullscreen mode

It's just @Configuration with a label saying "I'm Spring Boot's main configuration class." Why a separate annotation? To:

  1. Distinguish the main class from other @Configuration classes (used in tests: @SpringBootTest looks specifically for @SpringBootConfiguration).
  2. Add @Indexed (more on that below).

proxyBeanMethods = true (the default) means the class will be wrapped in a CGLIB proxy, and calls to @Bean methods within the class will be intercepted — this guarantees that a bean is created only once (singleton semantics).

4.4. @ComponentScan — How Scanning Works

4.4.1. Where the Base Package Comes From

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(ComponentScans.class)
public @interface ComponentScan {
    @AliasFor("basePackages")
    String[] value() default {};

    @AliasFor("value")
    String[] basePackages() default {};

    Class<?>[] basePackageClasses() default {};

    // ...
}
Enter fullscreen mode Exit fullscreen mode

If neither basePackages nor basePackageClasses is specified, scanning starts from the package of the class the annotation is on.

package com.example.myapp;   // ← the root package

@SpringBootApplication
public class MyApplication { }
Enter fullscreen mode Exit fullscreen mode

Everything under com.example.myapp and its sub-packages (com.example.myapp.service, com.example.myapp.web, etc.) will be found.

4.4.2. Why the "Root Package" Matters

If you put your main class in com.example.myapp and a service in com.other.service, the service won't be found. This isn't a Spring bug — it's standard scanning behavior.

Correct:

com.example.myapp
  ├─ MyApplication.java      ← @SpringBootApplication
  ├─ service/
  │    └─ UserService.java   ← @Service  ✅ will be found
  └─ web/
       └─ UserController.java ← @Controller ✅ will be found
Enter fullscreen mode Exit fullscreen mode

Incorrect:

com.example.myapp
  └─ MyApplication.java

com.other.service            ← outside the root package
  └─ UserService.java        ← @Service  ❌ NOT found
Enter fullscreen mode Exit fullscreen mode

The exception is if you specify scanBasePackages explicitly:

@SpringBootApplication(scanBasePackages = {"com.example.myapp", "com.other.service"})
Enter fullscreen mode Exit fullscreen mode

4.4.3. basePackageClasses — a Type-Safe Alternative

Instead of strings, you can pass marker classes:

@SpringBootApplication(
    scanBasePackageClasses = {MyApplication.class, AnotherMarker.class}
)
Enter fullscreen mode Exit fullscreen mode

The packages are derived automatically from the packages of the given classes. This is safer: if you refactor (rename a package), the compiler will catch the mistake.

4.4.4. What Exactly the Scanner Looks For

ClassPathBeanDefinitionScanner looks for classes marked with stereotype annotations:

Annotation Semantics
@Component the base stereotype
@Service business logic
@Repository data access (+ exception translation)
@Controller MVC controller
@RestController @Controller + @ResponseBody
@Configuration configuration class (also a component)

Important: all of them are meta-annotated with @Component. The scanner looks for @Component following the chain of meta-annotations.

4.4.5. The Mechanics of Scanning — ClassPathScanningCandidateComponentProvider

This class does the heavy lifting:

public class ClassPathScanningCandidateComponentProvider {
    private final List<TypeFilter> includeFilters = new ArrayList<>();
    private final List<TypeFilter> excludeFilters = new ArrayList<>();

    public Set<BeanDefinition> findCandidateComponents(String basePackage) {
        // 1. Collect all .class files in the package
        // 2. For each, read the metadata (via ASM, not reflection!)
        // 3. Apply the include/exclude filters
        // 4. Return the matching BeanDefinitions
    }
}
Enter fullscreen mode Exit fullscreen mode

Key point: the metadata is read via ASM (SimpleMetadataReader), not via Class.forName(). This means the scanner doesn't load classes during scanning — it only reads the headers of .class files. Reflection comes into play later, at bean creation time.

4.5. Filters: includeFilters and excludeFilters

4.5.1. FilterType — Five Strategies

public enum FilterType {
    ANNOTATION,        // by annotation
    ASSIGNABLE_TYPE,   // by type (class/interface)
    ASPECTJ,           // by AspectJ expression
    REGEX,             // by regex on the class name
    CUSTOM             // your own TypeFilter
}
Enter fullscreen mode Exit fullscreen mode

Examples:

// ANNOTATION: include everything annotated with @MyService
@ComponentScan(includeFilters = @Filter(type = FilterType.ANNOTATION, 
    classes = MyService.class))

// ASSIGNABLE_TYPE: include everything that implements Animal
@ComponentScan(includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, 
    classes = Animal.class))

// CUSTOM: your own logic
@ComponentScan(includeFilters = @Filter(type = FilterType.CUSTOM, 
    classes = MyCustomFilter.class))
Enter fullscreen mode Exit fullscreen mode

When multiple classes are listed in a single @Filter, OR logic applies: "the type is annotated with @foo OR @bar".

4.5.2. useDefaultFilters — an Important Flag

@ComponentScan(
    useDefaultFilters = false,
    includeFilters = @Filter(type = FilterType.ANNOTATION, 
        classes = MyService.class)
)
Enter fullscreen mode Exit fullscreen mode

By default, useDefaultFilters = true, meaning the scanner automatically includes @Component, @Service, @Repository, and @Controller. If you want to match only your own annotations, set it to false.

4.5.3. TypeExcludeFilter — Spring Boot's Extensible Filter

Recall that @SpringBootApplication contains:

@ComponentScan(excludeFilters = { 
    @Filter(type = CUSTOM, classes = TypeExcludeFilter.class),
    @Filter(type = CUSTOM, classes = AutoConfigurationExcludeFilter.class) 
})
Enter fullscreen mode Exit fullscreen mode

TypeExcludeFilter is an extension point. It doesn't filter anything on its own. Instead, at startup it fetches all beans implementing TypeExcludeFilter from the BeanFactory and applies their match() methods:

public class TypeExcludeFilter implements TypeFilter, BeanFactoryAware {
    @Override
    public boolean match(MetadataReader metadataReader, 
            MetadataReaderFactory metadataReaderFactory) {
        if (this.beanFactory instanceof ListableBeanFactory lbf) {
            Collection<TypeExcludeFilter> filters = lbf.getBeansOfType(
                TypeExcludeFilter.class).values();
            return filters.stream().anyMatch(filter -> 
                filter.match(metadataReader, metadataReaderFactory));
        }
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

This lets you plug in your own filters via @Bean:

@Bean
MyExcludeFilter myExcludeFilter() {
    return new MyExcludeFilter();
}
Enter fullscreen mode Exit fullscreen mode

Your filter will be applied automatically during scanning. It's a powerful but little-known mechanism.

4.5.4. AutoConfigurationExcludeFilter — the Second Built-in Filter

This filter excludes auto-configurations from scanning. The logic:

@Override
public boolean match(MetadataReader metadataReader, 
        MetadataReaderFactory factory) {
    return isConfiguration(metadataReader) && isAutoConfiguration(metadataReader);
}
Enter fullscreen mode Exit fullscreen mode

In other words, if a class is a @Configuration and is an auto-configuration (registered in .imports / spring.factories), it is not picked up by component scanning. Auto-configurations are loaded separately, via @EnableAutoConfiguration. Without this filter, they would be registered twice.

4.6. @AutoConfigurationPackage — the "Hidden" Annotation

Let's look at the definition of @EnableAutoConfiguration once more:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

@AutoConfigurationPackage is what remembers the package of your main class:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Import(AutoConfigurationPackages.Registrar.class)
public @interface AutoConfigurationPackage { }
Enter fullscreen mode Exit fullscreen mode

AutoConfigurationPackages.Registrar registers an AutoConfigurationPackages.BasePackages bean holding the list of base packages. Why? So that other auto-configurations know where to look for your entities:

  • Spring Data JPA (@EntityScan) — uses the base package to find @Entity classes.
  • Spring Data MongoDB — the same.
  • Spring Boot DevTools — for restarts.

If you put an @Entity outside the root package, JPA won't find it — because @AutoConfigurationPackage only remembered com.example.myapp.

4.7. @Indexed and META-INF/spring.components — Speeding Up Scanning

4.7.1. The Problem

At startup, Spring Boot scans the classpath. In large projects this can take seconds — especially when there are many JARs on the classpath.

4.7.2. The Solution: @Indexed

Spring Framework 5.0 introduced @Indexed:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Indexed { }
Enter fullscreen mode Exit fullscreen mode

All stereotype annotations (@Component, @Service, @Repository, @Controller, @Configuration, @SpringBootConfiguration) are meta-annotated with @Indexed.

4.7.3. How It Works

At compile time, an annotation processor (spring-context-indexer) generates the file:

META-INF/spring.components
Enter fullscreen mode Exit fullscreen mode

Example content:

com.example.myapp.service.UserService=org.springframework.stereotype.Component
com.example.myapp.web.UserController=org.springframework.stereotype.Component
com.example.myapp.config.AppConfig=org.springframework.context.annotation.Configuration
Enter fullscreen mode Exit fullscreen mode

At startup, CandidateComponentsIndexLoader reads this file and builds a CandidateComponentsIndex. Instead of scanning the classpath, ClassPathScanningCandidateComponentProvider simply reads the index.

4.7.4. How to Enable It

Option 1 — the annotation on the main class:

@SpringBootApplication
@Indexed
public class MyApplication { }
Enter fullscreen mode Exit fullscreen mode

Option 2 — a dependency + annotation processor:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context-indexer</artifactId>
    <optional>true</optional>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Important caveat: @Indexed switches on the index. If an index exists, scanning goes through it. If an index exists but you've added a new @Component without recompiling, it won't be found. The index must be rebuilt on every build.

4.8. Diagram: the Full Path of @ComponentScan

ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry()
  │
  └─ ConfigurationClassParser.parse()
        │
        ├─ processConfigurationClass(MyApplication.class)
        │     │
        │     ├─ @SpringBootConfiguration → registerAsConfigClass()
        │     │
        │     ├─ @ComponentScan → doProcessConfigurationClass()
        │     │     │
        │     │     ├─ ComponentScanAnnotationParser.parse()
        │     │     │     ├─ basePackages = {pkg(MyApplication)}
        │     │     │     ├─ excludeFilters = [TypeExcludeFilter, AutoConfigurationExcludeFilter]
        │     │     │     └─ useDefaultFilters = true
        │     │     │
        │     │     └─ ClassPathBeanDefinitionScanner.doScan(basePackages)
        │     │           │
        │     │           ├─ CandidateComponentsIndexLoader.loadIndex()   ← @Indexed
        │     │           │     └─ if the index exists → read spring.components
        │     │           │
        │     │           └─ findCandidateComponents(basePackage)
        │     │                 ├─ for each .class:
        │     │                 │     ├─ SimpleMetadataReader (ASM)
        │     │                 │     ├─ isCandidateComponent() → include/exclude
        │     │                 │     └─ if it passes → BeanDefinition
        │     │                 │
        │     │                 └─ registerBeanDefinition()
        │     │
        │     └─ @EnableAutoConfiguration → AutoConfigurationImportSelector
        │           └─ auto-configuration loading   ← Part 5
        │
        └─ ...
Enter fullscreen mode Exit fullscreen mode

4.9. Spring Boot 3 vs Spring Boot 4

Aspect Spring Boot 3.x Spring Boot 4.x
@ComponentScan Unchanged Unchanged
@SpringBootApplication Unchanged Unchanged
New mechanism BeanRegistrar — programmatic bean registration as an alternative to scanning
Modularity spring-boot-autoconfigure — a monolith 47 modular JARs, each with its own scanning
Starters spring-boot-starter-web spring-boot-starter-webmvc
@Indexed Works Works, but less relevant due to AOT

The main thing in Boot 4: the core behavior of @ComponentScan and @SpringBootApplication hasn't changed. But there's a new player — BeanRegistrar, an alternative way to register beans without scanning:

public class MyBeanRegistrar implements BeanRegistrar {
    @Override
    public void register(BeanRegistry registry, Environment env) {
        if (env.acceptsProfiles(Profiles.of("dev"))) {
            registry.registerBean("myService", MyService.class);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is closer to the AOT approach: beans are registered programmatically rather than discovered by scanning.

4.10. Key Takeaways

  1. @SpringBootApplication = three annotations, all processed by ConfigurationClassPostProcessor during refresh().
  2. The base package for scanning = the main class's package. Anything outside it won't be found unless you specify scanBasePackages explicitly.
  3. ClassPathBeanDefinitionScanner reads metadata via ASM without loading the classes.
  4. TypeExcludeFilter is an extensible point: you can add your own filter via a @Bean method.
  5. AutoConfigurationExcludeFilter keeps auto-configurations out of component scanning so they aren't registered twice.
  6. @AutoConfigurationPackage remembers the base package for JPA/Spring Data and other auto-configurations.
  7. @Indexed + META-INF/spring.components speeds up scanning in large projects.
  8. Boot 4: the core behavior is unchanged, but BeanRegistrar has appeared as an alternative to scanning.

Top comments (0)