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. 🙂)

Digging into the Stack Trace

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.

Takeaway: Be Intentional with Transactions

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.