Install in Claude Code
Copygit clone --depth 1 https://github.com/rrezartprebreza/spring-boot-skills /tmp/spring-batch && cp -r /tmp/spring-batch/skills/spring-boot-4/spring-batch ~/.claude/skills/spring-batchThen start a new Claude Code session; the skill loads automatically.
Definition
SKILL.md
# Spring Batch
Spring Boot 4.x ships **Spring Batch 6**. The API changed significantly from 5.x (and drastically
from 4.x) — most online examples are wrong. The rules that break the most agent-generated code:
1. **Do NOT add `@EnableBatchProcessing`.** Boot auto-configures the `JobRepository`,
`JobOperator`, and transaction manager. Adding `@EnableBatchProcessing` **disables** that
auto-configuration and you lose all the wired beans.
2. **Metadata is in-memory by default.** Batch 6's `JobRepository` is *resourceless* — nothing is
persisted. Restartability and the `BATCH_*` audit tables require the
`spring-boot-starter-batch-jdbc` starter (plain `spring-boot-starter-batch` = no restart after
a crash).
3. **`JobLauncher` and `JobExplorer` are consolidated into `JobOperator`** (which extends both).
Inject `JobOperator` and call `start(job, params)`.
4. **`JobBuilderFactory`/`StepBuilderFactory` are long gone**, and `chunk(500, txManager)` is the
old Batch 5 style — Batch 6 takes the size alone, with an optional `.transactionManager(...)`.
## Dependencies
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch-jdbc</artifactId> <!-- persistent BATCH_* metadata -->
</dependency>
<!-- spring-boot-starter-batch alone = resourceless in-memory repository:
fine for run-and-forget jobs, but no restart-on-failure, no audit trail -->
```
## Job & Step (Spring Batch 6 API)
```java
@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) // chunk size is the commit interval — and a TX boundary
.transactionManager(txManager) // optional in Batch 6 — but set it for JDBC-backed steps
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class)
.skipLimit(50)
.build();
}
}
```
`chunk(500)` 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.
The Batch 5 form `chunk(500, txManager)` is deprecated — size and transaction manager are now
separate builder calls.
## 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:
```java
// 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):
```java
.addString("requestId", requestId, false) // non-identifying — excluded from the instance key
```
(In Batch 6 `JobParameter` is an immutable record that carries its own name — `JobParameters` holds
a `Set<JobParameter>` — but the builder above is unchanged.)
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 — and it **only works with the JDBC job repository**; the default resourceless
repository forgets everything when the JVM exits. Don't defeat it by always passing a unique
parameter if you want resume-on-failure.
## ItemReader — sort key and thread-safety
```java
@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 reade