TaskExecutorConfig.java:

@Configuration
public class TaskExecutorConfig {

  @Configuration
  @ConditionalOnProperty(value = "spring.threads.virtual.enabled", havingValue = "true")
  public static class VirtualThreadTaskExecutorConfig {

    @Bean
    public AsyncTaskExecutor taskExecutor() {
      ThreadFactory factory = Thread.ofVirtual().name("virtual-thread-", 0).factory();
      // Similar to Executors.newVirtualThreadPerTaskExecutor(factory), but with thread name.
      return new TaskExecutorAdapter(Executors.newThreadPerTaskExecutor(factory));
    }

  }

  @Configuration
  @EnableAsync
  @ConditionalOnProperty(value = "spring.threads.virtual.enabled", havingValue = "false", matchIfMissing = true)
  // Uses spring.task.execution from config properties
  public static class ThreadPoolTaskExecutorConfig {

  }

}

application.yml:

spring:
  threads:
    virtual:
      enabled: <true or false>
  task:  # App's TaskExecutorConfig uses the 'ThreadPool' implementation if spring.threads.virtual.enabled is false or missing.
    execution:
      pool:
        core-size: 20
        max-size: 100
        queue-capacity: 3000
      thread-name-prefix: async-thread-

Usage

DefaultMessagingService.java:

@Service
// ...
public class DefaultMessagingService implements MessagingService {

  private final AsyncTaskExecutor taskExecutor;
  // ...

  @Override
  public void sendActivationNotification(User user) {
    this.taskExecutor.execute(() -> {
      log.info("Sending activation email in a separate thread");
      // ...
    });
  }
// ...
}