Spring BootJava-based Configuration
Spring Boot

Java-based Configuration

Java-based configuration uses @Configuration classes and @Bean methods to define Spring beans programmatically — replacing XML configuration with type-safe, IDE-friendly Java code. It gives you full control over bean creation, allows conditional logic, and integrates seamlessly with component scanning. Understanding Java config is essential for customizing Spring Boot's auto-configuration and building modular, testable applications.

What Is Java-based Configuration?

Java-based configuration was introduced in Spring 3.0 as a type-safe, refactoring-friendly alternative to XML configuration. Instead of declaring beans in applicationContext.xml, you declare them as methods in @Configuration classes. The method's return value becomes a Spring-managed bean, and the method name (by default) becomes the bean name. Java config has three significant advantages over XML: compile-time type checking (typos are errors, not runtime failures), full IDE support (navigation, refactoring, auto-complete), and the ability to use any Java logic — loops, conditions, factory methods — to construct beans. Spring Boot is built almost entirely on Java-based configuration. Every auto-configuration class you see in the spring-boot-autoconfigure JAR is a @Configuration class.

@Configuration and @Bean

@Configuration marks a class as a source of bean definitions. Methods inside it annotated with @Bean produce beans managed by the Spring container. This is the foundation of all Java-based configuration.
Java
// @Configuration — declares this class as a bean definition source:
@Configuration
public class AppConfig {

    // @Beanreturn value is registered as a Spring bean:
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    // Bean name defaults to method name — "passwordEncoder"
    // Inject elsewhere: @Autowired PasswordEncoder passwordEncoder;

    // Custom bean name:
    @Bean(name = "strongEncoder")
    public PasswordEncoder strongPasswordEncoder() {
        return new BCryptPasswordEncoder(14);
    }

    // Multiple names (aliases):
    @Bean(name = {"jwtService", "tokenService", "authService"})
    public JwtTokenService jwtTokenService() {
        return new JwtTokenService("secret", 3600);
    }

    // Dependencies between @Bean methods — call the method directly:
    @Bean
    public UserService userService() {
        // Spring intercepts this call and returns the SAME singleton instance
        // Does NOT create a new PasswordEncoder — returns the cached bean
        return new UserService(userRepository(), passwordEncoder());
    }

    @Bean
    public UserRepository userRepository() {
        return new JpaUserRepository();
    }

    // Dependencies injected as parameters (preferred over method calls):
    @Bean
    public OrderService orderService(OrderRepository orderRepository,
                                     PaymentService paymentService,
                                     EmailService emailService) {
        // Spring resolves and injects these parameters automatically
        return new OrderService(orderRepository, paymentService, emailService);
    }
}

CGLIB Proxy — Why @Bean Method Calls Work

When you call another @Bean method from within a @Configuration class, Spring intercepts the call and returns the existing singleton — it doesn't create a new instance. This works because Spring creates a CGLIB subclass of your @Configuration class.
Java
// How Spring handles @Bean method calls:
@Configuration
public class DataConfig {

    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        config.setMaximumPoolSize(10);
        return new HikariDataSource(config);
    }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        // Calls dataSource() — Spring intercepts this call
        return new JdbcTemplate(dataSource());
    }

    @Bean
    public UserRepository userRepository() {
        // Calls dataSource() again — Spring returns the SAME DataSource instance
        return new JdbcUserRepository(dataSource());
    }

    // Both jdbcTemplate and userRepository share the SAME DataSource bean
    // There is only ONE HikariDataSource — the singleton
}

// Without CGLIB — @Configuration(proxyBeanMethods = false):
@Configuration(proxyBeanMethods = false)  // no CGLIB proxy
public class LiteConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(config);
    }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        // dataSource() is a regular method call — creates a NEW DataSource!
        // jdbcTemplate and userRepository would have DIFFERENT DataSource instances!
        return new JdbcTemplate(dataSource());
    }
}

// proxyBeanMethods = false (Lite Mode):
// - FASTER startup (no CGLIB proxy generated)
// - SAFE when @Bean methods don't call other @Bean methods
// - Used heavily in Spring Boot's own auto-configuration for performance
// - Should be your default when building @Configuration classes that
//   inject dependencies via parameters instead of method calls

// Best practice — use parameter injection to avoid the issue entirely:
@Configuration(proxyBeanMethods = false)
public class SafeConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(hikariConfig());
    }

    @Bean
    public JdbcTemplate jdbcTemplate(DataSource dataSource) {
        // DataSource injected as parameter — works in both proxy modes
        return new JdbcTemplate(dataSource);
    }

    @Bean
    public UserRepository userRepository(DataSource dataSource) {
        // Same DataSource injected — correct in both modes
        return new JdbcUserRepository(dataSource);
    }
}

Bean Customization — Scope, Lazy, Primary, Qualifier

@Bean methods support the full set of bean configuration options — changing scope, deferring initialization, marking a bean as the default candidate, and adding qualifiers for disambiguation.
Java
@Configuration
public class BeanCustomizationConfig {

    // Prototype scope — new instance on every injection:
    @Bean
    @Scope("prototype")
    public ReportGenerator reportGenerator() {
        return new ReportGenerator();
    }

    // Request scope — one per HTTP request:
    @Bean
    @Scope(value = WebApplicationContext.SCOPE_REQUEST,
           proxyMode = ScopedProxyMode.TARGET_CLASS)
    public RequestContext requestContext() {
        return new RequestContext();
    }

    // Lazy — created only on first use:
    @Bean
    @Lazy
    public HeavyAnalyticsService analyticsService() {
        // Not created at startup — initialized on first injection
        return new HeavyAnalyticsService();
    }

    // Primary — preferred when multiple beans of the same type exist:
    @Bean
    @Primary
    public PaymentService stripePaymentService() {
        return new StripePaymentService(stripeApiKey);
    }

    @Bean
    public PaymentService paypalPaymentService() {
        return new PaypalPaymentService(paypalClientId);
    }

    // Qualifier — disambiguate when @Primary isn't sufficient:
    @Bean
    @Qualifier("fast")
    public CacheService inMemoryCacheService() {
        return new CaffeineCache();
    }

    @Bean
    @Qualifier("persistent")
    public CacheService redisCacheService() {
        return new RedisCache();
    }

    // DependsOn — ensure another bean is created first:
    @Bean
    @DependsOn("databaseMigration")
    public UserRepository userRepository(DataSource dataSource) {
        return new JpaUserRepository(dataSource);
    }

    @Bean
    public DatabaseMigration databaseMigration(DataSource dataSource) {
        return new FlywayMigration(dataSource);
    }

    // Lifecycle callbacks:
    @Bean(initMethod = "start", destroyMethod = "stop")
    public SchedulerService schedulerService() {
        return new QuartzSchedulerService();
    }
}

Importing Configuration Classes

@Import brings additional @Configuration classes into the current context — enabling modular configuration. @ImportResource imports legacy XML configuration. Both allow you to compose configuration from multiple sources.
Java
// @Import — pull in other @Configuration classes:
@Configuration
@Import({
    SecurityConfig.class,
    CacheConfig.class,
    MessagingConfig.class
})
public class AppConfig {
    // SecurityConfig, CacheConfig, MessagingConfig beans are now all available
}

// @Import used in feature-flag style — import configuration conditionally:
@Configuration
@ConditionalOnProperty(name = "feature.payments", havingValue = "true")
@Import(PaymentConfig.class)
public class FeatureConfig { }

// @ImportResourceimport legacy XML configuration:
@Configuration
@ImportResource("classpath:legacy-beans.xml")
public class LegacyMigrationConfig {
    // Beans from XML are available alongside Java-configured beans
    // Useful during migration from XML to Java config
}

// @Import with ImportSelector — conditionally import classes based on logic:
public class PaymentModuleSelector implements ImportSelector {

    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        String provider = System.getProperty("payment.provider", "stripe");
        return switch (provider) {
            case "paypal" -> new String[]{"com.example.config.PaypalConfig"};
            case "braintree" -> new String[]{"com.example.config.BraintreeConfig"};
            default -> new String[]{"com.example.config.StripeConfig"};
        };
    }
}

@Configuration
@Import(PaymentModuleSelector.class)
public class AppConfig { }

// @Import with ImportBeanDefinitionRegistrar — register beans programmatically:
public class DynamicRepositoryRegistrar implements ImportBeanDefinitionRegistrar {

    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata,
                                        BeanDefinitionRegistry registry) {
        // Scan for interfaces annotated with @MyRepository and register implementations
        BeanDefinitionBuilder builder = BeanDefinitionBuilder
            .genericBeanDefinition(DynamicRepositoryFactory.class);
        registry.registerBeanDefinition("dynamicRepo", builder.getBeanDefinition());
    }
}

Modular Configuration — Real-World Structure

Large Spring Boot applications split configuration across multiple @Configuration classes organized by concern. Each class configures one aspect of the infrastructure — security, persistence, caching, messaging.
Java
// config/SecurityConfig.java — security configuration:
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

// config/PersistenceConfig.java — database configuration:
@Configuration
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.example.myapp.repository")
public class PersistenceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public HikariDataSource dataSource(DataSourceProperties props) {
        return props.initializeDataSourceBuilder()
                    .type(HikariDataSource.class)
                    .build();
    }

    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName);
    }
}

// config/CacheConfig.java — cache configuration:
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())
            );

        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .withCacheConfiguration("users", config.entryTtl(Duration.ofMinutes(30)))
            .withCacheConfiguration("products", config.entryTtl(Duration.ofHours(1)))
            .build();
    }
}

// config/AsyncConfig.java — async execution configuration:
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig implements AsyncConfigurer {

    @Override
    @Bean
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
            log.error("Async exception in {}: {}", method.getName(), ex.getMessage(), ex);
    }
}

Conditional Configuration

Java-based configuration supports all the @Conditional annotations — enabling feature-flag configuration, environment-specific beans, and override patterns that are the foundation of Spring Boot's auto-configuration.
Java
// Feature flag configuration — enable/disable entire feature sets:
@Configuration
@ConditionalOnProperty(name = "feature.notifications.enabled", havingValue = "true",
                       matchIfMissing = true)
public class NotificationConfig {

    @Bean
    public EmailNotificationService emailNotificationService(MailProperties props) {
        return new SmtpEmailNotificationService(props);
    }

    @Bean
    public SmsNotificationService smsNotificationService(TwilioProperties props) {
        return new TwilioSmsService(props);
    }

    @Bean
    public NotificationOrchestrator notificationOrchestrator(
            EmailNotificationService email,
            SmsNotificationService sms) {
        return new NotificationOrchestrator(email, sms);
    }
}

// Environment-specific configuration:
@Configuration
@Profile("dev")
public class DevelopmentConfig {

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .addScript("classpath:schema.sql")
            .addScript("classpath:test-data.sql")
            .build();
    }

    @Bean
    public StorageService storageService() {
        return new LocalFileStorageService("/tmp/uploads");
    }
}

@Configuration
@Profile("prod")
public class ProductionConfig {

    @Bean
    public DataSource dataSource(DataSourceProperties props) {
        return DataSourceBuilder.create()
            .url(props.getUrl())
            .username(props.getUsername())
            .password(props.getPassword())
            .build();
    }

    @Bean
    public StorageService storageService(S3Properties props) {
        return new AwsS3StorageService(props.getBucket(), props.getRegion());
    }
}

// Override pattern — default bean with @ConditionalOnMissingBean:
@Configuration
public class DefaultConfig {

    @Bean
    @ConditionalOnMissingBean(MetricsCollector.class)
    public MetricsCollector noOpMetricsCollector() {
        return new NoOpMetricsCollector();  // default — does nothing
    }
}

// Any @Configuration class can override by defining a MetricsCollector bean:
@Configuration
@Profile("prod")
public class ProductionMetricsConfig {

    @Bean
    public MetricsCollector prometheusMetricsCollector() {
        return new PrometheusMetricsCollector();  // overrides default
    }
}

Java Config vs Component Scanning — When to Use Each

Java-based configuration and component scanning are complementary — not competing approaches. Most Spring Boot applications use both. Knowing when each is appropriate keeps your codebase clean and consistent.
Java
// USE COMPONENT SCANNING (@Service, @Repository, @Component) when:
// - The class IS the implementation (not configuring an external class)
// - Creation is straightforward — no factory logic needed
// - The class is part of your application code

@Service
public class UserService { }   // your class, simple creation → @Service

@Repository
public interface UserRepository extends JpaRepository<User, Long> { }   // your interface@Repository

// USE @Bean in @Configuration when:
// - Configuring a THIRD-PARTY class (you can't add @Component to Stripe's SDK)
// - Creation requires factory logic, conditional branches, or multiple steps
// - You need to produce multiple beans from one method
// - You want a named variant of an existing bean

@Configuration
public class InfrastructureConfig {

    // Third-party class — can't annotate the source:
    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            .addModule(new JavaTimeModule())
            .build();
    }

    // Requires factory logic:
    @Bean
    public RestTemplate restTemplate() {
        RestTemplateBuilder builder = new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(10))
            .additionalInterceptors(new LoggingInterceptor());
        return builder.build();
    }

    // Named variant:
    @Bean("longTimeoutRestTemplate")
    public RestTemplate longTimeoutRestTemplate() {
        return new RestTemplateBuilder()
            .setReadTimeout(Duration.ofSeconds(60))
            .build();
    }
}

// COMBINATION — most real-world applications use both:
@Service                    // component scanning registers UserService
public class UserService {
    private final ObjectMapper objectMapper;     // @Bean-configured
    private final RestTemplate restTemplate;    // @Bean-configured
    private final UserRepository userRepository; // JPA interface — component scanned

    public UserService(ObjectMapper objectMapper,
                       RestTemplate restTemplate,
                       UserRepository userRepository) {
        this.objectMapper = objectMapper;
        this.restTemplate = restTemplate;
        this.userRepository = userRepository;
    }
}