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 {
// @Bean — return 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 { }
// @ImportResource — import 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;
}
}