The bug showed up on a Wednesday afternoon. An order would go through, payment would process, but the inventory wouldn't update. No exception in the logs. No rollback. Just partial writes sitting in the database like they belonged there.
It took three hours to find it. The answer was embarrassing: a @Transactional service method was calling another @Transactional method on the same class. To most developers that looks fine, maybe even redundant. To Spring, the inner annotation is completely invisible.
Here's the thing about @Transactional: it works through a proxy. When Spring sees the annotation, it wraps your bean in a generated wrapper class that intercepts method calls and manages the transaction around them. That interception only happens when calls come from outside the class. When a method calls another method on this, it bypasses the proxy entirely. The inner method runs, but Spring has no idea it's happening. No transaction starts. Whatever you thought was being protected isn't.
That was my bug. The outer method opened a transaction. The inner method was supposed to open its own. It didn't. Half the writes committed. The other half never ran inside a transaction at all.
Self-invocation is the most common footgun, but it's not the only one. There's also the exception type problem. By default, Spring only rolls back a transaction when an unchecked exception bubbles up. A RuntimeException triggers a rollback. A checked exception, something you declared with throws IOException for example, will not. The transaction commits. If you catch and swallow a checked exception inside a @Transactional method, the transaction commits whatever ran before the exception. I've seen this cause data integrity issues that were nearly impossible to reproduce in testing because the test environment didn't trigger the exact exception path.
Then there's the annotation on private methods. If you put @Transactional on a private method, Spring quietly ignores it. A proxy can't override a private method, so the annotation does nothing. No warning. No error. The code looks like it has transaction coverage and it doesn't.
The last one is subtler. Not a bug exactly, a design problem. @Transactional on a method that does a lot of things, database writes, an HTTP call to a third-party service, maybe some file I/O, holds an open database transaction for the entire duration. That transaction holds locks. The longer it stays open, the longer other operations wait. I've watched teams annotate an entire service layer method without thinking about what that means for connection pool contention under load.
All of these problems share a common root. The annotation makes transaction boundaries invisible. You write @Transactional at the top of a method and mentally offload the responsibility to the framework. The framework handles it. Except it doesn't, not always, and the cases where it doesn't often show up in production under conditions that are hard to replicate locally.
The annotation is genuinely useful. I'm not arguing you should stop using it. My argument is that it trains a kind of learned helplessness about transaction management. When you can't answer where does this transaction start, where does it end, and what happens if the method throws halfway through, you don't have transaction safety. You have the appearance of it.
Transaction boundaries are a design decision. They belong in your head before they belong in your annotations. Which operations need to be atomic? What's the blast radius if a rollback happens here? Is this transaction scope too wide? Those are questions worth asking before you add the annotation, not after a data integrity incident forces you to.
Proxy mechanism, exception type rules, private method behavior. None of these are obscure edge cases. They're fundamental to how the feature works. They just don't come up until they bite you.
Comments (0)