While working through a code review exercise, I ran into code where @Transactional was present but two saves committed independently. An exception between saving a reservation and updating the slot state left half the changes applied. The annotation was clearly there. No errors, no warnings. The causes turned out to be two: the method was private, and it was being called from within the same class.

Spring’s @Transactional doesn’t work just because it’s declared. Spring wraps the bean in a proxy, and only calls entering through the proxy are intercepted to open a transaction. Outside that path, the annotation is silently ignored. I followed the official docs to understand why.

Proxy-Based AOP

Spring implements declarative transactions with aspect-oriented programming (AOP). When a bean carrying @Transactional is registered in the container, Spring wraps the original object in a proxy instead of exposing it directly. The code that begins, commits, and rolls back transactions is in the proxy, not in the annotation. When a caller invokes a method through the proxy, the proxy opens a transaction, runs the target method, then commits on normal return or rolls back on an exception.

sequenceDiagram
    participant Caller
    participant Proxy
    participant Target

    Caller->>Proxy: reserve()
    activate Proxy
    Note over Proxy: begin transaction
    Proxy->>Target: reserve()
    activate Target
    Target->>Target: this.saveAll()
bypasses the proxy Target-->>Proxy: return deactivate Target Note over Proxy: commit or rollback Proxy-->>Caller: return deactivate Proxy

Two proxying strategies are commonly used. If the target implements an interface, Spring builds an interface-based proxy with a JDK dynamic proxy; otherwise it uses CGLIB to generate a runtime subclass of the original class. Spring Boot uses class-based (CGLIB) proxies in its default configuration.

A lot runs on top of the transaction. Locks, JPA’s dirty checking, and the atomicity that groups two saves into one all depend on whether a transaction is actually open. So the first thing to check was whether the call went through the proxy.

Private Methods

CGLIB proxies are subclass-based. To insert transaction handling, the proxy must override the target method, and private methods cannot be overridden — a language-level constraint, not something Spring can work around.

@Service
public class ReservationService {

    public void reserve(String slotId, String userId) {
        // ... capacity validation
        saveAll(reservation, slot);
    }

    @Transactional // private methods cannot be overridden, so this is ignored
    private void saveAll(Reservation reservation, Slot slot) {
        reservationRepository.save(reservation);
        slotRepository.save(slot);
    }
}

As of Spring Framework 6.0, protected and package-visible methods are supported for class-based proxies. Private methods still aren’t. Interface-based proxies are stricter: the method must be public and declared on the proxied interface.

Self-Invocation

Making saveAll public looks like a fix, but the transaction still won’t open. reserve() calls saveAll() on this within the same class. The proxy is a separate object wrapping the target; once a call reaches the target object, an internal this.saveAll() goes straight to the target, not the proxy. The official docs call this self-invocation and state that proxy mode cannot intercept it.

It’s a call-path problem, not a visibility problem, so the fixes change the call path.

  • Move the transaction boundary to the entry point. Annotating reserve() opens the transaction from the proxied call onward. In the code I reviewed, this was the simplest fix.
  • Split the transactional method into a separate bean and inject it.
  • The docs’ fundamental recommendation is also refactoring to avoid self-invocation altogether.

Spring vs Jakarta @Transactional

IDE autocompletion offers two @Transactional annotations: org.springframework.transaction.annotation and jakarta.transaction. Spring supports the JTA (Jakarta Transactions) standard annotation as a drop-in replacement, so proxy handling works the same either way. The difference is in the attributes.

Attributejakarta.transaction.TransactionalSpring @Transactional
propagation6 TxType values7 Propagation values (adds NESTED)
isolationnot supportedsupported
timeoutnot supportedsupported
readOnlynot supportedsupported
rollback rulesrollbackOn / dontRollbackOnrollbackFor / noRollbackFor
transaction managernot supportedselectable via transactionManager

If you ever need isolation or timeout, the jakarta annotation has no way to express them. In a Spring application, Spring’s own annotation is the natural choice.

@DataJpaTest and the Test Transaction

The flawed code had tests, and they passed. @DataJpaTest wraps each test in a transaction and rolls it back at the end. The goal is isolation between tests, but there is a side effect: even with no transaction in the service, the test’s transaction groups every save. A commit-boundary defect never surfaces in this test.

Bean configuration is worth checking too. @DataJpaTest is a slice test that registers only repository-related beans. Instantiating the service with new means testing a bare object with no proxy. Registering the service with @Import and injecting it gives the same configuration as production.

@DataJpaTest
@Import(ReservationService.class) // test against the proxied bean
class ReservationServiceTest {

    @Autowired
    ReservationService reservationService;

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED) // drop the test transaction
    void rollsBackEverythingWhenASaveFails() {
        // ...
    }
}

To verify the commit/rollback boundary itself, the transaction wrapping the test has to be removed first. With @Transactional(propagation = Propagation.NOT_SUPPORTED), the test runs without one. The rollback safety net disappears with it, so the test has to clean up its own data.

Summary

@Transactional is a declaration; the proxy does the work. Whether a transaction opens is decided not by the annotation’s presence but by whether the call goes through the proxy. Private methods and self-invocation leave that path, and @DataJpaTest’s test transaction hides that they did. The code from the review was fixed by moving the annotation to the entry point — but seeing why that fix was needed took understanding the proxy.

Official docs referenced: Using @Transactional, Understanding AOP Proxies