Ch. 8 · Spring Boot

Spring Transactions and Proxy Invocation

Why @Transactional is ignored on self-invocation and private methods in Spring Boot, how the proxy works, and four ways to fix it.

~7 min readintermediateupdated Oct 6, 2026

“Why doesn’t @Transactional work when I call the method from the same class?” This is one of the most frequently asked Spring interview questions, because it tests whether you know how Spring applies annotations at all. The same mechanism explains why @Async, @Cacheable, @Retryable and method security behave the same way. A candidate who can draw the proxy and explain where the call goes has answered half a dozen future questions in one go.

Before you start

You should know what a Spring bean is, what @Transactional is meant to do, and the basic idea of the proxy design pattern. Knowing the difference between JDK dynamic proxies (interface based) and CGLIB proxies (subclass based) helps. Examples target Spring Boot 3.x, which uses CGLIB class proxies by default (spring.aop.proxy-target-class=true).

The short answer

Spring does not modify your class. For beans with @Transactional methods, it registers a proxy in place of the bean. Callers receive the proxy; when they call a transactional method, the proxy’s interceptor begins a transaction, calls your real object, then commits or rolls back. A call from one method to another inside the same object uses this, which is the real object, not the proxy, so no interceptor runs and the annotation on the second method is ignored. Private methods, and by default methods that are not public, are not intercepted either. The fixes are to move the transactional method into another bean, use TransactionTemplate, inject the proxy of the bean into itself lazily, or use AspectJ weaving.

How it works

Conceptually, the CGLIB proxy is a generated subclass that delegates to your real instance:

// What Spring generates, simplified
class OrderService$$SpringCGLIB$$0 extends OrderService {
    private final OrderService target;          // your real bean
    private final TransactionInterceptor tx;

    @Override
    public void placeOrder(Order order) {
        tx.invoke(() -> target.placeOrder(order));   // begin, call, commit or roll back
    }
}
java

Now the problem case:

@Service
public class OrderService {

    public void importOrders(List<Order> batch) {
        for (Order order : batch) {
            placeOrder(order);            // this.placeOrder(order): goes to the target, not the proxy
        }
    }

    @Transactional
    public void placeOrder(Order order) {
        orders.save(order);
        stock.decrement(order.sku(), order.quantity());
    }
}
java

A controller calls orderService.importOrders(batch). That call goes through the proxy, but importOrders has no annotation, so no transaction starts. Inside the real object, placeOrder(order) is this.placeOrder(order), and this is the target. The transaction interceptor never sees the call. Each repository call runs in its own transaction (Spring Data repository methods are transactional themselves), so if stock.decrement fails, the saved order stays.

The same applies to:

  • Private methods. CGLIB cannot override them, so there is nothing to intercept. Spring 6 can intercept protected and package-private methods on class-based proxies, but public methods remain the safe, conventional choice.
  • final methods and classes. Subclass proxies cannot override them.
  • Objects created with new. They are not beans and have no proxy.

Step-by-step walkthrough

Step 1: Confirm the transaction is not active

Before changing code, prove the diagnosis. Inside the method, check:

log.debug("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
java

Or enable logging.level.org.springframework.transaction.interceptor=trace, which logs “Getting transaction for [OrderService.placeOrder]” each time the interceptor runs. No log line means no interception.

Step 2: Move the transactional method to another bean (preferred)

@Service
public class OrderImportService {
    private final OrderPlacement placement;
    public OrderImportService(OrderPlacement placement) { this.placement = placement; }

    public ImportReport importOrders(List<Order> batch) {
        ImportReport report = new ImportReport();
        for (Order order : batch) {
            try {
                placement.place(order);          // through the proxy: one transaction per order
                report.succeeded(order);
            } catch (RuntimeException ex) {
                report.failed(order, ex);
            }
        }
        return report;
    }
}

@Service
public class OrderPlacement {
    @Transactional
    public void place(Order order) {
        orders.save(order);
        stock.decrement(order.sku(), order.quantity());
    }
}
java

The split often improves the design: batch orchestration and the unit of work are different responsibilities.

Step 3: Use TransactionTemplate for local control

When the transactional block is small and belongs in the same class, programmatic transactions avoid the proxy entirely:

@Service
public class OrderService {
    private final TransactionTemplate tx;

    public OrderService(PlatformTransactionManager txManager) {
        this.tx = new TransactionTemplate(txManager);
    }

    public void importOrders(List<Order> batch) {
        for (Order order : batch) {
            tx.executeWithoutResult(status -> {
                orders.save(order);
                stock.decrement(order.sku(), order.quantity());
            });
        }
    }
}
java

The transaction boundary is visible in the code, which some teams prefer for exactly this reason.

Step 4: Know the less preferred options

Self-injection with @Lazy (public OrderService(@Lazy OrderService self)) gives the class a reference to its own proxy, so self.placeOrder(order) is intercepted. It works but hides the issue and couples the class to its proxy. AopContext.currentProxy() requires exposeProxy = true and has the same smell. AspectJ compile-time or load-time weaving modifies bytecode so self-calls are advised, at the cost of build complexity. Mention these in an interview, but recommend Step 2 or Step 3.

Worked scenario

A team adds @Transactional to a private helper that updates both an account balance and a ledger entry:

@Service
public class TransferService {

    public void transfer(long from, long to, BigDecimal amount) {
        validate(from, to, amount);
        applyTransfer(from, to, amount);
    }

    @Transactional
    private void applyTransfer(long from, long to, BigDecimal amount) {
        accounts.debit(from, amount);
        ledger.append(from, to, amount);   // throws on a constraint violation
        accounts.credit(to, amount);
    }
}
java

Two problems combine: the method is private, and it is self-invoked. Neither call is intercepted. IntelliJ warns about the private method, but the warning was ignored. In production, a ledger constraint violation leaves debits without credits. Reconciliation finds thousands of euros missing from customer views.

The fix moves @Transactional to the public entry point, which is called through the proxy from the controller:

@Transactional
public void transfer(long from, long to, BigDecimal amount) {
    validate(from, to, amount);
    accounts.debit(from, amount);
    ledger.append(from, to, amount);
    accounts.credit(to, amount);
}
java

A test asserting that a ledger failure leaves both balances unchanged is added (see below). The same review searches the codebase for @Transactional on private methods and on methods only called internally.

Common mistake

  • “Spring adds transaction code to my class.” It wraps the bean in a proxy; your class is unchanged.
  • Annotating private methods. Never intercepted with proxies.
  • Calling an annotated method through this. Bypasses the proxy, whether the annotation is @Transactional, @Async, @Cacheable or @PreAuthorize.
  • Thinking REQUIRES_NEW on a self-invoked method creates a new transaction. It does nothing, because the interceptor never runs. See rollback rules for what happens when propagation does apply.
  • Expecting @Transactional on objects created with new. Same root cause as an @Autowired field being null: Spring never saw the object.
  • Self-injection to “fix” it without @Lazy. Spring Boot prohibits circular references by default, so a plain self-reference fails at startup; see circular dependency.

Verify the behavior

@SpringBootTest
class TransferAtomicityTest {

    @Autowired TransferService transfers;
    @Autowired AccountRepository accounts;
    @MockitoBean LedgerRepository ledger;

    @Test
    void ledgerFailureRollsBackDebit() {
        long from = accounts.save(new Account(new BigDecimal("100"))).getId();
        long to = accounts.save(new Account(BigDecimal.ZERO)).getId();
        doThrow(new DataIntegrityViolationException("dup")).when(ledger).append(anyLong(), anyLong(), any());

        assertThatThrownBy(() -> transfers.transfer(from, to, new BigDecimal("30")))
                .isInstanceOf(DataIntegrityViolationException.class);

        assertThat(accounts.findById(from).orElseThrow().balance()).isEqualByComparingTo("100");
    }

    @Test
    void beanIsProxied() {
        assertThat(AopUtils.isAopProxy(transfers)).isTrue();
    }
}
java

With the original private self-invoked method, the first test fails with a balance of 70. The test class must not be @Transactional, or the test’s own transaction would mask the bug.

Follow-up questions

JDK proxy or CGLIB? Boot defaults to CGLIB so beans can be injected by class. JDK proxies implement the bean’s interfaces only, so injecting the concrete class would fail.

Why can’t CGLIB proxy final classes? It creates a subclass, and final classes cannot be extended. Kotlin classes are final by default, which is why the kotlin-spring plugin opens them.

Does @Transactional on an interface work? It works with interface-based proxies and is recognized on class proxies in recent Spring versions, but annotating the concrete class is clearer.

What other annotations share this limitation? @Async, @Cacheable, @CacheEvict, @Retryable, @PreAuthorize and @Validated method validation; all are proxy-based by default.

Interview exercise

A service has a public processAll() that loops over 1,000 records and calls a public @Transactional(propagation = REQUIRES_NEW) processOne(record) method in the same class. The intent is that one failed record does not roll back the others. In testing, a failure on record 500 seems to roll back nothing and commit partial changes within record 500. Explain and fix it.

Answer and reasoning

processOne is called through this, so the proxy never intercepts it and REQUIRES_NEW has no effect. With no transaction around processOne, each repository call commits on its own, so record 500’s first writes commit while its later writes never happen: partial changes. Records 1 to 499 are unaffected, which is why “rolls back nothing” looked almost correct. Fix by moving processOne into a separate RecordProcessor bean and calling it through the injected proxy, catching exceptions per record in processAll; or wrap each iteration in TransactionTemplate with PROPAGATION_REQUIRES_NEW. Then each record is atomic and independent. The reasoning: annotations only apply when the call passes through the proxy, so check the call path before trusting the annotation.

Continue learning

More in Spring Boot

read ✓Spring Boot · mid

Spring Transaction Duration and Remote Calls

Why long @Transactional methods exhaust the Hikari pool and hold locks, and how to keep remote calls, files and email outside the transaction.

~7 min readread →
esc