Fix the import whose per-chunk @Transactional never rolls back
This service imports rows chunk by chunk. importChunk is annotated @Transactional, so a chunk that fails halfway should roll back whole. In production it does not: the rows saved before the failure stay in the database, and no rollback is logged. The bean is an ordinary @Service, transaction management is enabled, and the very same importChunk rolls back correctly when a controller calls it.
Constraints:
- keep one transactional boundary per chunk — a failing chunk must not partially commit
- do not wrap the whole of
importAllin a single transaction
@Service
public class ImportService {
private final RowRepository repo;
public ImportService(RowRepository repo) {
this.repo = repo;
}
public void importAll(List<List<Row>> chunks) {
for (List<Row> chunk : chunks) {
this.importChunk(chunk);
}
}
@Transactional
public void importChunk(List<Row> chunk) {
for (Row row : chunk) {
repo.save(row); // throws on a malformed row
}
}
}
Find and fix the bug.
@Transactional is applied by a proxy that wraps the bean, so it only takes effect on a call that enters the bean from outside. this.importChunk(chunk) is an in-object call that never leaves ImportService, bypasses the proxy, and starts no transaction at all — each save simply autocommits. Fix it by routing the call through the proxy: inject the bean into itself, move importChunk into a separate bean, or open the transaction explicitly with a TransactionTemplate.
- ✗Expecting
@Transactionalto apply to a method the bean calls on itself - ✗Blaming the rollback rules when no transaction was ever started
- ✗Assuming
REQUIRES_NEWalone fixes a call that never reaches the proxy
- →Why does making
importChunkprivateorfinalbreak@Transactionalin the same way? - →What are the risks of self-injecting a bean to send a call back through its own proxy?
Fix
@Service
public class ImportService {
private final RowRepository repo;
private final ImportService self; // a reference to ITSELF, through the proxy
public ImportService(RowRepository repo, @Lazy ImportService self) {
this.repo = repo;
this.self = self;
}
public void importAll(List<List<Row>> chunks) {
for (List<Row> chunk : chunks) {
self.importChunk(chunk); // ✅ the call goes through the proxy
}
}
@Transactional
public void importChunk(List<Row> chunk) {
for (Row row : chunk) {
repo.save(row);
}
}
}
What was happening. @Transactional is implemented with an AOP proxy: what the container hands to other beans is not ImportService itself but a wrapper around it. The wrapper is what opens the transaction on method entry and commits or rolls it back on exit.
this.importChunk(chunk) goes through a plain object reference and never touches the wrapper. The proxy never sees the call, no transaction is opened, and every repo.save(row) reaches the database on its own autocommit. The rows written before the failure stay put — there is nothing to roll back, because there was no transaction. The identical method called by a controller arrives through the proxy and behaves as designed, which is exactly why it "works in one place and not the other".
Three working fixes:
| Approach | When to pick it |
|---|---|
@Lazy self-injection (above) | A surgical change when you do not want to split the class. @Lazy is what breaks the bean-creation cycle |
Move importChunk into a separate ChunkImporter bean | The cleanest: the call becomes external by construction |
TransactionTemplate inside importAll | When the boundary is easier to express imperatively, without the annotation |
⚠️ The same trap breaks @Async, @Cacheable, and @PreAuthorize — everything built on proxies. And for the same reason @Transactional silently does nothing on private and final methods: the proxy cannot intercept them.