Skip to content

Why My @Transactional Method Wasn't Transactional: Self-Invocation

Published on
Reading time
5 mins read

I ran into this on a Spring Boot service. A method annotated with @Transactional failed halfway, and the rows it wrote before the failure were still in the database. The annotation was fine. The problem was how the method was called.

How @Transactional works

Spring doesn't change your class. It wraps the bean in a proxy (a CGLIB subclass by default in Spring Boot 2.x) and injects that proxy wherever the bean is used. When another bean calls a transactional method, the call hits the proxy first. The proxy's TransactionInterceptor starts a transaction, calls your real method, then commits or rolls back.

The catch is that once execution is inside your bean, this is the bean itself, not the proxy.

The self-invocation trap

OrderImportService.java
@Service
public class OrderImportService {

    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;

    // constructor omitted

    public void importAll(List<OrderRequest> requests) {
        for (OrderRequest request : requests) {
            importOne(request); // really this.importOne(request)
        }
    }

    @Transactional
    public void importOne(OrderRequest request) {
        orderRepository.save(Order.from(request));

        Stock stock = stockRepository.findBySku(request.getSku()).orElseThrow();
        stock.decrease(request.getQuantity()); // throws if there isn't enough stock
        stockRepository.save(stock);
    }
}

The controller calls importAll() on the proxy. That method has no annotation, so the proxy just passes the call through. Inside, importOne(request) is a plain Java call on this. The interceptor never sees it, so no transaction is started.

Nothing fails loudly, either. Spring Data's SimpleJpaRepository methods such as save() are transactional on their own, so each repository call commits in its own small transaction. When stock.decrease() throws, the order row is already committed.

The same thing happens with propagation = REQUIRES_NEW on a method called from its own class. It doesn't get its own transaction; it simply runs in whatever transaction the caller already has.

Two more ways to lose the transaction

Non-public methods. In proxy mode, only public methods are transactional. CGLIB can't override a private method at all, and Spring 5 ignores the annotation on protected and package-private methods. You get no error at startup and no transaction at runtime.

Checked exceptions. Even when the call goes through the proxy, Spring rolls back only on RuntimeException and Error by default:

@Transactional
public void importOne(OrderRequest request) throws InvalidOrderException {
    orderRepository.save(Order.from(request));
    validator.validate(request); // throws a checked InvalidOrderException
}

The exception reaches the caller, but the transaction commits and the order stays saved. If a checked exception should undo the work, say so with @Transactional(rollbackFor = InvalidOrderException.class), or use rollbackFor = Exception.class if every failure should roll back.

Check it, don't assume it

Two quick ways to see what is really happening:

log.debug("tx active: {}", TransactionSynchronizationManager.isActualTransactionActive());
application.properties
logging.level.org.springframework.transaction.interceptor=TRACE

With the trace log on, each call to a transactional method that goes through the proxy logs Getting transaction for [com.example.OrderImportService.importOne]. If that line never shows up, the call bypassed the proxy.

Fixes

1. Move the method to another bean

This is what Spring's own docs point to: refactor so the self-invocation doesn't happen.

@Service
public class OrderImporter {

    @Transactional
    public void importOne(OrderRequest request) {
        // same body as before
    }
}

@Service
public class OrderImportService {

    private final OrderImporter orderImporter;

    public OrderImportService(OrderImporter orderImporter) {
        this.orderImporter = orderImporter;
    }

    public void importAll(List<OrderRequest> requests) {
        requests.forEach(orderImporter::importOne); // goes through the proxy
    }
}

2. Use TransactionTemplate

If a new class feels artificial, make the boundary explicit. Spring Boot auto-configures a TransactionTemplate bean when there is a single transaction manager, so you can inject it:

public void importAll(List<OrderRequest> requests) {
    for (OrderRequest request : requests) {
        transactionTemplate.executeWithoutResult(status -> importOne(request));
    }
}

importOne no longer needs its annotation, and any runtime exception thrown inside the callback rolls the transaction back. executeWithoutResult was added in Spring 5.2; on older versions use execute with a TransactionCallbackWithoutResult.

3. Self-injection, as a last resort

@Autowired
private OrderImportService self; // the proxy, not this

Calling self.importOne(request) goes through the proxy. @Autowired has accepted self references since Spring 4.3, but this relies on Spring resolving a circular reference to the bean itself, and the reference docs call it a last resort. To me it's a sign that the class wants to be split.

4. AspectJ mode

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ) switches from proxies to AspectJ weaving: the transaction logic is woven into the class bytecode, so self-invocation and non-public methods work. The price is build setup: spring-aspects on the classpath plus compile-time weaving or a load-time weaving agent. That's a lot of machinery to fix one method.

Takeaways

  • @Transactional is applied by a proxy. Only calls that come in through the proxy get a transaction.
  • Calls on this never go through the proxy, and in Spring 5 only public methods are transactional.
  • Checked exceptions commit by default. Use rollbackFor when they shouldn't.
  • Verify with isActualTransactionActive() or the interceptor's trace log instead of trusting the annotation.
  • Prefer a separate bean or TransactionTemplate, and keep self-injection for emergencies.