Spring @Transactional is Not Magic: The Proxy Trap and Why Your Data Isn’t Safe

This post breaks down the reality of Spring’s transaction management. After 15 years of seeing the same production fires, it is clear that most developers treat @Transactional as a “set and forget” magic trick rather than a sophisticated AOP proxy.

The Proxy Trap: Why Self-Invocation Breaks Spring Transactions

I see this in almost every senior-level PR review. A developer adds @Transactional to a private or internal method and wonders why the data didn’t roll back. Spring transaction management is built on Java AOP proxies, not bytecode manipulation.

When an external bean calls your service, it interacts with a proxy. The proxy starts the transaction, calls your method, and then commits. However, when Method A calls Method B within the same class, the proxy is bypassed entirely.

@Service
public class OrderService {

    public void processOrder(Order order) {
        saveOrder(order);
    }

    @Transactional
    public void saveOrder(Order order) {
        orderRepository.save(order);
        paymentService.charge(order);
    }
}

In the example above, calling processOrder results in zero transaction management for saveOrder. The call uses the this reference, hitting the raw object instead of the proxy. You are effectively running in auto-commit mode. If the payment fails, the order remains in the database, creating a “zombie” record that was never paid for.

The Fix: You must either move the transactional logic to a separate bean or ensure the entry-point method itself is marked as @Transactional.


The Rollback Myth: Handling Checked Exceptions in Spring

By default, Spring only rolls back on unchecked exceptions (RuntimeException and Error). If your business logic throws a checked Exception, Spring assumes you have handled it and will commit the transaction anyway.

@Transactional
public void processPayment(Long orderId) throws InsufficientFundsException {
    Order order = repository.findById(orderId).orElseThrow();
    order.setStatus(Status.PROCESSING);
    repository.save(order);

    if (gateway.getBalance() < order.getAmount()) {
        throw new InsufficientFundsException();
    }
}

In this scenario, the order status is updated to PROCESSING in the database even though the InsufficientFundsException was thrown. The transaction manager sees a checked exception and proceeds to commit.

The Fix: You must be explicit about which exceptions should trigger a rollback. Use @Transactional(rollbackFor = Exception.class). Better yet, stop using checked exceptions for flow control in modern Java. They complicate the signature and break functional abstractions.


Propagation and Connection Exhaustion: The Hidden Costs of REQUIRES_NEW

Developers often use Propagation.REQUIRES_NEW to “sandbox” a task, such as logging a failure to the database even if the main transaction rolls back. This comes with a heavy architectural price.

When you use REQUIRES_NEW, Spring suspends the current transaction and opens a second database connection.

ProblemTechnical Impact
Connection HungerOne request now consumes two connections. Under high load, this leads to connection pool exhaustion.
Deadlock RiskIf the first transaction holds a lock that the second transaction needs, your thread will hang until a timeout occurs.
Resource LeakageImproperly managed nested transactions can keep connections open longer than necessary, degrading overall throughput.

The “No Transaction” Scenario:

If you avoid the annotation entirely and call save() on a repository ten times in a loop, you are opening and closing ten separate transactions. This is a performance disaster. Grouping these operations under a single @Transactional scope reduces the overhead of connection acquisition and release.


The Psychology of Corrupted Data: Why Patterns Fail

We spend too much time on “Clean Architecture” and not enough time on the database. Your Java code is ephemeral, but your data is permanent. We treat the database as a “dumb bucket” and hope Spring handles the complexity.

Hope is not a strategy. Most bad transaction code stems from:

  1. Resume-Driven Development: Over-engineering layers so much that the transactional boundaries become invisible.
  2. Cognitive Overload: Focusing on the 50 microservices in the cluster while forgetting how the local proxy works.

If you cannot manage a local transaction correctly, you have no business attempting a distributed Saga or Outbox pattern. Eventual consistency is not a license to write sloppy local code.


Actionable Exit: The Monday Morning Roadmap

Stop guessing if your transactions are working. Follow these steps to audit your codebase:

  • Audit for Self-Invocation: Scan your services for internal calls to @Transactional methods. Refactor these into separate components.
  • Enforce Rollback Rules: Standardize on RuntimeException or ensure rollbackFor = Exception.class is used globally.
  • Enable Read-Only Optimization: Use @Transactional(readOnly = true) for all query methods. This allows Hibernate to skip dirty checking, reducing memory consumption.
  • Validate via Logs: Turn on trace logging in your development environment to see exactly when transactions begin and end.
logging:
  level:
    org.springframework.transaction.interceptor: TRACE


Never Miss Another Tech Innovation

Concrete insights and actionable resources delivered straight to your inbox to boost your developer career.

My New ebook, Best Practices To Create A Backend With Spring Boot 3, is available now.

Best practices to create a backend with Spring Boot 3

Leave a comment