I wrote the original three-part Spring Batch blog series in 2013. The code worked back then, the posts got published, and then — like most side projects — it sat untouched on GitHub for over a decade. Java 1.5, Spring 3.1, Spring Batch 2.1, JUnit 3. It was a time capsule.
These are projects I would never have gone back and updated. The effort-to-reward ratio just isn’t there when you’re doing it manually. You’d spend hours chasing down deprecated APIs, fixing broken imports, untangling framework migration guides — all for a demo project that already works.
Fast forward to now (Mar 2026), and in less than an hour (over 2 days due to token resets), I used VSCode+Claude Code to vibe code the entire modernization.
Java 1.5 to 21. Spring 3.1 to 6.2. Spring Batch 2.1 to 5.2. JUnit 3 to 5. Mutable JavaBeans to immutable records. java.util.Date to java.time. double to BigDecimal. A hand-rolled DAO layer was replaced with a framework-provided layer JdbcBatchItemWriter. A single assertTrue(true) test expanded to 14 meaningful tests. Observability is added with MDC, Micrometer, and structured logging. Every change compiled, every test passed, and Claude caught edge cases I wouldn’t have thought to check — like test isolation failures from shared in-memory databases across Spring contexts.
The workflow was conversational: I’d ask Claude to review a category (Java language improvements, Spring Batch patterns, test coverage, observability), pick which recommendations to implement, and watch it make the changes, run the tests, and fix any issues. I never opened a migration guide or Stack Overflow tab.
This is the kind of work AI is great at — taking something that works but is outdated and methodically bringing it to current standards. Not greenfield creativity, but the unglamorous maintenance work that keeps codebases alive. For projects like this one, AI code-assist/vibe-coding tools (such as Claude Code) are the difference between “I should update that someday” and actually doing it.
It took me an hour (over 3 days) to wrap this up. Most of the time was spent waiting for my limited Claude tokens to reset and for me to eyeball the generated output (I still have trust issues with vibe coding). My only complaint, against myself, is that I need to eyeball the code even more.
Originally published as a three-part blog series in 2013, this guide has been updated for Java 21, Spring 6.2, and Spring Batch 5.2.
Spring Batch is a framework for building robust batch processing applications in Java. Whether you need to import a million-row CSV into a database, export records to a file, or run a scheduled maintenance task, Spring Batch provides the plumbing so you can focus on your business logic.
This guide walks through three progressively complex examples using a single Maven project. By the end, you’ll understand the core concepts and be ready to build your own batch jobs.
Core Concepts
Before diving into code, here are the building blocks you’ll work with in every Spring Batch application:
| Concept | What It Does |
|---|---|
| Job | The entire batch process. Think of it as a container for one or more steps. |
| Step | A single phase of work within a job. Steps execute sequentially. |
| Tasklet | A step that runs a single operation (no reader/writer). Good for simple tasks. |
| Chunk | A step that reads, optionally processes, and writes data in configurable batches. |
| ItemReader | Reads input data one item at a time (from a file, database, queue, etc.). |
| ItemProcessor | Transforms or validates each item. Returns null to filter it out. |
| ItemWriter | Writes a chunk of items to the destination (database, file, etc.). |
| JobRepository | Stores execution metadata (status, counts, timestamps) in a database. |
| JobLauncher | Kicks off a job with a set of parameters. |
The key insight: for data-intensive work, you define a Reader → Processor → Writer pipeline and Spring Batch handles chunking, transactions, restart, and error recovery.
Project Setup
The project uses Maven with these core dependencies:
1 2 3 4 5 | <properties> <java.version>21</java.version> <spring.version>6.2.3</spring.version> <spring.batch.version>5.2.2</spring.batch.version> </properties> |
Key dependencies: spring-batch-core, spring-jdbc, hsqldb (embedded database), and slf4j/logback for logging. See the full pom.xml in the repository.
All three examples share common infrastructure (transaction manager, job repository, job launcher) defined once in base-batch-context.xml and imported by each job’s XML configuration.
Example 1: Simple Tasklet — Hello World
The simplest Spring Batch job: two steps that each run a Tasklet — a single unit of work with no reader/writer pipeline.
The Tasklets
Each tasklet implements the Tasklet interface with one method: execute(). It does its work and returns RepeatStatus.FINISHED.
1 2 3 4 5 6 7 | public class HelloTask implements Tasklet { private String taskStartMessage; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { logger.info(taskStartMessage); return RepeatStatus.FINISHED; } } |
The second tasklet simply logs the current time:
1 2 3 4 | public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { logger.info("{}", LocalDateTime.now()); return RepeatStatus.FINISHED; } |
Job Configuration
The XML wires the two tasklets into a sequential two-step job:
1 2 3 4 5 6 7 8 | <batch:job id="simpleJob"> <batch:step id="step1" next="step2"> <batch:tasklet ref="helloTask" /> </batch:step> <batch:step id="step2"> <batch:tasklet ref="timeTask" /> </batch:step> </batch:job> |
step1 runs first, then step2. Each step wraps a tasklet reference. That’s it — no readers, writers, or chunk processing needed.
When to Use Tasklets
Tasklets are ideal for operations that don’t fit the read-process-write pattern: running a stored procedure, sending a notification, cleaning up temporary files, or any discrete operation within a larger batch job.
Example 2: Flat File to Database — The Reader-Processor-Writer Pipeline
This is where Spring Batch shines. We read ~200,000 ledger records from a CSV file, validate and normalize each record, and insert them into a database — all in about 2 seconds.
The Data
The input CSV (ledger.txt) looks like this:
1 2 | 02/22/09,Person1,1432,02/22/09,Offertery,$50.00,$0.00,comments 02/22/09,Person2,900,02/22/09,Offertery,$20.00,$0.00,comments |
The domain model is a Java record with BigDecimal for monetary precision:
1 2 3 4 5 6 7 8 9 10 11 | public record Ledger( int id, LocalDate receiptDate, String memberName, String checkNumber, LocalDate checkDate, String paymentType, BigDecimal depositAmount, BigDecimal paymentAmount, String comments) { } |
Reader: CSV to Java Objects
Spring Batch’s FlatFileItemReader handles the file parsing. A custom FieldSetMapper converts each CSV row into a Ledger record, parsing dollar amounts like $50.00 and date fields:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public Ledger mapFieldSet(FieldSet fs) { int idx = 0; return new Ledger( 0, fs.readDate(idx++, DATE_PATTERN).toInstant().atZone(ZoneId.systemDefault()).toLocalDate(), fs.readString(idx++), fs.readString(idx++), fs.readDate(idx++, DATE_PATTERN).toInstant().atZone(ZoneId.systemDefault()).toLocalDate(), fs.readString(idx++), parseDollarAmount(fs.readString(idx++)), parseDollarAmount(fs.readString(idx++)), null); } |
Processor: Validate and Normalize
The ItemProcessor is optional but powerful. Ours does two things: filter out records with negative amounts (by returning null) and normalize the payment type to uppercase:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public Ledger process(Ledger ledger) { if (ledger.depositAmount().compareTo(BigDecimal.ZERO) < 0 || ledger.paymentAmount().compareTo(BigDecimal.ZERO) < 0) { logger.warn("Filtering out ledger with negative amount: {}", ledger.memberName()); return null; } var normalizedType = ledger.paymentType().trim().toUpperCase(); if (normalizedType.equals(ledger.paymentType())) { return ledger; } return new Ledger( ledger.id(), ledger.receiptDate(), ledger.memberName(), ledger.checkNumber(), ledger.checkDate(), normalizedType, ledger.depositAmount(), ledger.paymentAmount(), ledger.comments()); } |
Returning null from a processor tells Spring Batch to skip that item — it won’t reach the writer.
Writer: Batch Insert
Instead of writing a custom DAO, we use Spring Batch’s built-in JdbcBatchItemWriter with named parameters that map directly to the record’s accessor methods:
1 2 3 4 5 6 | <bean id="itemWriter" class="org.springframework.batch.item.database.JdbcBatchItemWriter"> <property name="sql" value="INSERT INTO ledger (...) VALUES (:receiptDate, :memberName, ...)" /> <property name="itemSqlParameterSourceProvider"> <bean class="...BeanPropertyItemSqlParameterSourceProvider" /> </property> </bean> |
The BeanPropertyItemSqlParameterSourceProvider automatically maps :receiptDate to ledger.receiptDate(), :memberName to ledger.memberName(), and so on. No manual JDBC code needed.
Chunk Configuration with Fault Tolerance
The job definition ties reader, processor, and writer together in a chunk-oriented step. Each chunk commits 1,000 records at a time, with policies for handling errors:
1 2 3 4 5 6 7 8 | <batch:chunk reader="itemReader" processor="ledgerProcessor" writer="itemWriter" commit-interval="1000" skip-limit="10" retry-limit="3"> <batch:skippable-exception-classes> <batch:include class="...FlatFileParseException" /> </batch:skippable-exception-classes> <batch:retryable-exception-classes> <batch:include class="...DeadlockLoserDataAccessException" /> </batch:retryable-exception-classes> </batch:chunk> |
commit-interval="1000"— Read and process 1,000 items, then write them in a single transactionskip-limit="10"— Tolerate up to 10 malformed CSV rows before failing the jobretry-limit="3"— Retry database deadlocks up to 3 times before giving up
This fault tolerance is declarative — no try/catch blocks in your business code.
Example 3: Database to Flat File — Reversing the Flow
The third example reads records from the database and writes them to a CSV file. It demonstrates that the reader/writer pattern works in both directions.
Reader: JDBC Cursor
JdbcCursorItemReader opens a database cursor and returns one Ledger per call to read(). A RowMapper converts each ResultSet row:
1 2 3 4 5 6 7 8 9 10 11 12 | public Ledger mapRow(ResultSet rs, int rowNum) throws SQLException { return new Ledger( rs.getInt("id"), rs.getDate("rcv_dt").toLocalDate(), rs.getString("mbr_nm"), rs.getString("chk_nbr"), rs.getDate("chk_dt").toLocalDate(), rs.getString("pymt_typ"), rs.getBigDecimal("dpst_amt"), rs.getBigDecimal("pymt_amt"), rs.getString("comments")); } |
Writer: Flat File
FlatFileItemWriter with a DelimitedLineAggregator handles the CSV output. The BeanWrapperFieldExtractor selects which fields to include:
1 2 3 4 5 6 7 8 9 10 11 12 13 | <bean id="flatFileWriter" class="...FlatFileItemWriter"> <property name="resource" value="file:target/ledgers-output.txt" /> <property name="lineAggregator"> <bean class="...DelimitedLineAggregator"> <property name="delimiter" value="," /> <property name="fieldExtractor"> <bean class="...BeanWrapperFieldExtractor"> <property name="names" value="id,receiptDate,memberName" /> </bean> </property> </bean> </property> </bean> |
No processor is needed here — records pass directly from reader to writer.
Observability
All three jobs are instrumented with listeners that provide runtime visibility:
- MDC context — Every log line includes
[job=simpleJob step=step1]for correlation - Step metrics — Read, written, skipped, filtered, commit, and rollback counts with duration
- Micrometer metrics — Job timers and counters recorded via
SimpleMeterRegistry - Error logging — Read and write errors captured with full stack traces
Sample log output from the file-to-database job:
1 2 3 4 | 15:20:51.096 INFO [job=simpleJob step=] AppJobExecutionListener - Job starting (id=0) 15:20:51.101 INFO [job=simpleJob step=step1] StepMetricsListener - Step [step1] starting 15:20:52.780 INFO [job=simpleJob step=step1] StepMetricsListener - Step [step1] completed in 1679ms - Read: 196560, Processed: 196560, Written: 196560, Skipped: 0, Filtered: 0, Commits: 197, Rollbacks: 0 15:20:52.782 INFO [job=simpleJob step=] AppJobExecutionListener - Job completed (id=0) in 1.686s |
Testing
Each job has an integration test using @SpringBatchTest and JobLauncherTestUtils. The tests verify job completion status, item counts, and actual data:
1 2 3 4 5 6 7 8 9 10 | @SpringBatchTest @SpringJUnitConfig(locations = "classpath:com/batch/todb/contextToDB.xml") public class ToDBBatchTestCase { @Autowired private JobLauncherTestUtils jobLauncherTestUtils; @Test public void testLaunchJob() throws Exception { var execution = jobLauncherTestUtils.launchJob(); assertEquals(BatchStatus.COMPLETED, execution.getStatus()); var stepExecution = execution.getStepExecutions().iterator().next(); assertTrue(stepExecution.getReadCount() > 0); assertEquals(stepExecution.getReadCount(), stepExecution.getWriteCount()); } } |
Run all 14 tests with:
1 | mvn test |
Key Takeaways
- Tasklets are for simple, discrete operations. Chunks are for data pipelines.
- Reader → Processor → Writer is the core pattern. The processor is optional.
- Spring Batch provides production-ready readers and writers for files, databases, and more — avoid writing custom DAO layers.
- Fault tolerance (skip, retry) is declarative in the chunk configuration.
- The JobRepository tracks every execution, making jobs restartable and auditable.
@SpringBatchTestwithJobLauncherTestUtilsgives you integration testing for free.
Source Code
The complete project is available on GitHub: github.com/thomasma/springbatch3part
Technology Stack
| Component | Version |
|---|---|
| Java | 21 |
| Spring Framework | 6.2.3 |
| Spring Batch | 5.2.2 |
| Micrometer | 1.14.4 |
| JUnit | 5.11.4 |
| HSQLDB | 2.7.4 |
| SLF4J + Logback | 2.0.16 / 1.5.16 |