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.
| Attribute | jakarta.transaction.Transactional | Spring @Transactional |
|---|---|---|
| propagation | 6 TxType values | 7 Propagation values (adds NESTED) |
| isolation | not supported | supported |
| timeout | not supported | supported |
| readOnly | not supported | supported |
| rollback rules | rollbackOn / dontRollbackOn | rollbackFor / noRollbackFor |
| transaction manager | not supported | selectable 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