`ItemProcessor`s
|
This section documents the current Spring Batch line — 6.0.x, on Spring Framework 7 and Spring Boot 4.1.x, with a Java 17+ baseline — as published at the Spring Batch reference documentation. No specific patch version is pinned. Some surfaces (Spring Cloud Task and Spring Cloud Data Flow orchestration, the deployer-based partition handler, and JSR-352) are linked, not documented in depth. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. |
The ItemProcessor sits between the reader and the writer and is where business logic belongs: transform the
item, change its type, or drop it.
The contract
public interface ItemProcessor<I, O> {
O process(I item) throws Exception; // return the output, or null to filter the item out
}
The two type parameters are the step’s input and output types — they are allowed to differ, and usually should. A step that reads a flat-file DTO and writes a domain entity does its mapping here:
@Component
public class TradeItemProcessor implements ItemProcessor<TradeCsv, Trade> {
private final InstrumentService instrumentService;
public TradeItemProcessor(InstrumentService instrumentService) {
this.instrumentService = instrumentService;
}
@Override
public Trade process(TradeCsv item) {
Instrument instrument = instrumentService.byIsin(item.getIsin()); // read-only lookup
return new Trade(
item.getExternalId(),
instrument,
item.getQuantity(),
item.getPrice(),
item.getQuantity().multiply(item.getPrice()));
}
}
@Bean
public Step loadTradesStep(JobRepository jobRepository, PlatformTransactionManager tx,
ItemReader<TradeCsv> reader,
TradeItemProcessor processor,
ItemWriter<Trade> writer) {
return new StepBuilder("loadTradesStep", jobRepository)
.<TradeCsv, Trade>chunk(500, tx)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
A step with no processor simply passes items through; the input and output types are then the same.
Filtering vs. skipping
Returning null filters the item: it is not passed to the writer, the step continues normally, and
filterCount increases. This is the way to express "this record is valid but not interesting":
@Override
public Trade process(TradeCsv item) {
if (item.getQuantity().signum() == 0) {
return null; // a zero-quantity trade is nothing to load -- filtered, not an error
}
return map(item);
}
Throwing an exception is different: it is an error, and what happens next depends on whether the step is
fault-tolerant and whether the exception is skippable — see
Fault tolerance: skip & retry. It increments
processSkipCount, not filterCount.
| Meaning | |
|---|---|
Filter ( |
Expected, routine, part of the business rules. Counted in |
Skip (throw a skippable exception) |
Unexpected but tolerable — a bad record. Counted in |
Using an exception for routine filtering makes the skip limit meaningless and the counters misleading.
Composing processors
CompositeItemProcessor chains processors, feeding each one’s output into the next. The chain’s input type is
the first processor’s, its output type the last one’s; if any link returns null, the item is filtered and the
rest of the chain is skipped:
@Bean
public CompositeItemProcessor<TradeCsv, Trade> tradeProcessingChain(
BeanValidatingItemProcessor<TradeCsv> validator,
TradeEnrichingProcessor enricher,
TradeMappingProcessor mapper) {
CompositeItemProcessor<TradeCsv, Trade> composite = new CompositeItemProcessor<>();
composite.setDelegates(List.of(validator, enricher, mapper));
return composite;
}
ClassifierCompositeItemProcessor routes instead of chaining — one processor per item category:
@Bean
public ClassifierCompositeItemProcessor<Transaction, Posting> routingProcessor(
PurchaseProcessor purchaseProcessor,
RefundProcessor refundProcessor,
FeeProcessor feeProcessor) {
ClassifierCompositeItemProcessor<Transaction, Posting> processor =
new ClassifierCompositeItemProcessor<>();
processor.setClassifier(transaction -> switch (transaction.getType()) {
case PURCHASE -> purchaseProcessor;
case REFUND -> refundProcessor;
case FEE -> feeProcessor;
});
return processor;
}
Wrapping an existing service
ItemProcessorAdapter turns any bean method into a processor, which avoids writing a class whose only job is
to delegate:
@Bean
public ItemProcessorAdapter<TradeCsv, Trade> serviceProcessor(TradeMappingService mappingService) {
ItemProcessorAdapter<TradeCsv, Trade> adapter = new ItemProcessorAdapter<>();
adapter.setTargetObject(mappingService);
adapter.setTargetMethod("toTrade");
adapter.afterPropertiesSet();
return adapter;
}
A method reference does the same thing with less ceremony when the signature already matches:
@Bean
public ItemProcessor<TradeCsv, Trade> lambdaProcessor(TradeMappingService mappingService) {
return mappingService::toTrade;
}
Validation
ValidatingItemProcessor runs a Validator and passes the item through unchanged when it is valid:
@Bean
public ValidatingItemProcessor<TradeCsv> customValidatingProcessor() {
ValidatingItemProcessor<TradeCsv> processor = new ValidatingItemProcessor<>(item -> {
if (item.getPrice().signum() <= 0) {
throw new ValidationException("price must be positive: " + item.getExternalId());
}
});
processor.setFilter(false); // true => filter invalid items instead of throwing
return processor;
}
setFilter(true) is the switch between "an invalid record is an error" (throw, and let skip handle it) and
"an invalid record is uninteresting" (filter it out silently).
BeanValidatingItemProcessor applies Jakarta Bean Validation annotations, so the rules live on the item type:
public class TradeCsv {
@NotBlank
private String externalId;
@Pattern(regexp = "[A-Z]{2}[A-Z0-9]{9}[0-9]")
private String isin;
@NotNull @Positive
private BigDecimal price;
// getters and setters
}
@Bean
public BeanValidatingItemProcessor<TradeCsv> beanValidatingProcessor() throws Exception {
BeanValidatingItemProcessor<TradeCsv> processor = new BeanValidatingItemProcessor<>();
processor.setFilter(false);
processor.afterPropertiesSet();
return processor;
}
Pair it with .skip(ValidationException.class) and a SkipListener that writes rejects to a quarantine table,
and the job gains a complete, auditable data-quality gate.
ScriptItemProcessor evaluates a JSR-223 script (Groovy, JavaScript, and so on) as the processing logic — useful when a rule must be changed without redeploying:
@Bean
@StepScope
public ScriptItemProcessor<Trade, Trade> scriptProcessor(
@Value("#{jobParameters['rules.script']}") Resource script) throws Exception {
ScriptItemProcessor<Trade, Trade> processor = new ScriptItemProcessor<>();
processor.setScript(script); // setScriptSource(String, String) is the inline alternative
processor.afterPropertiesSet();
return processor;
}
Treat the script as code: version it, review it, and never load it from a location an untrusted party can write to.
Idempotency
A processor may be called more than once for the same item. When a chunk is rolled back and replayed — which is exactly what a fault-tolerant step does on a skip or a retry — every item in that chunk is re-processed from the framework’s cache.
Consequences, drawn out in Fault tolerance: skip & retry:
-
a processor that sends an e-mail, charges a card, posts to a queue or increments a shared counter will do it again on replay;
-
a processor that mutates the item in place and depends on its previous state will compute a different result the second time;
-
a processor that writes to the database is doing the writer’s job, and its write is not covered by the writer’s batching or ordering guarantees.
The rule that follows: keep the processor pure. Read-only lookups, computation and mapping belong here; all
side effects belong in the ItemWriter
(ItemWriters: databases & alternative
destinations). When a side effect genuinely cannot move, either make it idempotent (key it on the item’s
identity, so a second attempt is a no-op) or enable .processorNonTransactional() so processed outputs are
cached instead of recomputed.