You've added @Transactional to your method, committed the code, and watched your database enter chaos. The annotation that looks deceptively simple—just one decorator—hides a minefield of gotchas that catch senior engineers off guard. Understanding the @Transactional pitfalls in Spring in Java & Spring Boot is the difference between reliable data integrity and production incidents at 2 AM.
Self-invocation skips the proxy — no transaction
Spring's @Transactional works through proxies. When you call a method directly from the same class, you're bypassing the proxy entirely, which means the transaction handling code never runs. This is one of the most subtle traps because your code compiles, runs, and fails silently.
Consider this scenario: you have a service that needs to save a user and then notify them asynchronously. You've marked both methods with @Transactional, but the save method calls the notification method directly.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private NotificationService notificationService;
@Transactional
public void registerUser(User user) {
userRepository.save(user);
// This call does NOT use the proxy
notificationService.sendWelcomeEmail(user);
}
}
@Service
public class NotificationService {
@Transactional
public void sendWelcomeEmail(User user) {
// This runs outside any transaction context
// If this fails halfway, the user is already committed
}
}The @Transactional on sendWelcomeEmail is ignored because the call comes from within the same class. The proxy is only invoked when called from outside the class through Spring's container. To fix this, inject the service into itself or extract the transactional logic into a separate class.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private NotificationService notificationService;
@Transactional
public void registerUser(User user) {
userRepository.save(user);
// Call through the injected instance uses the proxy
notificationService.sendWelcomeEmail(user);
}
}This works because notificationService is injected, and Spring gives you the proxy instance, not the raw object.
Only public methods are proxied by default
Spring proxies only public methods by default. If you mark a protected or private method with @Transactional, Spring ignores it. This creates a false sense of security—the code looks correct, but transactions aren't being managed.
We've seen this happen in refactoring: a developer extracts a private helper method and adds @Transactional to protect a critical operation, not realizing it has no effect.
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public void processOrder(Order order) {
validateOrder(order);
saveOrder(order);
}
@Transactional
private void saveOrder(Order order) {
// This annotation does NOTHING
// The method has no transaction context
orderRepository.save(order);
}
private void validateOrder(Order order) {
if (order.getTotal() < 0) {
throw new IllegalArgumentException("Invalid order");
}
}
}The solution is to make transactional methods public or use a separate service class with public methods. If you're trying to protect a helper, it signals that your transaction boundary might be in the wrong place anyway.
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public void processOrder(Order order) {
validateOrder(order);
persistOrder(order);
}
@Transactional
public void persistOrder(Order order) {
// Now this is properly transactional
orderRepository.save(order);
}
private void validateOrder(Order order) {
if (order.getTotal() < 0) {
throw new IllegalArgumentException("Invalid order");
}
}
}Checked exceptions do not roll back by default
By default, Spring only rolls back for unchecked exceptions (RuntimeException and subclasses). If your transactional method throws a checked exception—even after modifying data—the transaction commits. This breaks the all-or-nothing contract you expect from transactions.
Picture this: you're processing payments and your method throws IOException when writing to an audit log. The payment is already committed to the database before the exception occurs.
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Autowired
private AuditLogger auditLogger;
@Transactional
public void processPayment(Payment payment) throws IOException {
payment.setStatus("COMPLETED");
paymentRepository.save(payment);
// If this throws IOException, the payment is already committed
auditLogger.log(payment);
}
}Use rollbackFor to explicitly handle checked exceptions, or better yet, wrap them in unchecked exceptions.
@Service
public class PaymentService {
@Autowired
private PaymentRepository paymentRepository;
@Autowired
private AuditLogger auditLogger;
@Transactional(rollbackFor = IOException.class)
public void processPayment(Payment payment) throws IOException {
payment.setStatus("COMPLETED");
paymentRepository.save(payment);
auditLogger.log(payment);
}
}Or convert to an unchecked exception:
@Transactional
public void processPayment(Payment payment) {
payment.setStatus("COMPLETED");
paymentRepository.save(payment);
try {
auditLogger.log(payment);
} catch (IOException e) {
throw new RuntimeException("Failed to audit payment", e);
}
}Keep transactions short — no HTTP calls inside
Transactions lock database resources. Every millisecond your transaction holds a lock, you risk contention and deadlocks. HTTP calls to external services can take seconds or fail unpredictably. Keeping these outside your transactional boundary is not just good practice—it's essential for system stability.
We've debugged production incidents where a transactional method called an external API that had degraded performance. The database connections pooled up, other queries starved, and the entire system froze.
@Service
public class InventoryService {
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private PricingClient pricingClient;
@Transactional
public void reserveItem(String itemId) {
Item item = inventoryRepository.findById(itemId).orElseThrow();
item.setReserved(true);
inventoryRepository.save(item);
// BAD: HTTP call inside transaction
PriceData priceData = pricingClient.getCurrentPrice(itemId);
item.setCurrentPrice(priceData.getPrice());
inventoryRepository.save(item);
}
}Extract the HTTP call outside the transaction:
@Service
public class InventoryService {
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private PricingClient pricingClient;
public void reserveItem(String itemId) {
// Get price BEFORE the transaction starts
PriceData priceData = pricingClient.getCurrentPrice(itemId);
// Now the fast database operation in a tight transaction
persistReservation(itemId, priceData);
}
@Transactional
private void persistReservation(String itemId, PriceData priceData) {
Item item = inventoryRepository.findById(itemId).orElseThrow();
item.setReserved(true);
item.setCurrentPrice(priceData.getPrice());
inventoryRepository.save(item);
}
}This pattern keeps your database transaction window small and predictable. If the external call fails, no database lock is held.
Common mistakes
- Catching and suppressing exceptions — If you catch an exception inside a transactional method and don't re-throw it, Spring won't roll back. The transaction marks as successful.
- Assuming @Transactional propagates to child calls — Each transactional method needs its own proxy boundary. Calling one transactional method from another doesn't automatically nest transactions the way you might expect.
- Using @Transactional on @Scheduled methods — Scheduled tasks run in their own threads outside the normal request cycle. Transactions often fail silently because database connections aren't available in the thread pool.
- Forgetting that @Transactional is read-only by default in some configurations — If you set
readOnly = trueglobally, you must explicitly setreadOnly = falseon write operations, or inserts and updates fail. - Testing without actual transaction boundaries — Spring test frameworks can mask transactional issues. Methods that fail in production might pass in tests because test transactions behave differently.
Key takeaways
- Spring uses proxies for
@Transactional; self-invocation and non-public methods bypass the proxy entirely. - Checked exceptions don't trigger rollback by default—use
rollbackForor convert to unchecked exceptions. - Keep transactions as short as possible; external calls and I/O belong outside transactional boundaries.
- Only public methods on Spring-managed beans can use
@Transactionalreliably. - Test transactional behavior with integration tests that actually commit and roll back, not just unit tests.






Comments
Loading comments…