Skip to content

Spring Boot Core Best Practices

To create a new Cursor Rule:

  1. Enter the name as sprint-boot-core
  2. Copy & paste the file content from below

For more information, visit the Project rules.

---
description: 
globs: 
alwaysApply: false
---
# Spring Boot Core

Spring Boot Core guidelines focus on proper usage of main annotations, bean management, and configuration best practices to build maintainable and efficient Spring Boot applications.

## Implementing These Principles

These guidelines are built upon the following core principles:

- Principle 1: Use appropriate Spring annotations to clearly express component responsibilities
- Principle 2: Leverage Spring's dependency injection and IoC container effectively
- Principle 3: Follow configuration best practices for maintainable and testable applications
- Principle 4: Apply proper bean lifecycle management and scoping

## Table of contents

- Rule 0: Spring Boot Main Application Class
- Rule 1: Main Spring Boot Annotations Usage
- Rule 2: Bean Definition and Management
- Rule 3: Configuration Classes and Properties
- Rule 4: Component Scanning and Package Organization
- Rule 5: Conditional Configuration and Profiles
- Rule 6: Constructor Dependency Injection Best Practices
- Rule 7: Bean Minimization and Composition
- Rule 8: Scheduled Tasks and Background Processing

## Rule 0: Spring Boot Main Application Class

Title: Create a Proper Spring Boot Main Application Class
Description: Every Spring Boot application should have a main application class annotated with @SpringBootApplication. This class serves as the entry point and configuration root, combining @Configuration, @EnableAutoConfiguration, and @ComponentScan annotations.

**Good example:**

```java
@SpringBootApplication
public class MainApplication {

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

// For more complex scenarios with custom configuration
@SpringBootApplication(
    scanBasePackages = {
        "com.company.app.controller",
        "com.company.app.service", 
        "com.company.app.repository",
        "com.company.app.config"
    },
    exclude = {
        DataSourceAutoConfiguration.class,
        SecurityAutoConfiguration.class
    }
)

Bad Example:

// Missing @SpringBootApplication annotation
public class MainApplication {
    public static void main(String[] args) {
        // Manual Spring context setup instead of SpringApplication.run()
        ApplicationContext context = new AnnotationConfigApplicationContext();
        // Manual configuration - loses Spring Boot benefits
    }
}

// Using individual annotations instead of @SpringBootApplication
@Configuration
@EnableAutoConfiguration  
@ComponentScan
public class MainApplication { // Verbose and error-prone
    public static void main(String[] args) {
        SpringApplication.run(MainApplication.class, args);
    }
}

// Poor naming and structure
@SpringBootApplication
public class App { // Non-descriptive name

    @Autowired
    private UserService userService; // Business logic in main class

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);

        // Business logic in main method - should be in separate components
        System.out.println("Processing users...");
    }
}

Rule 1: Main Spring Boot Annotations Usage

Title: Use Appropriate Spring Boot Annotations for Component Definition Description: Use the correct Spring Boot annotations to define components, controllers, services, and repositories. Each annotation has specific semantics and should be used according to the layer's responsibility.

Good example:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }
}

@Service
@Transactional
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }
}

@Repository
public interface UserRepository extends CrudRepository<User, Long> {

    @Query("SELECT * FROM users WHERE email = :email")
    Optional<User> findByEmail(@Param("email") String email);

    @Modifying
    @Query("UPDATE users SET last_login = :lastLogin WHERE id = :id")
    void updateLastLogin(@Param("id") Long id, @Param("lastLogin") LocalDateTime lastLogin);
}

@Table("users")
public class User {

    @Id
    private Long id;

    @Column("email")
    private String email;

    @Column("first_name")
    private String firstName;

    @Column("last_name") 
    private String lastName;

    @Column("last_login")
    private LocalDateTime lastLogin;

    // Constructors, getters, and setters
}

Bad Example:

@Component // Should be @RestController
public class UserController {

    @Inject // Use @Autowired for Spring Boot
    private UserService userService;
}

@Component // Should be @Service
public class UserService {
    // Missing @Transactional for data operations
}

@Component // Should be @Repository
public class UserRepository {
    // Manual JDBC instead of using Spring Data JDBC
}

Rule 2: Bean Definition and Management

Title: Proper Bean Definition, Scoping, and Lifecycle Management Description: Define beans with appropriate scope, use constructor injection, and manage bean lifecycle properly. Prefer constructor injection over field injection for better testability and immutability.

Good example:

@Configuration
public class AppConfig {

    @Bean
    @Scope("singleton") // Default, but explicit for clarity
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Scope("prototype")
    public AuditLogger auditLogger() {
        return new AuditLogger();
    }
}

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    // Constructor injection - preferred approach
    public UserService(UserRepository userRepository, 
                      PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }
}

@Component
public class DatabaseMigration {

    @EventListener
    public void onApplicationReady(ApplicationReadyEvent event) {
        // Perform initialization after Spring context is ready
        performMigration();
    }

    @PreDestroy
    public void cleanup() {
        // Cleanup resources before bean destruction
    }
}

Bad Example:

@Configuration
public class AppConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // Creates new instance every time
    }
}

@Service
public class UserService {

    @Autowired // Field injection - harder to test
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    // No constructor, relies on reflection
}

@Component
public class DatabaseMigration {

    @PostConstruct
    public void init() {
        // Heavy operations in PostConstruct can block application startup
        performHeavyMigration();
    }
}

Rule 3: Configuration Classes and Properties

Title: Organize Configuration Using @Configuration Classes and External Properties Description: Use @Configuration classes to organize beans logically, leverage @ConfigurationProperties for type-safe configuration, and externalize configuration values properly.

Good example:

@Configuration
@EnableConfigurationProperties({DatabaseProperties.class, SecurityProperties.class})
public class AppConfig {

    @Bean
    @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true")
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("users", "products");
    }
}

@ConfigurationProperties(prefix = "app.database")
@ConstructorBinding
public class DatabaseProperties {

    private final String url;
    private final String username;
    private final int maxConnections;
    private final Duration connectionTimeout;

    public DatabaseProperties(String url, String username, 
                            int maxConnections, Duration connectionTimeout) {
        this.url = url;
        this.username = username;
        this.maxConnections = maxConnections;
        this.connectionTimeout = connectionTimeout;
    }

    // Getters only - immutable
}

@Configuration
@Profile("!test")
public class ProductionConfig {

    @Bean
    public DataSource dataSource(DatabaseProperties properties) {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(properties.getUrl());
        config.setUsername(properties.getUsername());
        config.setMaximumPoolSize(properties.getMaxConnections());
        return new HikariDataSource(config);
    }
}

Bad Example:

@Configuration
public class AppConfig {

    @Value("${database.url}") // Scattered @Value annotations
    private String databaseUrl;

    @Value("${database.username}")
    private String username;

    @Bean
    public DataSource dataSource() {
        // Hardcoded values mixed with properties
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(databaseUrl);
        config.setUsername(username);
        config.setPassword("hardcoded-password"); // Security risk
        config.setMaximumPoolSize(10); // Magic number
        return new HikariDataSource(config);
    }
}

// No type safety, no validation
public class DatabaseConfig {
    @Value("${app.database.max-connections:#{null}}")
    private Integer maxConnections; // Can be null, no validation
}

Rule 4: Component Scanning and Package Organization

Title: Organize Components with Proper Package Structure and Component Scanning Description: Use logical package organization and configure component scanning appropriately. Avoid over-broad scanning and organize code by feature or layer consistently.

Good example:

@SpringBootApplication
@ComponentScan(basePackages = {
    "com.company.app.controller",
    "com.company.app.service", 
    "com.company.app.repository",
    "com.company.app.config"
})
@EnableJdbcRepositories("com.company.app.repository")
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

// Package structure:
// com.company.app
//   ├── controller/
//   │   ├── UserController.java
//   │   └── ProductController.java
//   ├── service/
//   │   ├── UserService.java
//   │   └── ProductService.java
//   ├── repository/
//   │   ├── UserRepository.java
//   │   └── ProductRepository.java
//   ├── config/
//   │   ├── DatabaseConfig.java
//   │   └── SecurityConfig.java
//   └── model/
//       ├── User.java
//       └── Product.java

@Component("userService") // Explicit bean name when needed
public class UserService {
    // Implementation
}

Bad Example:

@SpringBootApplication
@ComponentScan("com") // Too broad - scans entire classpath
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

// Poor package structure:
// com.company.app
//   ├── UserController.java      // Mixed responsibilities
//   ├── UserService.java         // in same package
//   ├── UserRepository.java
//   ├── ProductStuff.java        // Unclear naming
//   └── Utils.java               // Generic naming

@Component
public class UserService { // No explicit naming strategy
    // Multiple unrelated responsibilities in one class
    public void handleUser() { }
    public void sendEmail() { }
    public void generateReport() { }
}

Rule 5: Conditional Configuration and Profiles

Title: Use Conditional Configuration and Profiles for Environment-Specific Setup Description: Leverage Spring's conditional annotations and profiles to create flexible, environment-aware configurations that adapt to different deployment scenarios.

Good example:

@Configuration
@Profile("development")
public class DevConfig {

    @Bean
    @ConditionalOnMissingBean
    public Clock clock() {
        return Clock.systemDefaultZone();
    }

    @Bean
    public DataSource devDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/devdb");
        config.setUsername("dev_user");
        config.setPassword("dev_password");
        config.setMaximumPoolSize(5);
        return new HikariDataSource(config);
    }
}

@Configuration
@Profile("production")
public class ProdConfig {

    @Bean
    @ConditionalOnProperty(
        name = "app.monitoring.enabled", 
        havingValue = "true", 
        matchIfMissing = true
    )
    public MeterRegistry meterRegistry() {
        return new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
    }

    @Bean
    @ConditionalOnClass(name = "redis.clients.jedis.Jedis")
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(jedisConnectionFactory());
        return template;
    }
}

@Service
@ConditionalOnProperty(name = "features.advanced-analytics", havingValue = "true")
public class AdvancedAnalyticsService {
    // Only available when feature flag is enabled
}

// application-dev.yml
// spring:
//   profiles:
//     active: development
//   datasource:
//     url: jdbc:postgresql://localhost:5432/devdb
//     username: dev_user
//     password: dev_password

// application-prod.yml  
// spring:
//   profiles:
//     active: production
//   datasource:
//     url: ${DATABASE_URL}
//     username: ${DATABASE_USERNAME}
//     password: ${DATABASE_PASSWORD}

Bad Example:

@Configuration
public class AppConfig {

    @Value("${spring.profiles.active:}")
    private String activeProfile;

    @Bean
    public DataSource dataSource() {
        if ("development".equals(activeProfile)) {
            // Manual profile checking instead of @Profile
            return createDevDataSource();
        } else if ("production".equals(activeProfile)) {
            return createProdDataSource();
        }
        return createDefaultDataSource();
    }

    @Bean
    public FeatureService featureService() {
        // No conditional logic - always creates bean
        return new ExpensiveFeatureService();
    }
}

@Service
public class NotificationService {

    @Value("${app.env}")
    private String environment;

    public void sendNotification(String message) {
        if ("prod".equals(environment)) {
            // Environment logic scattered in business code
            sendRealNotification(message);
        } else {
            // Development behavior mixed with production code
            System.out.println("DEV: " + message);
        }
    }
}

Rule 6: Constructor Dependency Injection Best Practices

Title: Favor Constructor Injection for Immutable and Testable Components Description: Use constructor injection as the primary dependency injection mechanism. It promotes immutability, makes dependencies explicit, enables easier testing, and prevents circular dependencies.

Good example:

@Service
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;
    private final AuditService auditService;

    // Single constructor - @Autowired is optional since Spring 4.3
    public UserService(UserRepository userRepository, 
                      EmailService emailService,
                      AuditService auditService) {
        this.userRepository = Objects.requireNonNull(userRepository, "userRepository cannot be null");
        this.emailService = Objects.requireNonNull(emailService, "emailService cannot be null");
        this.auditService = Objects.requireNonNull(auditService, "auditService cannot be null");
    }

    public User createUser(CreateUserRequest request) {
        User user = new User(request.getEmail(), request.getName());
        User savedUser = userRepository.save(user);
        emailService.sendWelcomeEmail(savedUser);
        auditService.logUserCreation(savedUser);
        return savedUser;
    }
}

@Configuration
public class ServiceConfig {

    // Constructor injection for configuration classes
    private final DatabaseProperties databaseProperties;

    public ServiceConfig(DatabaseProperties databaseProperties) {
        this.databaseProperties = databaseProperties;
    }

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

// Optional dependencies using constructor with default values
@Service
public class NotificationService {

    private final EmailService emailService;
    private final SmsService smsService;

    // Primary constructor for all dependencies
    public NotificationService(EmailService emailService, SmsService smsService) {
        this.emailService = emailService;
        this.smsService = smsService;
    }

    // Secondary constructor for partial dependencies
    public NotificationService(EmailService emailService) {
        this(emailService, new NoOpSmsService());
    }
}

Bad Example:

@Service
public class UserService {

    @Autowired // Field injection - harder to test and debug
    private UserRepository userRepository;

    @Autowired
    private EmailService emailService;

    @Autowired
    private AuditService auditService;

    // No constructor - dependencies can be null
    // Cannot create immutable fields
    // Harder to unit test
}

@Service
public class OrderService {

    private UserService userService;
    private PaymentService paymentService;

    @Autowired // Setter injection - allows partial initialization
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @Autowired
    public void setPaymentService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    // Service can be in invalid state if setters not called
    public void processOrder(Order order) {
        // NullPointerException risk if dependencies not injected
        userService.validateUser(order.getUserId());
        paymentService.processPayment(order.getPayment());
    }
}

@Configuration
public class BadConfig {

    @Autowired // Field injection in configuration
    private Environment environment;

    @Bean
    public ApiClient apiClient() {
        // Configuration depends on field injection
        return new ApiClient(environment.getProperty("api.url"));
    }
}

Rule 7: Bean Minimization and Composition

Title: Minimize Bean Count Through Composition and Logical Grouping Description: Reduce the number of Spring beans by composing related functionality, using factory methods, and avoiding over-decomposition. Prefer composition over excessive bean granularity.

Good example:

// Compose related services into cohesive units
@Service
public class UserManagementService {

    private final UserRepository userRepository;
    private final UserValidator userValidator;
    private final UserNotificationService notificationService;

    public UserManagementService(UserRepository userRepository) {
        this.userRepository = userRepository;
        this.userValidator = new UserValidator(); // Simple composition
        this.notificationService = new UserNotificationService(new EmailClient(), new SmsClient());
    }

    public User createUser(CreateUserRequest request) {
        userValidator.validate(request);
        User user = userRepository.save(new User(request));
        notificationService.sendWelcomeNotification(user);
        return user;
    }
}

// Use factory methods for related beans
@Configuration
public class CommunicationConfig {

    @Bean
    public CommunicationService communicationService(
            @Value("${app.email.enabled:true}") boolean emailEnabled,
            @Value("${app.sms.enabled:false}") boolean smsEnabled) {

        List<NotificationChannel> channels = new ArrayList<>();

        if (emailEnabled) {
            channels.add(createEmailChannel());
        }
        if (smsEnabled) {
            channels.add(createSmsChannel());
        }

        return new CommunicationService(channels);
    }

    // Private factory methods instead of separate beans
    private EmailChannel createEmailChannel() {
        return new EmailChannel(new SmtpClient());
    }

    private SmsChannel createSmsChannel() {
        return new SmsChannel(new TwilioClient());
    }
}

// Compose utilities and helpers as inner classes or packages
@Service
public class ReportService {

    private final ReportRepository reportRepository;
    private final ReportFormatter formatter;
    private final ReportExporter exporter;

    public ReportService(ReportRepository reportRepository) {
        this.reportRepository = reportRepository;
        this.formatter = new ReportFormatter();
        this.exporter = new ReportExporter();
    }

    // Inner class for related functionality
    private static class ReportFormatter {
        public String formatAsJson(Report report) { return "..."; }
        public String formatAsXml(Report report) { return "..."; }
    }

    private static class ReportExporter {
        public void exportToPdf(String content) { /* implementation */ }
        public void exportToExcel(String content) { /* implementation */ }
    }
}

// Use configuration properties instead of multiple property beans
@ConfigurationProperties(prefix = "app")
public class ApplicationProperties {

    private final Database database = new Database();
    private final Security security = new Security();
    private final Cache cache = new Cache();

    // Nested static classes for logical grouping
    public static class Database {
        private String url;
        private String username;
        private int maxConnections = 10;
        // getters and setters
    }

    public static class Security {
        private boolean enabled = true;
        private String algorithm = "SHA-256";
        // getters and setters
    }

    public static class Cache {
        private boolean enabled = false;
        private Duration ttl = Duration.ofMinutes(30);
        // getters and setters
    }
}

Bad Example:

// Over-decomposition - too many beans for simple functionality
@Component
public class EmailValidator {
    public boolean isValid(String email) { return email.contains("@"); }
}

@Component
public class PasswordValidator {
    public boolean isValid(String password) { return password.length() >= 8; }
}

@Component
public class PhoneValidator {
    public boolean isValid(String phone) { return phone.matches("\\d{10}"); }
}

@Component
public class UserValidator {
    @Autowired private EmailValidator emailValidator;
    @Autowired private PasswordValidator passwordValidator;
    @Autowired private PhoneValidator phoneValidator;

    // Three beans for simple validation logic
}

// Separate beans for configuration values
@Component
public class DatabaseUrlProvider {
    @Value("${database.url}")
    private String url;
    public String getUrl() { return url; }
}

@Component
public class DatabaseUsernameProvider {
    @Value("${database.username}")
    private String username;
    public String getUsername() { return username; }
}

@Component
public class DatabasePasswordProvider {
    @Value("${database.password}")
    private String password;
    public String getPassword() { return password; }
}

// Multiple similar beans instead of composition
@Component
public class EmailSender {
    public void send(String to, String message) { /* implementation */ }
}

@Component
public class SmsSender {
    public void send(String phone, String message) { /* implementation */ }
}

@Component
public class PushNotificationSender {
    public void send(String deviceId, String message) { /* implementation */ }
}

@Service
public class NotificationService {
    @Autowired private EmailSender emailSender;
    @Autowired private SmsSender smsSender;
    @Autowired private PushNotificationSender pushSender;

    // Managing multiple beans instead of composed solution
}

// Utility classes as beans
@Component
public class StringUtils {
    public boolean isEmpty(String str) { return str == null || str.trim().isEmpty(); }
}

@Component
public class DateUtils {
    public String format(LocalDate date) { return date.toString(); }
}

Rule 8: Scheduled Tasks and Background Processing

Title: Implement Robust Scheduled Tasks with Proper Configuration and Error Handling Description: Use Spring's scheduling capabilities effectively with appropriate configuration, error handling, and monitoring. Ensure scheduled tasks are resilient, maintainable, and don't impact application performance.

Good example:

@Configuration
@EnableScheduling
@EnableAsync
public class SchedulingConfig {

    @Bean
    @Primary
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setErrorHandler(new CustomErrorHandler());
        return scheduler;
    }

    @Bean
    public TaskExecutor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-task-");
        executor.setWaitForTasksToCompleteOnShutdown(true);
        return executor;
    }
}

@Component
public class DataMaintenanceScheduler {

    private static final Logger logger = LoggerFactory.getLogger(DataMaintenanceScheduler.class);

    private final UserRepository userRepository;
    private final AuditLogRepository auditLogRepository;
    private final MeterRegistry meterRegistry;

    public DataMaintenanceScheduler(UserRepository userRepository,
                                  AuditLogRepository auditLogRepository,
                                  MeterRegistry meterRegistry) {
        this.userRepository = userRepository;
        this.auditLogRepository = auditLogRepository;
        this.meterRegistry = meterRegistry;
    }

    // Fixed rate - executes every 30 minutes regardless of previous execution time
    @Scheduled(fixedRateString = "${app.cleanup.rate:1800000}") // 30 minutes default
    public void cleanupExpiredSessions() {
        Timer.Sample sample = Timer.start(meterRegistry);
        try {
            logger.info("Starting session cleanup task");

            int deletedCount = userRepository.deleteExpiredSessions(
                LocalDateTime.now().minusHours(24)
            );

            logger.info("Cleaned up {} expired sessions", deletedCount);
            meterRegistry.counter("scheduled.cleanup.sessions", "status", "success")
                        .increment(deletedCount);

        } catch (Exception e) {
            logger.error("Failed to cleanup expired sessions", e);
            meterRegistry.counter("scheduled.cleanup.sessions", "status", "error")
                        .increment();
        } finally {
            sample.stop(Timer.builder("scheduled.cleanup.duration")
                            .tag("task", "sessions")
                            .register(meterRegistry));
        }
    }

    // Fixed delay - waits specified time after previous execution completes
    @Scheduled(fixedDelayString = "${app.audit.cleanup.delay:3600000}") // 1 hour default
    public void cleanupOldAuditLogs() {
        try {
            logger.debug("Starting audit log cleanup");

            LocalDateTime cutoffDate = LocalDateTime.now().minusDays(90);
            int deletedCount = auditLogRepository.deleteLogsOlderThan(cutoffDate);

            if (deletedCount > 0) {
                logger.info("Cleaned up {} old audit logs", deletedCount);
            }

        } catch (Exception e) {
            logger.error("Failed to cleanup old audit logs", e);
            // Don't rethrow - let other scheduled tasks continue
        }
    }

    // Cron expression - runs at 2 AM every day
    @Scheduled(cron = "${app.reports.schedule:0 0 2 * * *}")
    @Async("asyncExecutor")
    public CompletableFuture<Void> generateDailyReports() {
        return CompletableFuture.runAsync(() -> {
            try {
                logger.info("Starting daily report generation");

                // Long-running task executed asynchronously
                generateUserActivityReport();
                generateSystemHealthReport();

                logger.info("Daily reports generated successfully");

            } catch (Exception e) {
                logger.error("Failed to generate daily reports", e);
                // Could send alert or notification here
            }
        });
    }

    // Conditional scheduling based on profiles
    @Scheduled(fixedRate = 300000) // 5 minutes
    @ConditionalOnProperty(name = "app.monitoring.enabled", havingValue = "true")
    public void healthCheck() {
        logger.debug("Performing health check");
        // Implementation
    }

    private void generateUserActivityReport() {
        // Heavy computation that should run async
    }

    private void generateSystemHealthReport() {
        // Another heavy computation
    }
}

@Component
public class CustomErrorHandler implements ErrorHandler {

    private static final Logger logger = LoggerFactory.getLogger(CustomErrorHandler.class);

    @Override
    public void handleError(Throwable t) {
        logger.error("Scheduled task failed with error", t);

        // Could implement alerting, metrics, or other error handling logic
        if (t instanceof DataAccessException) {
            // Handle database-related errors
            logger.warn("Database error in scheduled task, will retry on next execution");
        } else {
            // Handle other types of errors
            logger.error("Unexpected error in scheduled task", t);
        }
    }
}

// Configuration properties for scheduling
@ConfigurationProperties(prefix = "app.scheduling")
public class SchedulingProperties {

    private boolean enabled = true;
    private int poolSize = 5;
    private Duration shutdownTimeout = Duration.ofSeconds(30);

    // getters and setters
}

Bad Example:

@Component
@EnableScheduling // Should be in @Configuration class
public class BadScheduler {

    @Autowired // Field injection
    private UserRepository userRepository;

    // Hardcoded timing, no error handling
    @Scheduled(fixedRate = 30000) // 30 seconds - too frequent for cleanup
    public void cleanupUsers() {
        // No logging, no error handling
        userRepository.deleteInactiveUsers();

        // Blocking operation in scheduled thread
        sendEmailNotifications(); // Should be async
    }

    @Scheduled(cron = "0 0 2 * * *") // Hardcoded, not configurable
    public void heavyProcessing() {
        // Long-running synchronous operation blocks scheduler
        for (int i = 0; i < 1000000; i++) {
            performComplexCalculation();
            // No progress tracking, no way to monitor or stop
        }

        // No error handling - exception will break scheduling
        riskyOperation();
    }

    @Scheduled(fixedDelay = 1000) // Too frequent, will impact performance
    public void constantPolling() {
        // Polling database every second
        checkForNewMessages(); // Should use messaging or webhooks instead
    }

    // Multiple methods with same timing - inefficient
    @Scheduled(fixedRate = 60000)
    public void task1() { /* implementation */ }

    @Scheduled(fixedRate = 60000)
    public void task2() { /* implementation */ }

    @Scheduled(fixedRate = 60000)  
    public void task3() { /* implementation */ }

    private void sendEmailNotifications() {
        // Synchronous email sending blocks the scheduler
        for (User user : getAllUsers()) {
            emailService.sendEmail(user.getEmail(), "notification");
            // No timeout, no retry logic, no error handling
        }
    }

    private void riskyOperation() {
        // Operation that might throw uncaught exception
        throw new RuntimeException("This will break all scheduling");
    }
}

// No thread pool configuration
@Configuration
public class BadSchedulingConfig {
    // Using default single-threaded scheduler
    // No error handling configuration
    // No monitoring or metrics
}

@Service
public class BlockingScheduledService {

    @Scheduled(fixedRate = 5000)
    public void blockingTask() {
        try {
            // Blocking I/O operation
            Thread.sleep(30000); // 30 second sleep blocks scheduler

            // Synchronous HTTP calls
            restTemplate.getForObject("http://slow-service/api", String.class);

        } catch (InterruptedException e) {
            // Poor exception handling
            e.printStackTrace(); // Never use printStackTrace in production
        }
    }
}

Scheduling Best Practices

Configuration Guidelines: - Always use @EnableScheduling in a @Configuration class - Configure custom TaskScheduler with appropriate thread pool size - Set up proper error handling with ErrorHandler - Use externalized configuration for timing and scheduling parameters

Error Handling: - Implement comprehensive logging for all scheduled tasks - Use try-catch blocks to prevent one task failure from affecting others - Consider implementing retry logic for transient failures - Add metrics and monitoring for scheduled task execution

Performance Considerations: - Use @Async for long-running tasks to avoid blocking the scheduler - Choose appropriate scheduling intervals based on business requirements - Monitor thread pool usage and adjust pool sizes accordingly - Avoid frequent polling - consider event-driven alternatives

Testing:

@TestConfiguration
public class TestSchedulingConfig {

    @Bean
    @Primary
    public TaskScheduler testTaskScheduler() {
        // Use synchronous scheduler for testing
        return new SyncTaskExecutor();
    }
}

@SpringBootTest
class ScheduledTaskTest {

    @Test
    @DirtiesContext
    void shouldExecuteScheduledTask() {
        // Test scheduled task logic without actual scheduling
        scheduler.cleanupExpiredSessions();
        // Verify expected behavior
    }
}

Advanced Configuration Patterns

For complex applications, consider these additional patterns:

  • @ConfigurationPropertiesBinding: Create custom property converters
  • @ConditionalOnBean/@ConditionalOnMissingBean: Fine-grained bean creation control
  • @Import: Compose configuration classes
  • @EnableAutoConfiguration(exclude = {...}): Disable specific auto-configurations
  • ApplicationContextInitializer: For programmatic context customization ```