Skip to main content
spring internals

Configuration Class Parsing and @Bean Method Registration

8 min read Chapter 6 of 78

Configuration Class Parsing and @Bean Method Registration

The Annotation

@Configuration marks a class as a source of bean definitions. Developers learn this in their first Spring tutorial. What the tutorial does not explain: there are two modes of @Configuration processing, they produce different runtime behavior, and using the wrong one causes a category of bugs that is invisible until the application is under load or someone writes a specific integration test.

The Mechanism

ConfigurationClassPostProcessor: The Driver

ConfigurationClassPostProcessor is a BeanDefinitionRegistryPostProcessor registered automatically by Spring Boot. It runs during phase 1 (bean definition time) and does three things:

  1. Identifies configuration class candidates. It scans the registry for any BeanDefinition whose class is annotated with @Configuration, @Component, @ComponentScan, @Import, or @ImportResource, or any class that has at least one @Bean method.

  2. Parses each configuration class. A ConfigurationClassParser walks through the class hierarchy, processing @PropertySource, @ComponentScan (triggering nested scanning), @Import, @ImportResource, and collecting all @Bean methods.

  3. Registers BeanDefinitions. A ConfigurationClassBeanDefinitionReader converts each parsed @Bean method into a ConfigurationClassBeanDefinition and registers it in the BeanDefinitionRegistry.

For the SaaS backend, consider the security configuration:

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder
            .withJwkSetUri("https://auth.saas-platform.com/.well-known/jwks.json")
            .build();
    }
}

When ConfigurationClassPostProcessor processes SecurityConfig, it creates two ConfigurationClassBeanDefinition objects:

  • filterChain: factory bean = securityConfig, factory method = filterChain, with a constructor argument dependency on HttpSecurity
  • jwtDecoder: factory bean = securityConfig, factory method = jwtDecoder, no constructor arguments

These are registered in the DefaultListableBeanFactory alongside the ScannedGenericBeanDefinition entries from component scanning. The factory bean reference means that when Spring instantiates filterChain, it will call securityConfig.filterChain(http) on an instance of SecurityConfig.

Full Mode: The Default

A class annotated with @Configuration (without proxyBeanMethods = false) runs in full mode. During ConfigurationClassPostProcessor processing, Spring marks the class for CGLIB enhancement. Later, when the SecurityConfig bean definition is instantiated, Spring does not create a plain SecurityConfig object. It creates a CGLIB subclass: SecurityConfig$$SpringCGLIB$$0.

This subclass intercepts every method call. When a @Bean method is called, the interceptor checks: has this bean already been created? If yes, return the existing singleton from the BeanFactory. If no, proceed with the original method to create it.

This interception is what makes inter-bean references work. Consider:

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        var manager = new CaffeineCacheManager();
        manager.setCaffeine(caffeineSpec());  // calls another @Bean method
        return manager;
    }

    @Bean
    public Caffeine<Object, Object> caffeineSpec() {
        return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(30));
    }
}

In full mode, cacheManager() calls caffeineSpec(). The CGLIB interceptor catches this call, looks up the caffeineSpec bean in the BeanFactory, and returns the singleton. The Caffeine instance used inside CacheManager is the same singleton instance available to every other bean that injects Caffeine<Object, Object>.

The CGLIB subclass is stored in place of the original class. If you call context.getBean(CacheConfig.class), you get the enhanced subclass. getClass() returns CacheConfig$$SpringCGLIB$$0. instanceof CacheConfig returns true.

Lite Mode: @Component with @Bean Methods

Any class that has @Bean methods but is not annotated with @Configuration runs in lite mode. Classes annotated with @Component, @Service, @Controller, or @Import that happen to contain @Bean methods are all lite. So is @Configuration(proxyBeanMethods = false).

In lite mode, Spring does not create a CGLIB subclass. The class is instantiated as-is. @Bean methods are registered as factory methods in the BeanDefinitionRegistry, but there is no method interception.

The consequence: calling one @Bean method from another @Bean method within the same class creates a new object every time. The call is a plain Java method call. Spring is not involved.

proxyBeanMethods: The Explicit Switch

Spring Framework 5.2 introduced @Configuration(proxyBeanMethods = false) as an explicit opt-out of CGLIB enhancement. Spring Boot’s own auto-configuration classes use this extensively:

@Configuration(proxyBeanMethods = false)
public class TenantAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public TenantResolver tenantResolver() {
        return new HeaderBasedTenantResolver();
    }

    @Bean
    @ConditionalOnMissingBean
    public TenantFilter tenantFilter(TenantResolver resolver) {
        return new TenantFilter(resolver);
    }
}

Why does Spring Boot use proxyBeanMethods = false for auto-configuration? Three reasons:

Startup performance. CGLIB enhancement involves generating bytecode for a subclass at runtime. For hundreds of auto-configuration classes, this adds measurable startup time. GraalVM native image compilation also handles non-CGLIB classes more efficiently.

No inter-bean references. Well-designed auto-configuration classes pass dependencies through method parameters (TenantFilter tenantFilter(TenantResolver resolver)) rather than calling sibling @Bean methods directly. When dependencies are injected via parameters, CGLIB interception is unnecessary because Spring resolves the parameter through the BeanFactory.

Clarity. proxyBeanMethods = false documents that this class does not rely on method interception. Future developers cannot accidentally add an inter-bean reference and expect it to be intercepted.

The Failure Mode

A developer creates a configuration class for the SaaS notification system. They use @Component instead of @Configuration because the class also needs to be injected elsewhere (a common but misguided pattern):

// BROKEN: @Component with @Bean methods runs in lite mode.
// Inter-bean references create new instances.
@Component
public class NotificationConfig {

    @Bean
    public ObjectMapper notificationObjectMapper() {
        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        return mapper;
    }

    @Bean
    public NotificationSerializer notificationSerializer() {
        // This calls notificationObjectMapper() directly.
        // In lite mode, this is a plain Java method call.
        // A NEW ObjectMapper is created. It is NOT the singleton
        // registered in the BeanFactory.
        return new NotificationSerializer(notificationObjectMapper());
    }

    @Bean
    public NotificationClient notificationClient() {
        // Another plain Java call. Another new ObjectMapper.
        // Three ObjectMapper instances now exist:
        // 1. The singleton in the BeanFactory (injected into other beans)
        // 2. The one inside NotificationSerializer
        // 3. The one inside NotificationClient
        return new NotificationClient(notificationObjectMapper());
    }
}

Three ObjectMapper instances exist. The one in the BeanFactory (created when Spring instantiates the notificationObjectMapper bean) has the JavaTimeModule registered. The two created by direct method calls also have it. But if someone later adds a BeanPostProcessor that configures additional modules on the ObjectMapper bean, only the BeanFactory instance receives that configuration. The other two are invisible to Spring’s post-processing pipeline.

This bug manifests as serialization inconsistencies: timestamps format correctly in some paths and incorrectly in others. The application starts without errors. Unit tests pass because they construct ObjectMapper directly. Integration tests might pass because the specific code path under test happens to use the BeanFactory instance. The bug appears in production when a notification payload serializes a LocalDateTime differently from the REST API.

Debugging this requires comparing object identity. If notificationSerializer receives a different ObjectMapper instance than the one in the BeanFactory, the configuration class is running in lite mode. The /actuator/beans endpoint will not reveal this because it shows the beans in the factory, not the orphaned instances created by direct method calls.

The Correct Pattern

Two options, depending on whether inter-bean references are needed.

Option 1: Use @Configuration (full mode) when methods call each other.

// CORRECT: @Configuration enables CGLIB interception.
// Inter-bean calls return the singleton from the BeanFactory.
@Configuration
public class NotificationConfig {

    @Bean
    public ObjectMapper notificationObjectMapper() {
        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        return mapper;
    }

    @Bean
    public NotificationSerializer notificationSerializer() {
        // CGLIB intercepts this call. Returns the BeanFactory singleton.
        return new NotificationSerializer(notificationObjectMapper());
    }

    @Bean
    public NotificationClient notificationClient() {
        // Same interception. Same singleton. One ObjectMapper instance total.
        return new NotificationClient(notificationObjectMapper());
    }
}

Option 2: Use parameter injection and proxyBeanMethods = false.

// CORRECT: Dependencies injected via method parameters.
// No inter-bean method calls. No CGLIB needed.
@Configuration(proxyBeanMethods = false)
public class NotificationConfig {

    @Bean
    public ObjectMapper notificationObjectMapper() {
        var mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        return mapper;
    }

    @Bean
    public NotificationSerializer notificationSerializer(
            ObjectMapper notificationObjectMapper) {
        // Spring injects the BeanFactory singleton via the parameter.
        return new NotificationSerializer(notificationObjectMapper);
    }

    @Bean
    public NotificationClient notificationClient(
            ObjectMapper notificationObjectMapper) {
        // Same singleton, injected by Spring, not by method call.
        return new NotificationClient(notificationObjectMapper);
    }
}

Option 2 is the stronger choice. It makes dependencies explicit in the method signature, removes the CGLIB overhead, and is compatible with GraalVM native image compilation without additional configuration. Every @Bean method declares what it needs. Spring satisfies those needs through the BeanFactory. No method interception is required because no method calls happen.

The decision rule: if your @Bean methods reference each other by calling sibling methods, use @Configuration (full mode) or refactor to parameter injection. If they do not reference each other, use @Configuration(proxyBeanMethods = false). Never use @Component or @Service as a configuration class. Those stereotypes are for scanned components, not for factory method containers.

Verifying the Mode at Runtime

If you suspect a configuration class is running in the wrong mode, check whether the bean is CGLIB-enhanced:

@Component
public class ConfigModeVerifier implements SmartInitializingSingleton {

    private final ApplicationContext context;

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

    @Override
    public void afterSingletonsInstantiated() {
        var config = context.getBean(NotificationConfig.class);
        boolean isEnhanced = config.getClass().getName().contains("$$SpringCGLIB$$");

        if (!isEnhanced) {
            // Running in lite mode. Inter-bean references will create
            // new instances instead of returning singletons.
            System.err.println("WARNING: NotificationConfig is NOT CGLIB-enhanced. " +
                "Inter-bean method calls will create duplicate instances.");
        }
    }
}

This is a development-time diagnostic, not a production pattern. But when you are tracking down why two beans have different instances of what should be a shared dependency, this check answers the question in one line.