Notes guide
Spring @Transactional does not mean what you think
In review I still see @Transactional used as a blessing: put it on a method, and the database will behave. It will not. Spring starts a transaction when a proxy intercepts an incoming call. If the call never hits the proxy, you have no transaction, no rollback, and a very confusing production bug.
What I believed → what I do now
I used to sprinkle @Transactional on every service method “to be safe.” I now put it on the one method that is the use-case boundary, I write a test that proves rollback, and I treat same-class private calls as a smell.
The proxy is the whole trick
A Spring bean with @Transactional is wrapped. Callers outside the class go through the wrapper. The wrapper starts the transaction, then calls your method, then commits or rolls back.
This call does start a transaction:
OrderController → orderService.place(order)
This call does not, even if place() is @Transactional:
orderService.place(order)
→ this.saveLines(order) // same instance, no proxy
this.saveLines() is a Java method call. Spring never sees it. If saveLines was the method you annotated, nothing happens. This is self-invocation. It is the most common “but I put @Transactional on it” bug I have debugged.
Fixes I accept in a PR:
- Move the inner method to another bean (
LineWriter) so the call crosses a proxy. - Keep one public method as the boundary and do the work in private methods inside that transaction, not as a second
@Transactionalhope. - Do not use
AopContext.currentProxy()unless you are ready to explain it in the next incident.
REQUIRED is the default, and it is sticky
Propagation.REQUIRED (the default) joins an existing transaction. Nested @Transactional methods on other beans do not get their own commit. If the outer method rolls back, the inner work rolls back too. That is usually what you want.
REQUIRES_NEW suspends the outer transaction and commits the inner one even if the outer later fails. I use it for audit logs and outbox writes that must survive a business rollback. I do not use it because “this method feels important.”
If you need REQUIRES_NEW, you also need a second bean. Same-class REQUIRES_NEW is still a self-invocation: it will not start.
Rollback is not “any exception”
By default Spring rolls back on unchecked exceptions (RuntimeException, Error). A checked exception commits unless you say otherwise:
@Transactional(rollbackFor = Exception.class)
I have watched a payment service commit a ledger row, then throw a checked RemoteServiceException, then tell the customer it failed. The database disagreed.
Be explicit at the boundary. If the method can fail in a way that must not persist, name those types in rollbackFor. Do not rely on “we only throw runtime exceptions” as a team convention. Someone will add a checked wrapper.
Where I put the boundary
- Controller: no
@Transactional. It parses HTTP and calls one service method. - Service use-case method: one annotation, one unit of work, named after the business action (
placeOrder,closePeriod). - Repositories: no extra
@Transactional“for safety.” Spring Data already wraps repository methods. Stacking them hides the real boundary.
A test I want to see: insert through the service, throw after the write, assert the row is gone. If that test is hard to write, the design is wrong, not the test.
Takeaways
@Transactionalworks only when a Spring proxy intercepts the call.this.foo()does not count.- Default
REQUIREDjoins the caller’s transaction.REQUIRES_NEWis for work that must commit even if the use-case fails. - Unchecked exceptions roll back by default. Checked exceptions commit unless you set
rollbackFor. - Put one transaction on the use-case, not on every method that touches a repository.