This content originally appeared on DEV Community and was authored by closeup1202
We've all been there. You add @Transactional to a method, expecting it to magically handle database transactions. Then production hits, and suddenly transactions aren't working as expected. No errors, no warnings—just silent failures.
After analyzing thousands of Spring codebases and building an IDE plugin for transaction inspection, I've identified 8 critical anti-patterns that repeatedly break production systems. Each one is silent, each one compiles fine, and each one can destroy your data integrity.
Let me show you each one and—more importantly—how to catch them automatically before they reach production.
1. The Silent Killer: Same-Class Method Calls
This is the #1 cause of transaction failures, and it's completely silent.
@Service
public class UserService {
@Transactional
public void updateUser(Long id) {
// This works fine
User user = findById(id);
user.setName("Updated");
}
@Transactional
public User findById(Long id) {
// ❌ This @Transactional is IGNORED!
return userRepository.findById(id);
}
}
Why it fails: Spring uses AOP proxies. When you call findById() from within the same class, you're calling the actual method—not the proxy. No proxy = no transaction.
The fix:
@Service
public class UserService {
@Autowired
private UserService self; // Inject self-reference
@Transactional
public void updateUser(Long id) {
User user = self.findById(id); // ✅ Call through proxy
user.setName("Updated");
}
}
2. The Private Method Trap
@Service
public class OrderService {
@Transactional // ❌ This does NOTHING
private void processOrder(Order order) {
orderRepository.save(order);
}
}
Why it fails: Spring AOP proxies can't intercept private methods. The annotation is simply ignored.
The fix: Make it public or protected.
3. The Final Method Problem
@Service
public class PaymentService {
@Transactional // ❌ Won't work with CGLIB proxies
public final void processPayment(Payment payment) {
paymentRepository.save(payment);
}
}
Why it fails: CGLIB (Spring's default proxy mechanism) can't override final methods. The proxy subclasses your bean and overrides methods to add transactional behavior, but final methods can't be overridden—so the transaction is never applied.
🧠 Important caveat: If your bean implements an interface, Spring may use a JDK Dynamic Proxy instead of CGLIB. JDK proxies don't have this limitation because they work through the interface contract, not subclassing. So if PaymentService implements IPaymentService, the final method would still be called through the proxy—though it's still bad practice to use final on interface implementation methods.
4. The Checked Exception Surprise
This one is sneaky:
@Transactional
public void importUsers(File file) throws IOException {
List<User> users = parseFile(file); // throws IOException
userRepository.saveAll(users);
}
What happens: If parseFile() throws IOException, the transaction DOESN'T rollback by default!
Why: Spring only rolls back on unchecked exceptions (RuntimeException). Checked exceptions don't trigger rollback.
The fix:
@Transactional(rollbackFor = IOException.class)
public void importUsers(File file) throws IOException {
// Now it rolls back on IOException ✅
}
5. The @async + @Transactional Conflict
@Async
@Transactional // ❌ These two don't play well together
public void sendEmailAsync(Long userId) {
User user = userRepository.findById(userId);
emailService.send(user.getEmail());
}
Why it fails: @Async runs in a different thread. The transaction context doesn't propagate to the async thread, causing LazyInitializationException or no transaction at all.
The fix: Separate concerns or use @TransactionalEventListener.
6. Writing in readOnly Transactions
@Transactional(readOnly = true)
public void updateUserStats(Long userId) {
User user = userRepository.findById(userId);
user.setLoginCount(user.getLoginCount() + 1);
userRepository.save(user); // ❌ Logical error!
}
What happens: readOnly = true is a hint to the persistence provider (like Hibernate). It doesn't prevent writes at the database level—it just tells Hibernate to skip dirty checking and flushing.
The unpredictable behavior:
- Hibernate: May skip the flush, so updates silently don't get persisted. Your object changes, but the database doesn't.
-
Some JPA providers: Throw an exception when you try to call
save()in a read-only transaction. - Others: Allow the write but don't flush changes to the database.
The real danger: Your code "works" locally because Hibernate usually flushes anyway, but in production with different configuration or database pooling, writes mysteriously disappear.
The fix: Don't use readOnly = true as an enforcement mechanism. Use it only for actual read operations:
@Transactional(readOnly = true)
public User getUserStats(Long userId) {
return userRepository.findById(userId); // ✅ Safe and clear
}
@Transactional // Remove readOnly or use a separate method
public void updateUserStats(Long userId) {
User user = userRepository.findById(userId);
user.setLoginCount(user.getLoginCount() + 1);
userRepository.save(user);
}
7. The N+1 Query Time Bomb
@Transactional
public void processOrders() {
List<Order> orders = orderRepository.findAll();
orders.forEach(order -> {
// ❌ This triggers a separate query for EACH order
Customer customer = order.getCustomer(); // LAZY by default
System.out.println(customer.getName());
});
}
What happens: If you have 100 orders, this runs 101 queries (1 for orders + 100 for customers). This includes not just @OneToMany and @ManyToMany, but also single-entity relationships like @ManyToOne(fetch = LAZY) and @OneToOne(fetch = LAZY).
The fix: Use @EntityGraph, JOIN FETCH, or batch loading strategies.
8. The Propagation Conflict Nightmare
This one hits you at runtime with cryptic exceptions:
@Service
public class PaymentService {
@Autowired
private TransferService transferService;
@Transactional(propagation = Propagation.MANDATORY)
public void transfer(Long from, Long to, BigDecimal amount) {
transferService.executeTransfer(from, to, amount);
}
}
@Service
public class TransferService {
@Transactional // ❌ Called without active transaction
public void executeTransfer(Long from, Long to, BigDecimal amount) {
accountRepository.debit(from, amount);
accountRepository.credit(to, amount);
}
}
Why it fails: propagation = MANDATORY means "this method MUST be called within an existing transaction." If called without one, Spring throws IllegalTransactionStateException. Similar disasters happen with:
-
NEVER- If you call aNEVERmethod from within a transaction, it explodes -
REQUIRES_NEW- Creates independent transactions, risking data inconsistency
The fix: Ensure calling methods have appropriate @Transactional context, or adjust propagation mode to match the calling context.
The Problem: IDEs Don't Warn You
The biggest issue? Your IDE doesn't catch any of these mistakes. They all compile fine. Tests might even pass. Then production breaks.
After spending too many hours debugging these issues across different projects, I built an IntelliJ IDEA plugin that detects all 8 anti-patterns automatically—right in your editor, as you type.
Introducing: Spring Transaction Inspector
What it does:
- ✅ 8 comprehensive inspections for Spring transaction anti-patterns
- ✅ Real-time detection as you type in your editor
- ✅ Smart quick-fixes for each issue (auto-add rollbackFor, change visibility, etc.)
- ✅ Gutter icons with visual indicators for transaction boundaries
- ✅ Customizable settings - enable/disable any inspection individually
- ✅ Type-based detection - 95%+ accuracy using repository interface verification
- ✅ Multi-JPA support - Works with javax.persistence (JPA 2.x) and jakarta.persistence (JPA 3.0+)
Example in Action
When you write code with transaction issues:
@Transactional(propagation = Propagation.MANDATORY)
public void criticalOperation() {
// ⚠️ Plugin warning: "Method requires an active transaction"
}
You get:
- 🔴 Clear warning highlighting
- 💡 Quick-fix suggestions
- 📋 Detailed explanation of the issue
- ✨ One-click fixes for most common issues
The 8 Inspections
| # | Inspection | Severity | Quick Fix |
|---|---|---|---|
| 1 | Same-class @Transactional calls | ⚠️ WARNING | Suppress only (requires refactoring) |
| 2 | Private/final/static methods | ⚠️ WARNING | Change visibility, remove modifier |
| 3 | N+1 Query Detection | ⚠️ WARNING | Informational (use Fetch-Join or @EntityGraph) |
| 4 | Write in read-only transaction | ⚠️ WARNING | Remove readOnly=true |
| 5 | Checked exceptions | ⚠️ WARNING | Add rollbackFor attribute |
| 6 | @async + @Transactional | 🔴 ERROR | Remove conflicting annotation |
| 7 | Read-only calling write methods | ⚠️ WARNING | Change to REQUIRES_NEW propagation |
| 8 | Propagation conflicts | 🔴 ERROR | Add @Transactional to caller |
Installation
Via JetBrains Marketplace (Easiest)
- Open IntelliJ IDEA (Community or Ultimate)
- Go to
Settings→Plugins→Marketplace - Search for "Spring Transaction Inspector"
- Click
Install
Manual Installation
Install from JetBrains Marketplace
Configuration
After installation, go to Settings → Tools → Spring Transaction Inspector to:
- Enable/disable individual inspections
- Toggle N+1 detection in loops vs streams
- Show/hide gutter icons
- Customize inspection severity levels
Open Source & MIT Licensed
The plugin is completely open source and maintained on GitHub:
- 📦 GitHub Repository
- 🐛 Report Issues & Request Features
- ⭐ Star if you find it useful!
Real Impact
This plugin has caught transaction bugs before they reached production:
- Prevented silent transaction failures
- Caught lazy loading exceptions before they happen
- Detected data consistency issues that would cost hours to debug
- Identified propagation conflicts that would cause runtime exceptions
For Spring Boot Developers
If you're working with:
- Spring Data JPA
- Spring Boot 2.x / 3.x
- Hibernate or JPA
- Complex transaction logic
...then this plugin is essential. It essentially gives you a transaction expert reviewing every line of code as you type.
What transaction issues have you encountered in production? Drop a comment below—I might add them to the plugin!
If this saves you even one hour of debugging, it's worth the install. Try it today and protect your data integrity! 🚀
This content originally appeared on DEV Community and was authored by closeup1202
closeup1202 | Sciencx (2025-10-23T13:59:48+00:00) 8 Spring @Transactional Pitfalls That Break Production (And How to Catch Them All). Retrieved from https://www.scien.cx/2025/10/23/8-spring-transactional-pitfalls-that-break-production-and-how-to-catch-them-all-2/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.
