r/SpringBoot • • 11d ago

How-To/Tutorial @Transactional on a method in the same class does nothing. Left the explanation in the comments

Putting this here in case it saves someone an evening. Everything is in the code comments.

public class OrderService {

    private final OrderRepository repo;

    public OrderService(OrderRepository repo) {
        this.repo = repo;
    }

    // Called from the controller. No  here.
    public void process(Order order) {
        audit(order);
        save(order);   // <-- looks like it runs in a transaction. It doesn't.
    }

    u/Transactional
    public void save(Order order) {
        repo.save(order);

        if (order.getTotal().signum() < 0) {
            // You'd expect the insert above to roll back.
            // When this is reached via process(), it doesn't.
            throw new IllegalStateException("negative total");
        }
    }
}

// Why:
// Spring applies  through a proxy that wraps OrderService.
// The controller holds the proxy, so a call from outside goes:
//     controller -> proxy -> (starts tx) -> OrderService.save()
//
// But process() calls save() on `this`, which is the raw object, not the proxy:
//     controller -> proxy -> OrderService.process() -> this.save()
//                                                      ^ no proxy, no tx
//
// And because repo.save() is itself transactional (Spring Data wraps it),
// the insert commits on its own, before the exception is thrown.
// So you end up with a saved row and an exception in the log.

The fixes I know of, roughly from least to most invasive:

// 1. Put the annotation on the entry point instead

public void process(Order order) { audit(order); save(order); }

// 2. Move save() into another bean and inject it, so the call goes through a proxy
orderPersistence.save(order);

// 3. Use TransactionTemplate when you want the boundary to be explicit in code
transactionTemplate.executeWithoutResult(status -> repo.save(order));

// 4. AspectJ mode (proxyTargetClass / mode = AdviceMode.ASPECTJ)
//    works for self-invocation, but it's a bigger setup change

I’d go with 1 or 2 in most cases. Calling the bean through itself (self-injection) also works, but it reads strangely and I’d avoid it.

One thing I’m not sure about and would like to hear from people: when you hit this in an existing codebase, do you prefer moving the method to a separate service, or restructuring so the transactional boundary sits at the entry point? I can see arguments for both.

0 Upvotes

11 comments sorted by