Practical conventions for Spring Batch in NUS Java applications. Covers job/step structure, naming, reader/processor/writer patterns, chunk vs tasklet choice, transaction boundaries, restartability, idempotency, parameter validation, logging/observability, and testing approaches. Use this skill when implementing or reviewing Spring Batch jobs/steps and related batch infrastructure.
Scope: Spring Batch jobs/steps, chunk processing, tasklets, item readers/processors/writers, job parameters, restartability, idempotency, observability, and batch testing patterns.
See also:
- Coding skill (boundary-safe errors + sensitive data):
.github/skills/coding/SKILL.md- Java umbrella skill:
.github/skills/java/SKILL.md- Java JDBC skill (SQL + JdbcTemplate patterns):
.github/skills/java/jdbc/SKILL.md- Java JPA skill (entities/repositories; avoid leaking persistence models):
.github/skills/java/jpa/SKILL.md- Java Spring Service skill (service-layer conventions; tasklets may delegate to services):
.github/skills/java/spring-service/SKILL.md
For an , you follow the repo's established patterns for:
JobBuilderFactory/StepBuilderFactory vs newer builders depending on Spring Batch version)You MUST NOT refactor the batch architecture (switch chunk ↔ tasklet, rewrite job structure, change table schema) unless explicitly requested.
userSyncJob, dailyAccountReconciliationJob, invoiceExportJobreadUsersStep, validateInvoicesStep, writeExportsStepstep1, processStep.camelCase.requestDate (ISO date: requestDate=2026-03-21) as the business trigger date for all scheduled and on-demand jobs.accountId, batchGroupId, sourceSystem).requestDate, also include a unique requestId (UUID) so Spring Batch treats each submission as a distinct JobInstance.Example (daily scheduled job):
requestDate=2026-03-21
sourceSystem=CORE_BANKING
Example (on-demand job triggered via scheduler or API):
requestDate=2026-03-21
accountId=ACC-00123
requestId=<uuid>
Validate all required parameters at the start of the job (e.g., in a JobExecutionListener or the first tasklet):
@Component
public class AccountSyncJobParameterValidator implements JobParametersValidator {
@Override
public void validate(JobParameters parameters) throws JobParametersInvalidException {
String requestDate = parameters.getString("requestDate");
if (requestDate == null || requestDate.isBlank()) {
throw new JobParametersInvalidException("requestDate is required (format: yyyy-MM-dd)");
}
try {
LocalDate.parse(requestDate); // throws DateTimeParseException if invalid — parse result discarded intentionally
} catch (DateTimeParseException e) {
throw new JobParametersInvalidException("requestDate must be in yyyy-MM-dd format, got: " + requestDate);
}
String accountId = parameters.getString("accountId");
if (accountId == null || accountId.isBlank()) {
throw new JobParametersInvalidException("accountId is required");
}
}
}
Wire the validator into the job:
@Bean
public Job accountSyncJob(JobRepository jobRepository, Step readAccountsStep,
AccountSyncJobParameterValidator validator) {
return new JobBuilder("accountSyncJob", jobRepository)
.validator(validator)
.start(readAccountsStep)
.build();
}
requestDate typically corresponds to the business date.requestId) to differentiate multiple runs for the same requestDate.202 Accepted with a job reference).Use chunk processing when:
Use a tasklet for:
Rule: if both work, prefer chunk for record processing and tasklet for orchestration.
Use this skeleton when adding a chunk-oriented step. JDBC-first (prefer JdbcPagingItemReader /
JdbcBatchItemWriter); adjust reader/writer for file or JPA sources per repo conventions.
@Configuration
public class AccountSyncJobConfig {
// --- Job wiring ---
@Bean
public Job accountSyncJob(JobRepository jobRepository,
AccountSyncJobParameterValidator validator,
Step syncAccountsStep) {
return new JobBuilder("accountSyncJob", jobRepository)
.validator(validator)
.start(syncAccountsStep)
.build();
}
// --- Chunk step: read → process → write, 100 items per transaction ---
@Bean
public Step syncAccountsStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
ItemReader<AccountRecord> accountReader,
ItemProcessor<AccountRecord, AccountDto> accountProcessor,
ItemWriter<AccountDto> accountWriter) {
return new StepBuilder("syncAccountsStep", jobRepository)
.<AccountRecord, AccountDto>chunk(100, txManager)
.reader(accountReader)
.processor(accountProcessor)
.writer(accountWriter)
.faultTolerant()
.skipLimit(50) // bounded skip
.skip(AccountValidationException.class) // only skip expected validation failures
.build();
}
// --- JDBC paging reader (stream; no full-table load) ---
@Bean
@StepScope
public JdbcPagingItemReader<AccountRecord> accountReader(
DataSource dataSource,
@Value("#{jobParameters['requestDate']}") String requestDate) {
Map<String, Order> sortKeys = new LinkedHashMap<>();
sortKeys.put("account_id", Order.ASCENDING);
OraclePagingQueryProvider queryProvider = new OraclePagingQueryProvider(); // use DB-specific provider
queryProvider.setSelectClause("SELECT account_id, display_name, status");
queryProvider.setFromClause("FROM accounts");
queryProvider.setWhereClause("WHERE request_date = :requestDate AND status = 'PENDING'");
queryProvider.setSortKeys(sortKeys);
return new JdbcPagingItemReaderBuilder<AccountRecord>()
.name("accountReader")
.dataSource(dataSource)
.queryProvider(queryProvider)
.parameterValues(Map.of("requestDate", requestDate))
.rowMapper((rs, rowNum) -> {
AccountRecord r = new AccountRecord();
r.setAccountId(rs.getString("account_id"));
r.setDisplayName(rs.getString("display_name"));
r.setStatus(rs.getString("status"));
return r;
})
.pageSize(100)
.build();
}
// --- JDBC batch writer (idempotent upsert preferred) ---
@Bean
@StepScope
public JdbcBatchItemWriter<AccountDto> accountWriter(DataSource dataSource) {
String sql =
"MERGE INTO processed_accounts dst " +
"USING (SELECT :accountId AS account_id, :displayName AS display_name FROM dual) src " +
"ON (dst.account_id = src.account_id) " +
"WHEN MATCHED THEN UPDATE SET dst.display_name = src.display_name " +
"WHEN NOT MATCHED THEN INSERT (account_id, display_name) " +
" VALUES (src.account_id, src.display_name)";
return new JdbcBatchItemWriterBuilder<AccountDto>()
.sql(sql)
.dataSource(dataSource)
.itemSqlParameterSourceProvider(item -> new MapSqlParameterSource()
.addValue("accountId", item.getAccountId())
.addValue("displayName", item.getDisplayName()))
.build();
}
}
Key rules illustrated by the template:
@StepScope on reader/writer so jobParameters are resolved at step start time.skipLimit is bounded; only declared, expected exceptions are skipped.Use this skeleton for orchestration steps that perform a single operation rather than record-by-record processing (cleanup tables, send a completion notification, move/rename a file, etc.).
@Component
public class NotifyCompletionTasklet implements Tasklet {
private static final Logger log = LoggerFactory.getLogger(NotifyCompletionTasklet.class);
private final NotificationService notificationService;
public NotifyCompletionTasklet(NotificationService notificationService) {
this.notificationService = notificationService;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
JobParameters params = chunkContext.getStepContext()
.getStepExecution().getJobParameters();
String requestDate = params.getString("requestDate"); // business trigger date
String accountId = params.getString("accountId"); // business key
// Delegate to service; tasklet stays thin
notificationService.notifyCompletion(requestDate, accountId);
// Boundary-safe log — no PII, no tokens
log.info("Completion notification sent requestDate={} accountId={}", requestDate, accountId);
return RepeatStatus.FINISHED;
}
}
Wire the tasklet as a step in the job config:
@Bean
public Step notifyCompletionStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
NotifyCompletionTasklet notifyCompletionTasklet) {
return new StepBuilder("notifyCompletionStep", jobRepository)
.tasklet(notifyCompletionTasklet, txManager)
.build();
}
@Bean
public Job accountSyncJob(JobRepository jobRepository,
AccountSyncJobParameterValidator validator,
Step syncAccountsStep,
Step notifyCompletionStep) {
return new JobBuilder("accountSyncJob", jobRepository)
.validator(validator)
.start(syncAccountsStep)
.next(notifyCompletionStep)
.build();
}
Key rules illustrated by the template:
requestDate and business key(s) from JobParameters.@Service; tasklet stays thin (no domain logic inline).JdbcPagingItemReader or JdbcCursorItemReader depending on repo conventions.FlatFileItemReader (CSV) or equivalent.JdbcBatchItemWriter or NamedParameterJdbcTemplate batch operations).(jobName, requestDate, <businessKey>)Rule: never rely on "we will never rerun" as an idempotency strategy.
jobName, jobExecutionId, requestDate (if present), start/end timestamps, statusSuggested test coverage:
requestDate or business keys)requestDate and relevant business primary key(s); on-demand runs include a unique requestId