@Transactional in Spring and Exceptions
-
RuntimeExceptions cause the transaction to be rolled back, checked exceptions do not;
-
RuntimeExceptions cause the transaction to be rolled back the moment the exception crosses boundaries
@Transactional
-method. Even if you catch this exception higher up the stack, the transaction will still be rolled back; -
This behavior can be controlled through attributes
rollbackFor
/noRollbackFor
at the annotation@Transcational
.
Example 1
@Slf4j
@Component
public class Bean {
private final BeanRepository repository;
public Bean (BeanRepository repository) {
this.repository = repository;
}
@Transactional
public void saveItems(List<Item> items) {
for(Item item: items) {
try {
saveItem(item);
} catch(RuntimeException e) {
log.error("Could not save item {}", item, e);
}
}
}
@Transactional
public void saveItem(Item item) {
if(item.foo().equals("BAD_ITEM"))
throw new RuntimeException();
repository.save(item);
}
}
What happens if we pass a list to save Item
‘s whose 0th and 2nd elements are NOT BAD_ITEMs, but the 1st is?
-
Nothing will be saved because
RuntimeException
in the saveItem method will roll back the transaction; -
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
The correct answer is 3 (well, as a rule, more details below). When we call a method of the same component from a component in this way, it is just a call to an internal method, not a method of a proxy object. It simply ignores the annotation @Transactional
at saveItem
and will not create a new transactional context. Why this is happening can be found in the articles I linked to at the beginning. This means that RuntimeException will not cross the boundary of the @Transactional method.
If we use compile-time weaving, or if we rewrite the code so that the call occurs through inner.saveItem
then the correct answer is 1.
Example 2
@Component
public class Bean {
private final BeanRepository repository;
public Bean(BeanRepository repository) {
this.repository = repository;
}
@Transactional
public void saveItem(Item item) {
if(item.foo().equals("BAD_ITEM"))
throw new RuntimeException();
repository.save(item);
}
}
@Slf4j
@Controller
public class Controller {
private final Bean bean;
public Controller(Bean bean) {
this.bean = bean;
}
public void saveItems(List<Item> items) {
for(Item item: items) {
try {
bean.saveItem(item);
} catch(RuntimeException e) {
log.error("Could not save item {}", item, e);
}
}
}
}
The same question: what happens if we pass a list to save Item
‘s whose 0th and 2nd elements are NOT BAD_ITEMs, but the 1st is?
-
Nothing will be saved;
-
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
The correct answer is still 3. Now the transaction with RuntimeException
‘om will be rolled back, but this code creates 3 transactions. The remaining 2 will be committed.
Example 3
Everything is the same, but now the method saveItems
Same @Transactional
:
@Slf4j
@Controller
public class Controller {
...
@Transactional
public void saveItems(List<Item> items) {
for(Item item: items) {
try {
bean.saveItem(item);
} catch(RuntimeException e) {
log.error("Could not save item {}", item, e);
}
}
}
}
-
Nothing will be saved;
-
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
And now the correct answer is 1. By default, @Transactional
uses Propagation.REQUIRED
which will lead to saveItem
will use the transaction opened for saveItems
. If an error occurs, it will be marked as rollbackOnly
.
Example 4
And now let’s add a constraint to the controller that RuntimeException
‘s should not be rolled back.
@Slf4j
@Controller
public class Controller {
...
@Transactional(noRollbackFor = RuntimeException.class)
public void saveItems(List<Item> items) {
try {
bean.saveItem(item);
} catch(RuntimeException e) {
log.error("Could not save item {}", item, e);
}
}
}
-
Nothing will be saved;
-
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
The correct answer is still 1. noRollbackFor
only affects the annotated method, its behavior is not “inherited” by components down the call stack, even if they use the same transaction. That’s why saveItem
will mark the transaction as rollbackOnly
.
Example 5
reschedule noRollbackFor
on saveItem
. And we will remove the interception code from the controller RuntimeException
.
@Component
public class Bean {
private final BeanRepository repository;
public Bean(BeanRepository repository) {
this.repository = repository;
}
@Transactional(noRollbackFor = RuntimeException.class)
public void saveItem(Item item) {
if(item.foo().equals("BAD_ITEM"))
throw new RuntimeException();
repository.save(item);
}
}
@Slf4j
@Controller
public class Controller {
private final Bean bean;
public Controller(Bean bean) {
this.bean = bean;
}
@Transactional
public void saveItems(List<Item> items) {
for(Item item: items) {
bean.saveItem(item);
}
}
}
-
Nothing will be saved
-
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
The correct answer is 1. Yes, now saveItem
does not roll back the transaction, but with the RuntimeException
‘oh he doesn’t do anything. Exception will fly through the controller, and already at it @Transactional
-method will cause the transaction to be rolled back.
Example 6
Let’s mark both @Transactional
-method so that they don’t rollback runtime exceptions:
@Component
public class Bean {
private final BeanRepository repository;
public Bean(BeanRepository repository) {
this.repository = repository;
}
@Transactional(noRollbackFor = RuntimeException.class)
public void saveItem(Item item) {
if(item.foo().equals("BAD_ITEM"))
throw new RuntimeException();
repository.save(item);
}
}
@Slf4j
@Controller
public class Controller {
private final Bean bean;
public Controller(Bean bean) {
this.bean = bean;
}
@Transactional(noRollbackFor = RuntimeException.class)
public void saveItems(List<Item> items) {
for(Item item: items) {
bean.saveItem(item);
}
}
}
-
Nothing will be saved
-
Only the 0th element will be saved;
-
The 0th and 2nd element will be stored.
Answer
The correct answer is 2. The transaction will not be rolled back, but the list processing will stop due to an exception. The same effect will be if you remove the annotations altogether @Transactional
or if this annotation is removed only from the controller.
Example 7
At Spring Data
in the interface CrudRepository
there is this method:
void deleteAllById(Iterable<? extends ID> ids)
What if one non-existent one among the existing identifiers is passed to it as input? I’d expect it to remove the ones that are there and ignore the ones that aren’t (well, just like the regular operator delete
V SQL
). And yes Spring Data
will do just that, but only if you use Spring Data 3
. And he’s about six months old now. If you are using an earlier version you will get an error EmptyResultDataAccessException
(Spring Data
strange people wrote, and yes, she went to hell with this backward compatibility of yours).
@Component
public class Bean {
private final BeanRepository repository;
public Bean(BeanRepository repository) {
this.repository = repository;
}
@Transactional(noRollbackFor = EmptyResultDataAccessException.class)
public void deleteItems(List<Item> items) {
repostory.deleteAllById(items.stream().map(Item::id).toList());
}
}
What happens if there is a non-existent ID in the middle of the list in Spring Data 2.x
?
Answer
The transaction will be completely rolled back. At Repository
modifying methods are themselves labeled as @Transactional
so this example is effectively equivalent to example number 4.
You can override the method in your repository deleteAllById
having already indicated to him noRollbackFor = EmptyResultDataAccessException.class
but that’s a bad idea. deleteAllById
just calls in a loop deleteById
. It is also marked as @Transactional
but it doesn’t matter, as we found out in example number 1. When deleteById
will throw an exception, the transaction will not be rolled back, but the loop will break. As a result, you will find yourself in the situation of example number 6, when half Item
‘ov retired, and half – no.
Exit – either instead deleteAllById
check on the service side that Item
exists, and then call deleteById
or removed by modifying JPQL
-request.
Example 8
public interface BeanRepository extends JpaRepository<Item, Long> {
@Override
@Transactional(noRollbackForClassName = "org.springframework.dao.EmptyResultDataAccessException", rollbackFor = DataAccessException.class, noRollbackFor = RuntimeException.class)
void deleteAllById(Iterable<? extends Long> longs);
}
What happens if one of the IDs is missing?
Answer
Well, firstly, the author of such a pull request will most likely be beaten on a code review. Possibly feet.
Answer – the rule that contains the nearest parent of the abandoned will work. Exception
‘A. In our case – noRollbackForClassName
so part Item
‘ov will be removed, some will not.
If the parent is not among the rules, the default behavior will be used (RuntimeException
And Error
rollback, checked – do not touch). There is also a completely stubborn option, when something like is written rollbackFor = RuntimeException.class, noRollbackFor = RuntimeException.class
. Spring
let me do that too, unfortunately. Judging by the source code, noRollback rules will always work.
Results
-
Attributes
rollbackFor
/noRollbackFor
control only the behavior of the transaction in case of exceptions. The exceptions themselves are still pushed higher up the stack; -
rollbackFor / noRollbackFor attributes are not inherited
@Transactional
-methods down the call stack, even if usedPropagation.REQUIRED
; -
If you seriously have to understand and actively use all this, then think about whether transactions in general and container-managed transactions in particular are needed in your particular case. Switching to manual work with transactions can simplify the code, compared to the logic built on different types of exceptions.