Step flow & listeners
|
This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. |
A job is more than a straight line of steps: it branches on outcomes, runs branches in parallel, and reports on itself through listeners. This page covers the flow API, the listener interfaces, and late binding.
Sequential flow
The simplest job runs its steps in order, stopping at the first failure:
@Bean
public Job endOfDayJob(JobRepository jobRepository, Step stageStep, Step loadStep, Step reportStep) {
return new JobBuilder("endOfDayJob", jobRepository)
.start(stageStep)
.next(loadStep)
.next(reportStep)
.build();
}
Conditional flow
.on(exitCode) matches a step’s ExitStatus exit code and .to(step) names where to go next. .from(step)
adds further transitions from a step already mentioned:
@Bean
public Job conditionalJob(JobRepository jobRepository,
Step loadStep, Step reconcileStep, Step alertStep, Step reportStep) {
return new JobBuilder("conditionalJob", jobRepository)
.start(loadStep)
.on("FAILED").to(alertStep)
.from(loadStep)
.on("COMPLETED WITH SKIPS").to(reconcileStep)
.from(loadStep)
.on("*").to(reportStep)
.end()
.build();
}
Matching is on the exit code string, and two wildcards are available: matches any sequence of characters
(including none) and ? matches exactly one. .on("") is therefore the catch-all, and ordering matters only
in that the most specific pattern wins.
Three terminators end a flow explicitly:
| Terminator | Resulting job status |
|---|---|
|
|
|
|
|
|
.start(validateStep)
.on("INVALID_INPUT").fail()
.from(validateStep)
.on("MANUAL_REVIEW").stopAndRestart(loadStep)
.from(validateStep)
.on("*").to(loadStep)
.end()
Custom exit codes come from a StepExecutionListener that returns a new ExitStatus — see
Jobs, instances & parameters.
JobExecutionDecider
When the branch depends on something other than the previous step’s outcome — the day of the week, a row
count, a feature flag — use a decider. It returns a FlowExecutionStatus, which transitions match exactly as
they match an exit code:
@Component
public class WeekendDecider implements JobExecutionDecider {
@Override
public FlowExecutionStatus decide(JobExecution jobExecution, StepExecution stepExecution) {
LocalDate runDate = jobExecution.getJobParameters().getLocalDate("run.date");
DayOfWeek day = runDate.getDayOfWeek();
boolean weekend = day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
return new FlowExecutionStatus(weekend ? "WEEKEND" : "WEEKDAY");
}
}
@Bean
public Job decidedJob(JobRepository jobRepository, WeekendDecider weekendDecider,
Step loadStep, Step fullReportStep, Step shortReportStep) {
return new JobBuilder("decidedJob", jobRepository)
.start(loadStep)
.next(weekendDecider)
.on("WEEKEND").to(shortReportStep)
.from(weekendDecider)
.on("WEEKDAY").to(fullReportStep)
.end()
.build();
}
Split flows
A split runs several flows concurrently, each on a thread from the supplied TaskExecutor. The job waits for
all of them and continues only if all succeeded:
@Bean
public Flow customerFlow(Step loadCustomersStep) {
return new FlowBuilder<SimpleFlow>("customerFlow").start(loadCustomersStep).build();
}
@Bean
public Flow productFlow(Step loadProductsStep) {
return new FlowBuilder<SimpleFlow>("productFlow").start(loadProductsStep).build();
}
@Bean
public Job parallelLoadJob(JobRepository jobRepository, Flow customerFlow, Flow productFlow,
Step aggregateStep) {
return new JobBuilder("parallelLoadJob", jobRepository)
.start(customerFlow)
.split(new SimpleAsyncTaskExecutor("split-"))
.add(productFlow)
.next(aggregateStep)
.end()
.build();
}
The branches must be genuinely independent — no shared mutable state, no contended tables. Other parallelism options, and how to choose between them, are in Scaling & parallel processing.
Externalized and reusable flows
A Flow is a bean, so a sequence used by several jobs can be defined once:
@Bean
public Flow stagingFlow(Step downloadStep, Step unzipStep, Step validateStep) {
return new FlowBuilder<SimpleFlow>("stagingFlow")
.start(downloadStep)
.next(unzipStep)
.next(validateStep)
.build();
}
Embedding it in a job is .start(stagingFlow). Wrapping it in a FlowStep instead makes the whole flow appear
as one step in the metadata, which keeps the parent job’s step list readable:
@Bean
public Step stagingStep(JobRepository jobRepository, Flow stagingFlow) {
FlowStep flowStep = new FlowStep();
flowStep.setName("stagingStep");
flowStep.setJobRepository(jobRepository);
flowStep.setFlow(stagingFlow);
return flowStep;
}
Listeners
Listeners are the observation points around every phase. Each has an interface and an annotation equivalent; the annotations are convenient on an existing bean, the interfaces when the component is dedicated.
| Interface | Annotations | Fires |
|---|---|---|
|
|
Around a whole step. |
|
|
Around each chunk, inside/outside the transaction boundary. |
|
|
Around each |
|
|
Around each |
|
|
Around each |
|
|
When an item is skipped — see Fault tolerance: skip & retry. |
|
— |
Around a retried operation. |
|
|
Around the whole job — see Configuring a job. |
@Component
public class StepAuditListener {
private static final Logger log = LoggerFactory.getLogger(StepAuditListener.class);
@BeforeStep
public void before(StepExecution stepExecution) {
log.info("step {} starting", stepExecution.getStepName());
}
@AfterChunk
public void afterChunk(ChunkContext context) {
StepExecution stepExecution = context.getStepContext().getStepExecution();
log.debug("committed chunk {}: read={} write={}",
stepExecution.getCommitCount(),
stepExecution.getReadCount(),
stepExecution.getWriteCount());
}
@AfterStep
public ExitStatus after(StepExecution stepExecution) {
if (stepExecution.getSkipCount() > 0) {
return new ExitStatus("COMPLETED WITH SKIPS",
stepExecution.getSkipCount() + " items were skipped");
}
return stepExecution.getExitStatus();
}
}
@Bean
public Step loadStep(JobRepository jobRepository, PlatformTransactionManager tx,
ItemReader<Trade> reader, ItemWriter<Trade> writer,
StepAuditListener stepAuditListener) {
return new StepBuilder("loadStep", jobRepository)
.<Trade, Trade>chunk(500, tx)
.reader(reader)
.writer(writer)
.listener((Object) stepAuditListener)
.build();
}
Keep item-level listeners cheap: @AfterRead runs once per item, so logging there at INFO level will dwarf the
job’s real work.
Late binding with @StepScope and @JobScope
Job parameters are not known when the application context is built, so a bean that needs one must be created
when the step runs. That is what @StepScope (and @JobScope, one level up) provides: a proxy is injected,
and the real bean is instantiated per step execution, at which point the parameter is available through SpEL.
@Bean
@StepScope
public FlatFileItemReader<TradeCsv> tradeReader(
@Value("#{jobParameters['input.file']}") Resource inputFile) {
return new FlatFileItemReaderBuilder<TradeCsv>()
.name("tradeReader")
.resource(inputFile)
.delimited()
.names("id", "isin", "quantity", "price")
.targetType(TradeCsv.class)
.build();
}
@Bean
@StepScope
public TradeItemProcessor tradeProcessor(
@Value("#{jobParameters['run.date']}") LocalDate runDate,
@Value("#{jobParameters['dry.run'] ?: false}") boolean dryRun) {
return new TradeItemProcessor(runDate, dryRun);
}
Four SpEL roots are available in a step-scoped bean:
| Expression root | Contains |
|---|---|
|
The launch parameters, typed. |
|
The job-scoped |
|
The step-scoped |
|
The execution objects themselves, for anything else. |
Partitioned steps rely on this to give each worker its slice:
@Bean
@StepScope
public JdbcPagingItemReader<Trade> partitionedTradeReader(
DataSource dataSource,
@Value("#{stepExecutionContext['minId']}") Long minId,
@Value("#{stepExecutionContext['maxId']}") Long maxId) {
return new JdbcPagingItemReaderBuilder<Trade>()
.name("partitionedTradeReader")
.dataSource(dataSource)
.selectClause("SELECT id, isin, quantity, price")
.fromClause("FROM trade")
.whereClause("WHERE id BETWEEN :minId AND :maxId")
.parameterValues(Map.of("minId", minId, "maxId", maxId))
.sortKeys(Map.of("id", Order.ASCENDING))
.pageSize(1000)
.rowMapper(new TradeRowMapper())
.build();
}
The full partitioning picture is in Scaling & parallel processing.
Two practical notes on these expressions:
-
they belong in
@Value(or XML) inside code, where the braces are literal; -
a step-scoped bean must be referenced through its proxy. Injecting a
@StepScopebean into a singleton by type works because the proxy is a singleton, but calling its methods outside a running step throws.