diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java
index 0670560c94..69ffe907e9 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistryBeanPostProcessor.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2006-2023 the original author or authors.
+ * Copyright 2006-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -40,6 +40,10 @@
* {@link JobRegistry}. Include a bean of this type along with your job configuration and
* use the same {@link JobRegistry} as a {@link JobLocator} when you need to locate a
* {@link Job} to launch.
+ *
+ * An alternative to this class is {@link JobRegistrySmartInitializingSingleton}, which is
+ * recommended in cases where this class may cause early bean initializations. You must
+ * include at most one of either of them as a bean.
*
* @author Dave Syer
* @author Mahmoud Ben Hassine
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java
new file mode 100644
index 0000000000..9e4bbb3a4f
--- /dev/null
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingleton.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.batch.core.configuration.support;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.configuration.DuplicateJobException;
+import org.springframework.batch.core.configuration.JobLocator;
+import org.springframework.batch.core.configuration.JobRegistry;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.FatalBeanException;
+import org.springframework.beans.factory.BeanFactory;
+import org.springframework.beans.factory.BeanFactoryAware;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.ListableBeanFactory;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link SmartInitializingSingleton} that registers {@link Job} beans with a
+ * {@link JobRegistry}. Include a bean of this type along with your job configuration and
+ * use the same {@link JobRegistry} as a {@link JobLocator} when you need to locate a
+ * {@link Job} to launch.
+ *
+ * This class is an alternative to {@link JobRegistryBeanPostProcessor} and prevents early
+ * bean initializations. You must include at most one of either of them as a bean.
+ *
+ * @author Henning Pöttker
+ * @since 5.1.1
+ */
+public class JobRegistrySmartInitializingSingleton
+ implements SmartInitializingSingleton, BeanFactoryAware, InitializingBean, DisposableBean {
+
+ private static final Log logger = LogFactory.getLog(JobRegistrySmartInitializingSingleton.class);
+
+ // It doesn't make sense for this to have a default value...
+ private JobRegistry jobRegistry = null;
+
+ private final Collection jobNames = new HashSet<>();
+
+ private String groupName = null;
+
+ private ListableBeanFactory beanFactory;
+
+ /**
+ * Default constructor.
+ */
+ public JobRegistrySmartInitializingSingleton() {
+ }
+
+ /**
+ * Convenience constructor for setting the {@link JobRegistry}.
+ * @param jobRegistry the {@link JobRegistry} to register the {@link Job}s with
+ */
+ public JobRegistrySmartInitializingSingleton(JobRegistry jobRegistry) {
+ this.jobRegistry = jobRegistry;
+ }
+
+ /**
+ * The group name for jobs registered by this component. Optional (defaults to null,
+ * which means that jobs are registered with their bean names). Useful where there is
+ * a hierarchy of application contexts all contributing to the same
+ * {@link JobRegistry}: child contexts can then define an instance with a unique group
+ * name to avoid clashes between job names.
+ * @param groupName the groupName to set
+ */
+ public void setGroupName(String groupName) {
+ this.groupName = groupName;
+ }
+
+ /**
+ * Injection setter for {@link JobRegistry}.
+ * @param jobRegistry the {@link JobRegistry} to register the {@link Job}s with
+ */
+ public void setJobRegistry(JobRegistry jobRegistry) {
+ this.jobRegistry = jobRegistry;
+ }
+
+ @Override
+ public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
+ if (beanFactory instanceof ListableBeanFactory listableBeanFactory) {
+ this.beanFactory = listableBeanFactory;
+ }
+ }
+
+ /**
+ * Make sure the registry is set before use.
+ */
+ @Override
+ public void afterPropertiesSet() throws Exception {
+ Assert.state(jobRegistry != null, "JobRegistry must not be null");
+ }
+
+ /**
+ * Unregister all the {@link Job} instances that were registered by this post
+ * processor.
+ */
+ @Override
+ public void destroy() throws Exception {
+ for (String name : jobNames) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("Unregistering job: " + name);
+ }
+ jobRegistry.unregister(name);
+ }
+ jobNames.clear();
+ }
+
+ @Override
+ public void afterSingletonsInstantiated() {
+ if (beanFactory == null) {
+ return;
+ }
+ Map jobs = beanFactory.getBeansOfType(Job.class, false, false);
+ for (var entry : jobs.entrySet()) {
+ postProcessAfterInitialization(entry.getValue(), entry.getKey());
+ }
+ }
+
+ private void postProcessAfterInitialization(Job job, String beanName) {
+ try {
+ String groupName = this.groupName;
+ if (beanFactory instanceof DefaultListableBeanFactory defaultListableBeanFactory
+ && beanFactory.containsBean(beanName)) {
+ groupName = getGroupName(defaultListableBeanFactory.getBeanDefinition(beanName), job);
+ }
+ job = groupName == null ? job : new GroupAwareJob(groupName, job);
+ ReferenceJobFactory jobFactory = new ReferenceJobFactory(job);
+ String name = jobFactory.getJobName();
+ if (logger.isDebugEnabled()) {
+ logger.debug("Registering job: " + name);
+ }
+ jobRegistry.register(jobFactory);
+ jobNames.add(name);
+ }
+ catch (DuplicateJobException e) {
+ throw new FatalBeanException("Cannot register job configuration", e);
+ }
+ }
+
+ /**
+ * Determine a group name for the job to be registered. The default implementation
+ * returns the {@link #setGroupName(String) groupName} configured. Provides an
+ * extension point for specialised subclasses.
+ * @param beanDefinition the bean definition for the job
+ * @param job the job
+ * @return a group name for the job (or null if not needed)
+ */
+ protected String getGroupName(BeanDefinition beanDefinition, Job job) {
+ return groupName;
+ }
+
+}
diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java
new file mode 100644
index 0000000000..7738ee2d4f
--- /dev/null
+++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/support/JobRegistrySmartInitializingSingletonTests.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.batch.core.configuration.support;
+
+import java.util.Collection;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.configuration.DuplicateJobException;
+import org.springframework.batch.core.configuration.JobRegistry;
+import org.springframework.batch.core.job.JobSupport;
+import org.springframework.beans.FatalBeanException;
+import org.springframework.beans.factory.ListableBeanFactory;
+import org.springframework.context.support.ClassPathXmlApplicationContext;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+
+/**
+ * @author Henning Pöttker
+ */
+class JobRegistrySmartInitializingSingletonTests {
+
+ private final JobRegistry jobRegistry = new MapJobRegistry();
+
+ private final JobRegistrySmartInitializingSingleton singleton = new JobRegistrySmartInitializingSingleton(
+ jobRegistry);
+
+ private final ListableBeanFactory beanFactory = mock(ListableBeanFactory.class);
+
+ @BeforeEach
+ void setUp() {
+ var job = new JobSupport();
+ job.setName("foo");
+ lenient().when(beanFactory.getBeansOfType(Job.class, false, false)).thenReturn(Map.of("bar", job));
+ singleton.setBeanFactory(beanFactory);
+ }
+
+ @Test
+ void testInitializationFails() {
+ singleton.setJobRegistry(null);
+ var exception = assertThrows(IllegalStateException.class, singleton::afterPropertiesSet);
+ assertTrue(exception.getMessage().contains("JobRegistry"));
+ }
+
+ @Test
+ void testAfterSingletonsInstantiated() {
+ singleton.afterSingletonsInstantiated();
+ assertEquals("[foo]", jobRegistry.getJobNames().toString());
+ }
+
+ @Test
+ void testAfterSingletonsInstantiatedWithGroupName() {
+ singleton.setGroupName("jobs");
+ singleton.afterSingletonsInstantiated();
+ assertEquals("[jobs.foo]", jobRegistry.getJobNames().toString());
+ }
+
+ @Test
+ void testAfterSingletonsInstantiatedWithDuplicate() {
+ singleton.afterSingletonsInstantiated();
+ var exception = assertThrows(FatalBeanException.class, singleton::afterSingletonsInstantiated);
+ assertTrue(exception.getCause() instanceof DuplicateJobException);
+ }
+
+ @Test
+ void testUnregisterOnDestroy() throws Exception {
+ singleton.afterSingletonsInstantiated();
+ singleton.destroy();
+ assertEquals("[]", jobRegistry.getJobNames().toString());
+ }
+
+ @Test
+ void testExecutionWithApplicationContext() throws Exception {
+ var context = new ClassPathXmlApplicationContext("test-context-with-smart-initializing-singleton.xml",
+ getClass());
+ var registry = context.getBean("registry", JobRegistry.class);
+ Collection jobNames = registry.getJobNames();
+ String[] names = context.getBeanNamesForType(JobSupport.class);
+ int count = names.length;
+ // Each concrete bean of type JobConfiguration is registered...
+ assertEquals(count, jobNames.size());
+ // N.B. there is a failure / wonky mode where a parent bean is given an
+ // explicit name or beanName (using property setter): in this case then
+ // child beans will have the same name and will be re-registered (and
+ // override, if the registry supports that).
+ assertNotNull(registry.getJob("test-job"));
+ assertEquals(context.getBean("test-job-with-name"), registry.getJob("foo"));
+ assertEquals(context.getBean("test-job-with-bean-name"), registry.getJob("bar"));
+ assertEquals(context.getBean("test-job-with-parent-and-name"), registry.getJob("spam"));
+ assertEquals(context.getBean("test-job-with-parent-and-bean-name"), registry.getJob("bucket"));
+ assertEquals(context.getBean("test-job-with-concrete-parent"), registry.getJob("maps"));
+ assertEquals(context.getBean("test-job-with-concrete-parent-and-name"), registry.getJob("oof"));
+ assertEquals(context.getBean("test-job-with-concrete-parent-and-bean-name"), registry.getJob("rab"));
+ }
+
+}
diff --git a/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml b/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml
new file mode 100644
index 0000000000..64ae6eed68
--- /dev/null
+++ b/spring-batch-core/src/test/resources/org/springframework/batch/core/configuration/support/test-context-with-smart-initializing-singleton.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+