Spring Boot + Spring Batch Setup: Your First Complete Batch Project
Article 2 showed how chunk processing works conceptually. This article sets up the real project you’ll build on for the rest of the series — complete Maven configuration, MySQL metadata tables, multiple ways to trigger jobs, and correct use of JobParameters. Everything in this article is production-ready from the start.
Project Dependencies
Add spring-boot-starter-batch to your Maven project. That one starter brings in everything you need.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.devopsmonk</groupId>
<artifactId>batch-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Batch — includes spring-batch-core, spring-batch-infrastructure, spring-retry -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
<!-- JPA for entity-based reading and writing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Web for REST-triggered jobs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MySQL driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
spring-boot-starter-batch transitively includes:
spring-batch-core— Job, Step, JobRepository, all the core typesspring-batch-infrastructure— Readers, writers, retry, the execution enginespring-retry— The retry mechanism used internally and optionally by your jobs
Let Spring Boot manage all versions via the parent BOM. Never specify Spring Batch versions explicitly when using the starter.
What Auto-Configuration Wires Up
In Spring Boot 3, do not add @EnableBatchProcessing. Adding it disables Spring Boot’s batch auto-configuration. Just declare your job and step beans — Spring Boot handles the rest.
BatchAutoConfiguration (activated automatically when Spring Batch is on the classpath) creates:
| Bean | Type | What It Does |
|---|---|---|
jobRepository | SimpleJobRepository | Persists all job/step execution state to the database |
jobLauncher | TaskExecutorJobLauncher | Starts jobs; synchronous by default |
jobExplorer | SimpleJobExplorer | Read-only access to job history |
jobOperator | SimpleJobOperator | Stop, restart, abandon jobs by ID |
jobRegistry | MapJobRegistry | In-memory registry of all registered Job beans |
| Step scope | StepScope | Creates new bean instances per step execution (used with @StepScope) |
| Job scope | JobScope | Creates new bean instances per job execution (used with @JobScope) |
All of these are injected by type in your configuration classes. You don’t declare them — you use them:
@Configuration
public class BatchConfig {
@Bean
public Job myJob(JobRepository jobRepository, Step myStep) {
return new JobBuilder("myJob", jobRepository)
.start(myStep)
.build();
}
@Bean
public Step myStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("myStep", jobRepository)
.<String, String>chunk(100, transactionManager)
.reader(...)
.writer(...)
.build();
}
}
JobRepository and PlatformTransactionManager are injected directly from the auto-configured beans.
MySQL Configuration
Spring Batch needs a database to store its six metadata tables. For development, use initialize-schema: always — Spring Boot creates the tables on startup from the schema file bundled in the jar (org/springframework/batch/core/schema-mysql.sql).
spring:
datasource:
url: jdbc:mysql://localhost:3306/batch_db?serverTimezone=UTC&useSSL=false
username: batch_user
password: secret
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
jpa:
hibernate:
ddl-auto: update
show-sql: false
batch:
jdbc:
initialize-schema: always # Creates Spring Batch tables on startup
job:
enabled: false # Don't run jobs automatically on startup
The initialize-schema options:
| Value | When to Use |
|---|---|
always | Development and CI — creates tables on every startup |
embedded | H2/Derby embedded databases only (the default) |
never | Production — manage the schema with Liquibase or manual DDL |
For production, extract the MySQL schema from the jar and add it to your migration tool:
# Extract the MySQL schema from spring-batch-core.jar
jar xf ~/.m2/repository/org/springframework/batch/spring-batch-core/5.1.x/spring-batch-core-5.1.x.jar \
org/springframework/batch/core/schema-mysql.sql
The schema creates six tables and three sequence tables (MySQL doesn’t support native sequences, so Spring Batch uses single-row tables as sequence generators):
BATCH_JOB_INSTANCE — one row per unique job name + parameters
BATCH_JOB_EXECUTION — one row per job run attempt
BATCH_JOB_EXECUTION_PARAMS — parameters passed to each execution
BATCH_JOB_EXECUTION_CONTEXT — job-level saved state (for restart)
BATCH_STEP_EXECUTION — one row per step per job execution
BATCH_STEP_EXECUTION_CONTEXT — step-level saved state (for restart)
BATCH_JOB_INSTANCE_SEQ — ID generator for job instances
BATCH_JOB_EXECUTION_SEQ — ID generator for job executions
BATCH_STEP_EXECUTION_SEQ — ID generator for step executions
Understanding spring.batch.* Properties
| Property | Default | Purpose |
|---|---|---|
spring.batch.job.enabled | true | Set false to prevent auto-run on startup |
spring.batch.job.name | (none) | Run only this named job when multiple jobs are registered |
spring.batch.jdbc.initialize-schema | embedded | always, embedded, or never |
spring.batch.jdbc.schema | auto-detected | Override the schema SQL file path |
spring.batch.table-prefix | BATCH_ | Change if you need a custom prefix on metadata table names |
spring.batch.job.enabled=false is important. Without it, Spring Boot runs all registered Job beans automatically when the application starts. In production, jobs should be triggered explicitly — on a schedule, via REST, or via an external scheduler.
JobParameters: How Spring Batch Identifies Job Runs
JobParameters are key/value pairs passed when launching a job. They serve two purposes:
- Job instance identity: Parameters with
identifying=true(the default) contribute to theJOB_KEYhash that determines whether two runs are the sameJobInstance. - Runtime configuration: Pass dynamic values (file paths, dates, processing windows) into readers and processors.
Parameter types
JobParameters params = new JobParametersBuilder()
.addString("fileName", "orders-2026-05-01.csv") // String
.addLong("chunkSize", 500L) // Long
.addDouble("threshold", 99.5) // Double
.addLocalDate("processDate", LocalDate.of(2026, 5, 1)) // LocalDate
.addLocalDateTime("runAt", LocalDateTime.now()) // LocalDateTime
.toJobParameters();
Identifying vs. non-identifying parameters
By default, all parameters are identifying — they contribute to the JobInstance hash. Two calls with the same identifying parameters refer to the same JobInstance.
// These two parameter sets → same JobInstance (can't run twice)
new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.of(2026, 5, 1))
.toJobParameters();
// These → different JobInstances (can both run)
new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.of(2026, 5, 1))
.toJobParameters();
new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.of(2026, 5, 2))
.toJobParameters();
If you try to run a job that already has a COMPLETED JobExecution with the same identifying parameters, Spring Batch throws JobInstanceAlreadyCompleteException. This is intentional — it prevents accidental duplicate processing.
Mark a parameter as non-identifying when it shouldn’t affect identity:
new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.of(2026, 5, 1), true) // identifying
.addString("triggeredBy", "scheduler", false) // NOT identifying
.toJobParameters();
RunIdIncrementer: always-new JobInstances
RunIdIncrementer adds a monotonically increasing run.id (Long) parameter before each launch, ensuring every run is a new JobInstance. Use it for jobs that should always run fresh rather than restart a previous instance.
@Bean
public Job importOrdersJob(JobRepository jobRepository, Step step) {
return new JobBuilder("importOrdersJob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(step)
.build();
}
Each call to jobLauncher.run(importOrdersJob, params) automatically gets a new run.id, so the same job can be launched as many times as needed.
Four Ways to Run a Job
1. CommandLineRunner (startup, once)
Simple. The job runs once when the application starts. Useful for scheduled container jobs or migration tools.
@SpringBootApplication
public class BatchApplication {
public static void main(String[] args) {
SpringApplication.run(BatchApplication.class, args);
}
@Bean
CommandLineRunner runOnStartup(JobLauncher jobLauncher, Job importOrdersJob) {
return args -> {
JobParameters params = new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.now())
.toJobParameters();
JobExecution execution = jobLauncher.run(importOrdersJob, params);
System.out.println("Status: " + execution.getStatus());
execution.getStepExecutions().forEach(step ->
System.out.printf(" %s: read=%d, written=%d, filtered=%d%n",
step.getStepName(),
step.getReadCount(),
step.getWriteCount(),
step.getFilterCount())
);
};
}
}
Keep spring.batch.job.enabled=false when using this approach to avoid double-launching (the auto-runner would also fire).
2. @Scheduled (recurring, cron-based)
Run the job on a schedule. Add @EnableScheduling to a configuration class.
@Configuration
@EnableScheduling
public class BatchScheduler {
private final JobLauncher jobLauncher;
private final Job importOrdersJob;
public BatchScheduler(JobLauncher jobLauncher, Job importOrdersJob) {
this.jobLauncher = jobLauncher;
this.importOrdersJob = importOrdersJob;
}
@Scheduled(cron = "0 0 2 * * *") // Every day at 2am
public void runNightlyImport() throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDate("processDate", LocalDate.now())
.toJobParameters();
JobExecution execution = jobLauncher.run(importOrdersJob, params);
log.info("Nightly import finished with status: {}", execution.getStatus());
}
}
Common cron patterns:
| Cron | Meaning |
|---|---|
0 0 2 * * * | Daily at 2:00 AM |
0 0 * * * * | Every hour on the hour |
0 */15 * * * * | Every 15 minutes |
0 0 0 1 * * | First day of every month at midnight |
0 0 18 * * MON-FRI | Weekdays at 6 PM |
3. REST endpoint (on-demand)
Expose an endpoint to trigger jobs from external systems, dashboards, or deployment pipelines.
@RestController
@RequestMapping("/api/jobs")
public class JobController {
private final JobLauncher jobLauncher;
private final Job importOrdersJob;
public JobController(JobLauncher jobLauncher, Job importOrdersJob) {
this.jobLauncher = jobLauncher;
this.importOrdersJob = importOrdersJob;
}
@PostMapping("/import-orders")
public ResponseEntity<Map<String, Object>> launchImport(
@RequestParam LocalDate processDate) throws Exception {
JobParameters params = new JobParametersBuilder()
.addLocalDate("processDate", processDate)
.addLong("runId", System.currentTimeMillis(), false) // non-identifying
.toJobParameters();
JobExecution execution = jobLauncher.run(importOrdersJob, params);
return ResponseEntity.ok(Map.of(
"executionId", execution.getId(),
"status", execution.getStatus().toString(),
"processDate", processDate.toString()
));
}
}
Trigger it:
curl -X POST "http://localhost:8080/api/jobs/import-orders?processDate=2026-05-01"
By default, JobLauncher runs synchronously — the HTTP call blocks until the job finishes. For long-running jobs, configure an async launcher:
@Bean
public JobLauncher asyncJobLauncher(JobRepository jobRepository) {
TaskExecutorJobLauncher launcher = new TaskExecutorJobLauncher();
launcher.setJobRepository(jobRepository);
launcher.setTaskExecutor(new SimpleAsyncTaskExecutor());
return launcher;
}
With the async launcher, the REST endpoint returns immediately with the executionId, and the job runs in a background thread. Poll BATCH_JOB_EXECUTION or expose a status endpoint to check progress.
4. Multiple jobs, run by name
If you have multiple jobs, use spring.batch.job.name to specify which runs on startup:
spring:
batch:
job:
name: importOrdersJob # only this job runs on startup
Or pass it at runtime:
java -jar batch-demo.jar --spring.batch.job.name=importOrdersJob
@StepScope and @JobScope: Late-Bound Parameters
@StepScope creates a new bean instance for each step execution. Combined with Spring Expression Language (SpEL), it enables parameter injection into readers and processors at runtime.
@Bean
@StepScope
public FlatFileItemReader<Order> orderReader(
@Value("#{jobParameters['fileName']}") String fileName) {
return new FlatFileItemReaderBuilder<Order>()
.name("orderReader")
.resource(new FileSystemResource(fileName)) // injected from JobParameters
.delimited()
.names("orderId", "customerId", "total")
.fieldSetMapper(new BeanWrapperFieldSetMapper<Order>() {{
setTargetType(Order.class);
}})
.build();
}
Launch with:
JobParameters params = new JobParametersBuilder()
.addString("fileName", "/data/orders-2026-05-01.csv")
.toJobParameters();
jobLauncher.run(importOrdersJob, params);
The fileName parameter is injected into the reader bean when the step starts. This is the standard pattern for making readers configurable without changing code.
@JobScope works the same way but scoped to the job execution rather than the step. Use it for beans shared across multiple steps in one job run.
Separating Batch Metadata from Application Data
In production, keep Spring Batch’s metadata tables in a separate database or schema from your application data. This prevents metadata overhead from affecting application query performance and makes it easy to purge old batch history without touching business data.
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.app")
public DataSourceProperties appDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@Primary
public DataSource appDataSource() {
return appDataSourceProperties().initializeDataSourceBuilder().build();
}
@Bean
@ConfigurationProperties("spring.datasource.batch")
public DataSourceProperties batchDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource batchDataSource() {
return batchDataSourceProperties().initializeDataSourceBuilder().build();
}
}
spring:
datasource:
app:
url: jdbc:mysql://app-db:3306/orders_db?serverTimezone=UTC
username: app_user
password: app_pass
driver-class-name: com.mysql.cj.jdbc.Driver
batch:
url: jdbc:mysql://batch-db:3306/batch_metadata?serverTimezone=UTC
username: batch_user
password: batch_pass
driver-class-name: com.mysql.cj.jdbc.Driver
batch:
jdbc:
initialize-schema: always
Then tell Spring Batch to use the batch datasource for the JobRepository:
@Configuration
public class BatchConfig extends DefaultBatchConfiguration {
@Autowired
@Qualifier("batchDataSource")
private DataSource batchDataSource;
@Override
protected DataSource getDataSource() {
return batchDataSource;
}
}
Your step beans continue to use the primary (app) datasource for reading and writing business data — only the metadata tables use the batch datasource.
The Full Project Structure
By the end of this article, the project looks like this:
src/
├── main/
│ ├── java/com/devopsmonk/batch/
│ │ ├── BatchApplication.java
│ │ ├── config/
│ │ │ └── BatchConfig.java
│ │ ├── domain/
│ │ │ └── Order.java
│ │ ├── processor/
│ │ │ └── OrderProcessor.java
│ │ ├── reader/
│ │ │ └── (custom readers, if any)
│ │ └── controller/
│ │ └── JobController.java
│ └── resources/
│ ├── application.yaml
│ └── data/
│ └── orders.csv
└── test/
└── java/com/devopsmonk/batch/
└── BatchConfigTest.java
Verifying the Setup
After starting the application, verify Spring Batch is configured correctly:
-- These tables should exist after first startup
SHOW TABLES;
-- BATCH_JOB_EXECUTION
-- BATCH_JOB_EXECUTION_CONTEXT
-- BATCH_JOB_EXECUTION_PARAMS
-- BATCH_JOB_EXECUTION_SEQ
-- BATCH_JOB_INSTANCE
-- BATCH_JOB_INSTANCE_SEQ
-- BATCH_STEP_EXECUTION
-- BATCH_STEP_EXECUTION_CONTEXT
-- BATCH_STEP_EXECUTION_SEQ
-- After running a job, check execution history
SELECT ji.job_name, je.status, je.start_time, je.end_time
FROM BATCH_JOB_INSTANCE ji
JOIN BATCH_JOB_EXECUTION je ON ji.job_instance_id = je.job_instance_id
ORDER BY je.start_time DESC;
-- Check step-level metrics
SELECT step_name, read_count, write_count, filter_count,
commit_count, rollback_count, exit_code
FROM BATCH_STEP_EXECUTION
ORDER BY start_time DESC;
What’s Next
Article 4 goes deep on JobRepository and batch metadata — what each table contains, how Spring Batch uses the VERSION column for optimistic locking, how to query execution history, and how to use JobExplorer programmatically.
With the project structure established, from Article 5 onward the series focuses on individual components: readers, writers, processors, error handling, and scaling.
Key rules from this article:
- Never add
@EnableBatchProcessingin Spring Boot 3 - Always set
spring.batch.job.enabled=falsein production - Use
initialize-schema: neverin production; manage the schema with your migration tool - Use
@StepScope+ SpEL to injectJobParametersinto readers and processors