`ItemWriter`s: databases & alternative destinations
|
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 database is the usual destination for a chunk, and it is where the "whole chunk at once" contract pays
off: one PreparedStatement executed as a JDBC batch instead of 500 round-trips.
JdbcBatchItemWriter
The workhorse. It takes one SQL statement and executes it as a batch over the chunk.
Bean-mapped
Named parameters are resolved from the item’s properties:
@Bean
public JdbcBatchItemWriter<Trade> tradeWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Trade>()
.dataSource(dataSource)
.sql("""
INSERT INTO trade (external_id, isin, quantity, price, trade_date)
VALUES (:externalId, :isin, :quantity, :price, :tradeDate)
""")
.beanMapped() // :externalId <- item.getExternalId()
.assertUpdates(true) // fail if any statement affects no rows
.build();
}
assertUpdates(true) (the default) throws EmptyResultDataAccessException when a statement updates zero rows — exactly what you want for an UPDATE that must match, and exactly what you must turn off for an
idempotent upsert that may legitimately do nothing.
ItemPreparedStatementSetter
For positional ? parameters, or when values must be computed or converted:
@Bean
public JdbcBatchItemWriter<Trade> positionalTradeWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Trade>()
.dataSource(dataSource)
.sql("INSERT INTO trade (external_id, isin, quantity, notional) VALUES (?, ?, ?, ?)")
.itemPreparedStatementSetter((trade, statement) -> {
statement.setString(1, trade.getExternalId());
statement.setString(2, trade.getInstrument().getIsin());
statement.setLong(3, trade.getQuantity());
statement.setBigDecimal(4, trade.getQuantity().multiply(trade.getPrice()));
})
.build();
}
ItemSqlParameterSourceProvider
The named-parameter equivalent, and the right hook when the parameter set is not a one-to-one map of the item’s properties:
@Bean
public JdbcBatchItemWriter<Trade> providerTradeWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Trade>()
.dataSource(dataSource)
.sql("""
INSERT INTO trade (external_id, isin, notional, loaded_at)
VALUES (:externalId, :isin, :notional, :loadedAt)
ON CONFLICT (external_id) DO UPDATE SET notional = EXCLUDED.notional
""")
.itemSqlParameterSourceProvider(trade -> new MapSqlParameterSource()
.addValue("externalId", trade.getExternalId())
.addValue("isin", trade.getInstrument().getIsin())
.addValue("notional", trade.getQuantity().multiply(trade.getPrice()))
.addValue("loadedAt", Timestamp.from(Instant.now())))
.assertUpdates(false) // an upsert may legitimately update nothing
.build();
}
An upsert like this is what makes a step restartable without duplicates, which is the practical form of the idempotency requirement discussed in Architecture & processing strategies. The statement syntax itself is covered in SQL Modifications.
JPA and Hibernate writers
JpaItemWriter merges (or persists) each item and flushes once per chunk:
@Bean
public JpaItemWriter<Trade> jpaTradeWriter(EntityManagerFactory entityManagerFactory) {
return new JpaItemWriterBuilder<Trade>()
.entityManagerFactory(entityManagerFactory)
.usePersist(true) // persist() for new entities instead of merge()
.build();
}
@Bean
public HibernateItemWriter<Trade> hibernateTradeWriter(SessionFactory sessionFactory) {
return new HibernateItemWriterBuilder<Trade>()
.sessionFactory(sessionFactory)
.clearSession(true) // clear the first-level cache after each chunk
.build();
}
Two batch-specific cautions:
-
the persistence context must be cleared per chunk (
clearSession(true), or an explicitentityManager.clear()), otherwise it accumulates every entity written by the step and eventually exhausts the heap; -
JDBC batching only happens if the provider is configured for it —
hibernate.jdbc.batch_size, plus an identifier strategy that does not force a round-trip per row (IDENTITYdefeats batching).
Both are covered in Hibernate Reference and
Spring Data JPA and are not restated here. For pure bulk
loading, JdbcBatchItemWriter is usually the faster choice.
Spring Data writers
RepositoryItemWriter calls a repository method per item — convenient, and the natural fit when the
repository already encapsulates the save logic:
@Bean
public RepositoryItemWriter<Trade> repositoryTradeWriter(TradeRepository tradeRepository) {
return new RepositoryItemWriterBuilder<Trade>()
.repository(tradeRepository)
.methodName("save")
.build();
}
MongoItemWriter writes a chunk to a collection, either inserting or upserting:
@Bean
public MongoItemWriter<Trade> mongoTradeWriter(MongoTemplate mongoTemplate) {
return new MongoItemWriterBuilder<Trade>()
.template(mongoTemplate)
.collection("trade")
.mode(MongoItemWriter.Mode.UPSERT)
.build();
}
MongoItemWriter.Mode.REMOVE deletes the chunk’s items instead, which makes a purge step trivial.
Alternative-destination adapters
ItemWriterAdapter turns an existing service method into a writer — one call per item:
@Bean
public ItemWriterAdapter<Trade> serviceWriter(TradeService tradeService) {
ItemWriterAdapter<Trade> adapter = new ItemWriterAdapter<>();
adapter.setTargetObject(tradeService);
adapter.setTargetMethod("record"); // void record(Trade trade)
adapter.afterPropertiesSet();
return adapter;
}
Note the cost: per-item calls give up the batching that makes a chunk writer fast. It is the right adapter for a service that genuinely takes one item, and the wrong one for anything that could accept a list.
PropertyExtractingDelegatingItemWriter calls a method whose arguments are extracted from the item’s
properties, which suits a legacy service signature:
@Bean
public PropertyExtractingDelegatingItemWriter<Trade> extractingWriter(LedgerService ledgerService) {
PropertyExtractingDelegatingItemWriter<Trade> writer =
new PropertyExtractingDelegatingItemWriter<>();
writer.setTargetObject(ledgerService);
writer.setTargetMethod("post"); // post(String isin, BigDecimal notional)
writer.setFieldsUsedAsTargetMethodArguments(
new String[] { "instrument.isin", "notional" }); // nested paths are allowed
writer.afterPropertiesSet();
return writer;
}
Messaging and mail destinations
Writers that publish to a MessageChannel, a JMS queue or an SMTP server live in the Spring Integration
bridge rather than in core — see
Spring Batch Integration, which also covers the
asynchronous writer used to overlap slow remote calls with processing.
Whatever the destination, remember that a non-transactional writer — a message broker, an HTTP call, a
file with transactional(false) — will not be rolled back with the chunk. Either make the operation
idempotent and keyed on the item, or move it into a separate step that runs after the data is safely
committed.