Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/rrezartprebreza-spring-batch/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 52% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 109% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 327% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 70% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 89% | 0% |
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:
@EnableBatchProcessing. Boot auto-configures the JobRepository,JobOperator, and transaction manager. Adding @EnableBatchProcessing disables that auto-configuration and you lose all the wired beans.
JobRepository is resourceless — nothing ispersisted. Restartability and the BATCH_* audit tables require the spring-boot-starter-batch-jdbc starter (plain spring-boot-starter-batch = no restart after a crash).
JobLauncher and JobExplorer are consolidated into JobOperator (which extends both).Inject JobOperator and call start(job, params).
JobBuilderFactory/StepBuilderFactory are long gone, and chunk(500, txManager) is theold Batch 5 style — Batch 6 takes the size alone, with an optional .transactionManager(...).
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 -->
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.
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 completeThis 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.
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(); }
ORDER BY on a unique column. Without it the DB returnsrows 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 aresafe for multi-threaded steps. For a non-thread-safe reader in a multi-threaded step, wrap it in SynchronizedItemStreamReader.
status fromPENDING 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.
java@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.
Since Batch 5 the writer receives a Chunk<? extends T>, not List<? extends T>:
java@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():
java@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).
Boot runs every Job bean on startup by default. For scheduled or on-demand jobs, turn that off and launch explicitly:
yamlspring: batch: job: enabled: false # don't run jobs on app startup; we trigger them ourselves jdbc: initialize-schema: never # (batch-jdbc starter) manage BATCH_* tables with Flyway in prod
java@Component @RequiredArgsConstructor public class OrderExportScheduler { private final JobOperator jobOperator; // Batch 6: replaces JobLauncher AND JobExplorer 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(); jobOperator.start(orderExportJob, params); } }
Use start(Job, JobParameters) — the old start(String jobName, Properties) overload is deprecated for removal. The default JobOperator is synchronous — start(...) blocks the @Scheduled thread until the whole job finishes. For fire-and-forget, configure it with an async TaskExecutor (or annotate a @Bean method with @BatchTaskExecutor), or trigger from a request thread only if you accept the block.
With the JDBC repository, 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). Upgrading an existing Boot 3 database? Batch 6 renamed the BATCH_JOB_SEQ sequence to BATCH_JOB_INSTANCE_SEQ — the project ships migration scripts; add one to your Flyway history.
java@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(); }
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.
@EnableBatchProcessing — on Boot it disables auto-config; remove it, just inject JobRepositoryspring-boot-starter-batch and expects restart/audit — Batch 6's default repository is resourceless (in-memory); use spring-boot-starter-batch-jdbc for the BATCH_* tablesJobBuilderFactory / StepBuilderFactory — removed in Batch 5; use new JobBuilder(name, repo) / new StepBuilder(name, repo).chunk(500, txManager) — Batch 5 style, deprecated in 6; use .chunk(500) + .transactionManager(txManager)JobLauncher or JobExplorer — consolidated into JobOperator in Batch 6; inject JobOperator and call start(job, params)jobOperator.start("jobName", properties) — deprecated for removal; use start(Job, JobParameters)@EnableBatchProcessing(dataSourceRef = ...) — split in Batch 6: @EnableBatchProcessing(taskExecutorRef = ...) + @EnableJdbcJobRepository(dataSourceRef = ...)BATCH_* — Batch 6 renamed BATCH_JOB_SEQ to BATCH_JOB_INSTANCE_SEQ; add the migration scriptwrite(List<? extends T> items) — the signature is write(Chunk<? extends T> chunk) since Batch 5ObservationRegistry bean wired to your MeterRegistryJobInstanceAlreadyCompleteException — add RunIdIncrementer or a unique identifying paramORDER BY (or a non-unique one) — pages skip/duplicate rows silently; order by a unique columnJdbcCursorItemReader in a multi-threaded step — not thread-safe; use a paging reader or SynchronizedItemStreamReadernull from a processor expecting pass-through — null filters (drops) the itemItemWriter — runs inside the chunk TX; do it in an afterJob listener@StepScope on a reader that reads jobParameters — @Value("#{jobParameters[...]}") only binds in step scopespring.batch.job.enabled=false and launch explicitlyinitialize-schema: always DDL the prod DB — use never + a Flyway migration for the BATCH_* tables| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 27,006 | 45,273 | +68% | 1 | 1 | 0% | 4,317 | 6,579 | +52% | 0 | 0 | — |
case-02 | fail→pass | 22,234 | 15,453 | -30% | 1 | 1 | 0% | 3,233 | 6,744 | +109% | 0 | 0 | — |
case-03 | pass→pass | 28,223 | 22,403 | -21% | 1 | 1 | 0% | 4,287 | 6,817 | +59% | 0 | 0 | — |
case-04 | pass→pass | 15,940 | 13,264 | -17% | 1 | 1 | 0% | 1,839 | 4,988 | +171% | 0 | 0 | — |
case-05 | fail→pass | 10,395 | 8,604 | -17% | 1 | 1 | 0% | 946 | 4,038 | +327% | 0 | 0 | — |
case-06 | pass→pass | 15,444 | 8,695 | -44% | 1 | 1 | 0% | 2,011 | 5,019 | +150% | 0 | 0 | — |
case-07 | fail→pass | 18,876 | 8,883 | -53% | 1 | 1 | 0% | 2,458 | 4,167 | +70% | 0 | 0 | — |
case-08 | pass→pass | 13,109 | 6,239 | -52% | 1 | 1 | 0% | 2,265 | 4,634 | +105% | 0 | 0 | — |
case-09 | fail→pass | 21,025 | 5,684 | -73% | 1 | 1 | 0% | 2,332 | 4,401 | +89% | 0 | 0 | — |
case-10 | pass→pass | 13,785 | 19,880 | +44% | 1 | 1 | 0% | 2,225 | 5,313 | +139% | 0 | 0 | — |
case-11 | pass→pass | 8,035 | 9,605 | +20% | 1 | 1 | 0% | 1,333 | 4,203 | +215% | 0 | 0 | — |
case-12 | pass→pass | 16,411 | 12,882 | -22% | 1 | 1 | 0% | 1,688 | 4,792 | +184% | 0 | 0 | — |
case-13 | pass→pass | 20,339 | 11,832 | -42% | 1 | 1 | 0% | 1,361 | 4,523 | +232% | 0 | 0 | — |
case-14 | pass→pass | 17,954 | 10,800 | -40% | 1 | 1 | 0% | 2,292 | 5,468 | +139% | 0 | 0 | — |
case-15 | pass→pass | 35,325 | 19,267 | -45% | 1 | 1 | 0% | 3,724 | 6,309 | +69% | 0 | 0 | — |
case-16 | pass→pass | 11,100 | 9,416 | -15% | 1 | 1 | 0% | 1,128 | 4,171 | +270% | 0 | 0 | — |
case-17 | pass→pass | 9,659 | 8,646 | -10% | 1 | 1 | 0% | 843 | 4,005 | +375% | 0 | 0 | — |
case-18 | fail→pass | 14,056 | 16,044 | +14% | 1 | 1 | 0% | 2,263 | 5,230 | +131% | 0 | 0 | — |
case-19 | pass→pass | 9,675 | 7,922 | -18% | 1 | 1 | 0% | 743 | 3,887 | +423% | 0 | 0 | — |
case-20 | pass→pass | 21,305 | 15,970 | -25% | 1 | 1 | 0% | 2,949 | 5,896 | +100% | 0 | 0 | — |
case-21 | pass→pass | 17,193 | 9,059 | -47% | 1 | 1 | 0% | 2,028 | 4,060 | +100% | 0 | 0 | — |
case-22 | pass→pass | 10,994 | 9,149 | -17% | 1 | 1 | 0% | 1,935 | 4,994 | +158% | 0 | 0 | — |
case-23 | pass→pass | 15,313 | 14,745 | -4% | 1 | 1 | 0% | 2,153 | 4,975 | +131% | 0 | 0 | — |
case-24 | pass→pass | 14,405 | 10,104 | -30% | 1 | 1 | 0% | 2,079 | 5,019 | +141% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +25 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.