TL;DR Decide at which layer of your application you want to ensure transaction boundaries (I suggest the application layer or Use Case layer for styles like Clean or Onion Architecture). Document that decision and enforce it via a technology like ArchUnit. (Don’t forget to explain the reasoning in the error message — at least link to this blog post. 🙂)
A colleague was debugging a weird behavior with Spring/Hibernate @Transactional the other day and was wondering:
“I’m iterating over a collection, and one element throws an exception which I immediately catch — but the transaction gets rolled back anyway. Why is that?”
First, we saw in the stack trace that the transaction was marked as rollback-only. From my understanding, this happens when an exception is not caught and gets thrown out of the annotated method. The Spring-generated proxy method then catches this exception and performs the rollback — that’s how it should work. But in our case, we were catching it inside the method.
Long story short: as we dug deeper into the call stack, we discovered another @Transactional annotation on the save call of a repository method. This method had no effect because the use case had already opened a transaction. However, the exception still passed through the generated proxy method from this nested annotation and marked the enclosing transaction for rollback. After removing the inner annotation, everything worked as expected.
This strengthens my belief that you should never sprinkle @Transactional annotations over your code “just in case.” You could argue: when someone calls a save method, the system should guarantee that the data is saved. My argument is: saving data almost never happens in isolation but as part of a broader use case. If a part of that use case fails, nothing should be saved.