Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 046 / 100

@Transactional pitfalls in Spring in Java & Spring Boot

The annotation that looks simple and bites everyone once.

Expona Admin6 min read

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.

java
@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.

java
@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.

java
@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.

java
@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.

java
@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.

java
@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:

java
@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.

java
@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:

java
@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 = true globally, you must explicitly set readOnly = false on 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 rollbackFor or 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 @Transactional reliably.
  • Test transactional behavior with integration tests that actually commit and roll back, not just unit tests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles