Spring Boot Skills
GitHub
Batch & Jobs

Spring Batch

Use when building batch jobs, ETL pipelines, scheduled imports/exports, or any chunk-oriented bulk processing with Spring Batch. Covers the Spring Batch 5 / Boot 3 builder API, restartability and idempotent job parameters, reader/writer thread-safety, fault tolerance, and chunk transaction boundaries.

View SKILL.md

Spring Boot 3.x ships Spring Batch 5. The API changed significantly from 4.x — most online examples are wrong. The two rules that break the most agent-generated code:

  1. Do NOT add @EnableBatchProcessing. Boot auto-configures the JobRepository, JobLauncher, and transaction manager. Adding @EnableBatchProcessing disables that auto-configuration and you lose all the wired beans.
  2. JobBuilderFactory and StepBuilderFactory are gone. Build jobs and steps with new JobBuilder(name, jobRepository) / new StepBuilder(name, jobRepository), injecting the JobRepository and PlatformTransactionManager Boot already created.

Job & Step (Spring Batch 5 API)

@Configuration
@RequiredArgsConstructor
public class OrderExportJobConfig {

    @Bean
    public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
        return new JobBuilder("orderExportJob", jobRepository)
            .incrementer(new RunIdIncrementer()) // lets the same job be re-run; see "Idempotency"
            .start(exportStep)
            .build();
    }

    @Bean
    public Step exportStep(JobRepository jobRepository,
                           PlatformTransactionManager txManager, // Boot's, injected — do NOT new one up
                           ItemReader<Order> reader,
                           ItemProcessor<Order, OrderRow> processor,
                           ItemWriter<OrderRow> writer) {
        return new StepBuilder("exportStep", jobRepository)
            .<Order, OrderRow>chunk(500, txManager) // chunk size is the commit interval — and a TX boundary
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .skip(FlatFileParseException.class)
            .skipLimit(50)
            .build();
    }
}

chunk(500, txManager) means: read 500 items, process each, hand the list of 500 to the writer, commit one transaction, repeat. The chunk is the unit of restart and the unit of rollback.

Idempotency & Restartability — the #1 operational gotcha

A JobInstance is identified by its identifying JobParameters. Launch the same job with the same identifying parameters twice and you get:

JobInstanceAlreadyCompleteException: A job instance already exists and is complete

This is by design — Batch refuses to re-run completed work. Two ways to handle it:

// Option A — RunIdIncrementer on the job (above) + JobLauncherApplicationRunner bumps run.id each launch.
// Option B — add a unique identifying parameter yourself when launching:
JobParameters params = new JobParametersBuilder()
    .addString("status", "COMPLETED")              // identifying — part of the instance key
    .addLong("run.id", System.currentTimeMillis()) // identifying & unique — makes each run a new instance
    .toJobParameters();

Mark a parameter non-identifying with the false flag when it's metadata that shouldn't change the instance identity (e.g. a request id you log but don't key on):

.addString("requestId", requestId, false) // non-identifying — excluded from the instance key

A failed job, by contrast, is resumed when relaunched with the same parameters — it skips completed steps and restarts the failed step from the last committed chunk. That is the point of the metadata tables. Don't defeat it by always passing a unique parameter if you want resume-on-failure.

ItemReader — sort key and thread-safety

@Bean
@StepScope // required: late-binds jobParameters at step execution, not context startup
public JpaPagingItemReader<Order> orderReader(
        EntityManagerFactory emf,
        @Value("#{jobParameters['status']}") String status) {
    return new JpaPagingItemReaderBuilder<Order>()
        .name("orderReader")
        .entityManagerFactory(emf)
        .queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // ORDER BY is MANDATORY
        .parameterValues(Map.of("status", OrderStatus.valueOf(status)))
        .pageSize(500) // keep pageSize == chunk size
        .build();
}
  • Paging readers require a deterministic ORDER BY on a unique column. Without it the DB returns rows in arbitrary order across pages → rows get skipped or processed twice. This is silent data corruption, not an error.
  • JdbcCursorItemReader is NOT thread-safe. JdbcPagingItemReader / JpaPagingItemReader are safe for multi-threaded steps. For a non-thread-safe reader in a multi-threaded step, wrap it in SynchronizedItemStreamReader.
  • Don't mutate the column you page on inside the same job. If the writer flips status from PENDING to DONE while the reader pages WHERE status = 'PENDING' ORDER BY id, the result set shifts under you and pages are missed. Read into a stable snapshot, page by immutable id, or use a cursor reader.

ItemProcessor — returning null filters

@Component
public class OrderProcessor implements ItemProcessor<Order, OrderRow> {
    @Override
    public OrderRow process(Order order) {
        if (order.getTotal().isZero()) {
            return null; // ⚠️ null = FILTER this item; it is NOT written and NOT an error
        }
        return OrderRow.from(order);
    }
}

Returning null silently drops the item from the chunk. That's a feature (filtering) but a footgun if you returned null by accident expecting it to pass through.

ItemWriter — Spring Batch 5 signature change

In 5.x the writer receives a Chunk<? extends T>, not List<? extends T>:

@Override
public void write(Chunk<? extends OrderRow> chunk) { // was List<? extends T> in 4.x
    repository.saveAll(chunk.getItems());
}

For SQL writes, prefer the batched JDBC writer over per-row saves — it uses one addBatch():

@Bean
public JdbcBatchItemWriter<OrderRow> orderWriter(DataSource dataSource) {
    return new JdbcBatchItemWriterBuilder<OrderRow>()
        .dataSource(dataSource)
        .sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
        .beanMapped()
        .build();
}

The writer runs inside the chunk transaction. Never fire emails, publish to Kafka, or call webhooks from a writer — if the chunk rolls back you've already sent it. Bind side effects to the job completion instead (see [[transactional-patterns]] and the listener below).

Launching jobs

Boot runs every Job bean on startup by default. For scheduled or on-demand jobs, turn that off and launch explicitly:

spring:
  batch:
    job:
      enabled: false           # don't run jobs on app startup; we trigger them ourselves
    jdbc:
      initialize-schema: never # manage BATCH_* tables with Flyway in prod (see below)
@Component
@RequiredArgsConstructor
public class OrderExportScheduler {

    private final JobLauncher jobLauncher;
    private final Job orderExportJob;

    @Scheduled(cron = "0 0 2 * * *")
    public void runNightly() throws JobExecutionException {
        JobParameters params = new JobParametersBuilder()
            .addString("status", "COMPLETED")
            .addLong("run.id", System.currentTimeMillis())
            .toJobParameters();
        jobLauncher.run(orderExportJob, params);
    }
}

The default JobLauncher is synchronous (SyncTaskExecutor) — jobLauncher.run(...) blocks the @Scheduled thread until the whole job finishes. For fire-and-forget, configure the launcher with an async TaskExecutor, or trigger from a request thread only if you accept the block.

Metadata schema in production

Spring Batch needs its BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, BATCH_STEP_EXECUTION, … tables. initialize-schema: always is fine for dev/embedded DBs but don't let Batch DDL your production database on startup. Set initialize-schema: never and ship the schema as a versioned [[flyway-migrations]] migration (the canonical DDL lives in org/springframework/batch/core/schema-*.sql inside spring-batch-core).

Listeners — work that must run after the job, not per chunk

@Bean
public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
    return new JobBuilder("orderExportJob", jobRepository)
        .incrementer(new RunIdIncrementer())
        .listener(new JobExecutionListener() {
            @Override public void afterJob(JobExecution exec) {
                if (exec.getStatus() == BatchStatus.COMPLETED) {
                    notifier.notifyExportReady(exec.getJobParameters()); // safe: all chunks committed
                }
            }
        })
        .start(exportStep)
        .build();
}

Don't reach for Batch when you don't need it

Spring Batch earns its complexity (metadata tables, restart, chunking) on large, restartable, auditable bulk jobs. For a quick one-off async task, @Async or a @Scheduled loop is lighter. For durable background jobs with retry, a job queue is a better fit. Match the tool to the scale.

Gotchas

  • Agent adds @EnableBatchProcessing — on Boot 3 it disables auto-config; remove it, just inject JobRepository
  • Agent uses JobBuilderFactory / StepBuilderFactory — removed in Batch 5; use new JobBuilder(name, repo) / new StepBuilder(name, repo)
  • Agent calls .chunk(500) without a transaction manager — Batch 5 requires .chunk(500, txManager)
  • Agent writes write(List<? extends T> items) — Batch 5 signature is write(Chunk<? extends T> chunk)
  • Agent re-runs a job with identical parameters and hits JobInstanceAlreadyCompleteException — add RunIdIncrementer or a unique identifying param
  • Agent adds a unique param every run on a job that should resume-on-failure — kills restartability; only add it when you want a fresh instance
  • Agent writes a paging reader query with no ORDER BY (or a non-unique one) — pages skip/duplicate rows silently; order by a unique column
  • Agent uses JdbcCursorItemReader in a multi-threaded step — not thread-safe; use a paging reader or SynchronizedItemStreamReader
  • Agent pages on a column the writer mutates in the same job — result set shifts; page on an immutable id
  • Agent returns null from a processor expecting pass-through — null filters (drops) the item
  • Agent sends email / publishes events from the ItemWriter — runs inside the chunk TX; do it in an afterJob listener
  • Agent forgets @StepScope on a reader that reads jobParameters — @Value("#{jobParameters[...]}") only binds in step scope
  • Agent leaves jobs running on startup in a web app — set spring.batch.job.enabled=false and launch explicitly
  • Agent lets initialize-schema: always DDL the prod DB — use never + a Flyway migration for the BATCH_* tables
FROM THE REPOSITORY

Examples & templates

bad-batch-config.javaexamples
Source on GitHub
// ❌ BAD — every line here is a Spring Batch 4.x habit that breaks on Boot 3 / Batch 5,
// or a silent data-correctness bug.

@Configuration
@EnableBatchProcessing  // ❌ on Boot 3 this DISABLES auto-config — JobRepository/JobLauncher vanish
@RequiredArgsConstructor
public class OrderExportJobConfig {

    private final JobBuilderFactory jobBuilderFactory;   // ❌ removed in Batch 5 — won't compile
    private final StepBuilderFactory stepBuilderFactory; // ❌ removed in Batch 5 — won't compile

    @Bean
    public Job orderExportJob(Step exportStep) {
        return jobBuilderFactory.get("orderExportJob")   // ❌ factory API gone
            .start(exportStep)
            .build();
        // ❌ no incrementer → re-running with same params throws JobInstanceAlreadyCompleteException
    }

    @Bean
    public Step exportStep(ItemReader<Order> reader,
                           ItemWriter<OrderRow> writer) {
        return stepBuilderFactory.get("exportStep")
            .<Order, OrderRow>chunk(500)                 // ❌ Batch 5 needs chunk(500, txManager)
            .reader(reader)
            .writer(writer)
            .build();
    }

    @Bean
    public JdbcCursorItemReader<Order> reader(DataSource ds) { // ❌ cursor reader is NOT thread-safe
        JdbcCursorItemReader<Order> r = new JdbcCursorItemReader<>();
        r.setDataSource(ds);
        r.setSql("SELECT id, total FROM orders WHERE status = 'PENDING'"); // ❌ no ORDER BY — pages drift
        r.setRowMapper(new OrderRowMapper());
        return r;
        // ❌ not @StepScope, so it can't read jobParameters
    }

    @Bean
    public ItemWriter<OrderRow> writer(EmailService email, OrderExportRepository repo) {
        return items -> {                                // ❌ Batch 5 passes Chunk<>, not List<>
            repo.saveAll(items);
            email.send("export-done@co", "batch finished"); // ❌ inside chunk TX — fires even if chunk rolls back
        };
    }
}
good-batch-config.javaexamples
Source on GitHub
// ✅ GOOD — Spring Batch 5 / Boot 3: no @EnableBatchProcessing, builders take JobRepository,
// chunk takes a transaction manager, reader is @StepScope with a deterministic sort, job is restartable.

@Configuration
@RequiredArgsConstructor
public class OrderExportJobConfig {

    @Bean
    public Job orderExportJob(JobRepository jobRepository, Step exportStep) {
        return new JobBuilder("orderExportJob", jobRepository)
            .incrementer(new RunIdIncrementer())          // re-runnable; resumes a failed run on same params
            .listener(new ExportCompletionListener())     // side effects AFTER the job, never in the writer
            .start(exportStep)
            .build();
    }

    @Bean
    public Step exportStep(JobRepository jobRepository,
                           PlatformTransactionManager txManager,   // injected — Boot already created it
                           ItemReader<Order> orderReader,
                           ItemProcessor<Order, OrderRow> orderProcessor,
                           ItemWriter<OrderRow> orderWriter) {
        return new StepBuilder("exportStep", jobRepository)
            .<Order, OrderRow>chunk(500, txManager)        // commit interval == transaction boundary
            .reader(orderReader)
            .processor(orderProcessor)
            .writer(orderWriter)
            .faultTolerant()
            .skip(FlatFileParseException.class)
            .skipLimit(50)                                 // tolerate up to 50 bad rows, then fail the step
            .build();
    }

    @Bean
    @StepScope                                             // late-binds jobParameters at execution time
    public JpaPagingItemReader<Order> orderReader(
            EntityManagerFactory emf,
            @Value("#{jobParameters['status']}") String status) {
        return new JpaPagingItemReaderBuilder<Order>()
            .name("orderReader")
            .entityManagerFactory(emf)
            .queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // unique sort key
            .parameterValues(Map.of("status", OrderStatus.valueOf(status)))
            .pageSize(500)                                 // matches chunk size
            .build();
    }

    @Bean
    public ItemProcessor<Order, OrderRow> orderProcessor() {
        return order -> order.getTotal().isZero() ? null   // null = filter; documented intent
                                                  : OrderRow.from(order);
    }

    @Bean
    public JdbcBatchItemWriter<OrderRow> orderWriter(DataSource dataSource) {
        return new JdbcBatchItemWriterBuilder<OrderRow>()
            .dataSource(dataSource)
            .sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
            .beanMapped()
            .build();
    }
}
ChunkJobConfig.javatemplates
Source on GitHub
// Copy-paste starting point for a chunk-oriented Spring Batch 5 job (Spring Boot 3.x).
// Replace Order / OrderRow / the reader query and writer SQL with your domain.
// Do NOT add @EnableBatchProcessing — Boot auto-configures JobRepository + transaction manager.

@Configuration
@RequiredArgsConstructor
public class ChunkJobConfig {

    private static final int CHUNK_SIZE = 500;

    @Bean
    public Job exampleJob(JobRepository jobRepository, Step exampleStep) {
        return new JobBuilder("exampleJob", jobRepository)
            .incrementer(new RunIdIncrementer())
            .start(exampleStep)
            .build();
    }

    @Bean
    public Step exampleStep(JobRepository jobRepository,
                            PlatformTransactionManager txManager,
                            ItemReader<Order> reader,
                            ItemProcessor<Order, OrderRow> processor,
                            ItemWriter<OrderRow> writer) {
        return new StepBuilder("exampleStep", jobRepository)
            .<Order, OrderRow>chunk(CHUNK_SIZE, txManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .retry(TransientDataAccessException.class).retryLimit(3) // retry transient failures
            .skip(FlatFileParseException.class).skipLimit(50)        // tolerate bad input rows
            .build();
    }

    @Bean
    @StepScope
    public JpaPagingItemReader<Order> reader(
            EntityManagerFactory emf,
            @Value("#{jobParameters['status']}") String status) {
        return new JpaPagingItemReaderBuilder<Order>()
            .name("exampleReader")
            .entityManagerFactory(emf)
            .queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id") // unique sort key
            .parameterValues(Map.of("status", OrderStatus.valueOf(status)))
            .pageSize(CHUNK_SIZE)
            .build();
    }

    @Bean
    public ItemProcessor<Order, OrderRow> processor() {
        return OrderRow::from; // return null from a processor to FILTER an item out of the chunk
    }

    @Bean
    public JdbcBatchItemWriter<OrderRow> writer(DataSource dataSource) {
        return new JdbcBatchItemWriterBuilder<OrderRow>()
            .dataSource(dataSource)
            .sql("INSERT INTO order_export (id, total) VALUES (:id, :total)")
            .beanMapped()
            .build();
    }
}
JobScheduler.javatemplates
Source on GitHub
// Copy-paste starting point for triggering a Spring Batch job on a schedule.
// Pair with application.yml:
//   spring.batch.job.enabled: false      # don't auto-run jobs on startup
//   spring.batch.jdbc.initialize-schema: never   # manage BATCH_* tables via Flyway in prod
//
// The default JobLauncher is SYNCHRONOUS — run(...) blocks until the job finishes.
// Inject an async TaskExecutor-backed launcher if you need fire-and-forget.

@Component
@RequiredArgsConstructor
@Slf4j
public class JobScheduler {

    private final JobLauncher jobLauncher;
    private final Job exampleJob;

    @Scheduled(cron = "0 0 2 * * *") // 02:00 daily
    public void run() {
        JobParameters params = new JobParametersBuilder()
            .addString("status", "COMPLETED")               // identifying — part of the JobInstance key
            .addLong("run.id", System.currentTimeMillis())  // identifying + unique — forces a fresh instance
            // .addString("requestId", id, false)           // non-identifying — logged, not part of the key
            .toJobParameters();

        try {
            JobExecution execution = jobLauncher.run(exampleJob, params);
            log.info("Job {} finished with status {}", exampleJob.getName(), execution.getStatus());
        } catch (JobExecutionException e) {
            // JobInstanceAlreadyCompleteException, JobRestartException, etc.
            log.error("Failed to launch {}", exampleJob.getName(), e);
        }
    }
}

Verification scope

Compiled sources
No
Runtime checks
No

Coverage describes checked-in fixtures, not a guarantee for every snippet or project.

Coverage matrix & agent evidence
ADD TO YOUR AGENT

Install Boot 3 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skills