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 types
  • spring-batch-infrastructure — Readers, writers, retry, the execution engine
  • spring-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:

BeanTypeWhat It Does
jobRepositorySimpleJobRepositoryPersists all job/step execution state to the database
jobLauncherTaskExecutorJobLauncherStarts jobs; synchronous by default
jobExplorerSimpleJobExplorerRead-only access to job history
jobOperatorSimpleJobOperatorStop, restart, abandon jobs by ID
jobRegistryMapJobRegistryIn-memory registry of all registered Job beans
Step scopeStepScopeCreates new bean instances per step execution (used with @StepScope)
Job scopeJobScopeCreates 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:

ValueWhen to Use
alwaysDevelopment and CI — creates tables on every startup
embeddedH2/Derby embedded databases only (the default)
neverProduction — 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

PropertyDefaultPurpose
spring.batch.job.enabledtrueSet 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-schemaembeddedalways, embedded, or never
spring.batch.jdbc.schemaauto-detectedOverride the schema SQL file path
spring.batch.table-prefixBATCH_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:

  1. Job instance identity: Parameters with identifying=true (the default) contribute to the JOB_KEY hash that determines whether two runs are the same JobInstance.
  2. 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:

CronMeaning
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-FRIWeekdays 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 @EnableBatchProcessing in Spring Boot 3
  • Always set spring.batch.job.enabled=false in production
  • Use initialize-schema: never in production; manage the schema with your migration tool
  • Use @StepScope + SpEL to inject JobParameters into readers and processors