Skip to content
QSWEQB
mediumConceptScenarioMidSenior

Why does @Transactional sometimes do nothing at all, even though the annotation is right there?

Spring applies @Transactional through a proxy that wraps the bean. Calling the annotated method from another method of the same class goes through `this`, not the proxy, so the advice never runs and no transaction is started. The same silence affects @Cacheable, @Async and @Retryable.

4 min readUpdated 2026-07-26Asked at Amazon, Oracle, SAP

What the interviewer is scoring

  • Whether the explanation names the proxy as the mechanism rather than blaming configuration
  • That the candidate can place proxy creation in the bean lifecycle
  • Does the candidate reach for extracting a collaborator before reaching for AopContext
  • Whether they know the other annotations with the same failure mode
  • Can they say why constructor injection is the default recommendation without reciting "it is best practice"

Answer

Injection is a wiring decision

Dependency injection in Spring means the container instantiates your collaborators and hands them over, so a bean declares what it needs and never looks anything up. The container reads those needs from the constructor signature, from @Autowired fields, or from setters, and resolves each one by type, disambiguating with @Qualifier or @Primary when more than one candidate matches.

Constructor injection is the recommendation for reasons that are mechanical rather than aesthetic. The dependencies become final, so the object cannot exist in a partly wired state. A missing dependency fails at context startup instead of at first use. The class is constructible in a test without a container or reflection. And a constructor with eight parameters is visibly uncomfortable in a way that eight annotated fields are not, which makes it a design signal you cannot ignore. Since Spring 4.3, a class with a single constructor does not need @Autowired at all.

Field injection also makes circular dependencies possible, which is why they are common in older codebases. Spring can satisfy a cycle through fields by injecting a half-built bean; it cannot do so through constructors, because neither object can be created first. Spring Boot has rejected circular references by default since 2.6 rather than continuing to accommodate them.

The lifecycle, in order

Knowing the order matters because the answer to the main question lives inside it. For a singleton bean, the container:

  • instantiates the bean, calling the constructor and so performing constructor injection;
  • populates the remaining properties, performing field and setter injection;
  • invokes the *Aware callbacks, such as BeanNameAware and ApplicationContextAware;
  • calls postProcessBeforeInitialization on every registered BeanPostProcessor;
  • runs @PostConstruct, then afterPropertiesSet if the bean implements InitializingBean, then any configured init method;
  • calls postProcessAfterInitialization on every BeanPostProcessorthis is where the AOP proxy is created, by an auto-proxy creator that returns a wrapper in place of your object;
  • serves that wrapper to everything that injected the bean;
  • on shutdown, runs @PreDestroy, then DisposableBean.destroy, then any configured destroy method.

Two things follow. First, @PostConstruct runs on the raw target before the proxy exists, so an annotated method invoked from @PostConstruct is unadvised even if you call it on an injected reference to yourself. Second, and more importantly, what other beans hold is not your object. It is a proxy that holds your object.

Why the annotation goes quiet

The proxy intercepts calls that arrive through it. A call from inside the target object arrives through this.

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InvoiceService {

    @Transactional
    public void save(Invoice invoice) {
        // ...
    }

    public void saveAll(List<Invoice> invoices) {
        for (Invoice invoice : invoices) {
            // Resolves to this.save(...) at the bytecode level. The proxy is not
            // on the call path, so no transaction is opened and no rollback
            // rules apply. Nothing logs a warning.
            save(invoice);
        }
    }
}

Call saveAll from a controller and every write happens in whatever transaction context the caller happened to have, which in a plain read path is none. Each save commits on its own connection with autocommit semantics, and a failure halfway through leaves the earlier rows in place. The code looks transactional, the annotation is present, the tests pass because they usually call save directly, and the defect surfaces as partial data in production.

The same reasoning applies to every proxy-based annotation. @Cacheable on a self-invoked method never consults the cache. @Async on a self-invoked method runs synchronously on the caller's thread, which is the nastier version because the return type is still a CompletableFuture and nothing looks wrong. @Retryable never retries. A related case with the same root cause is annotating a non-public method: under proxying, private methods are invisible to the proxy and the advice is silently skipped.

Fixes, in the order you should consider them

Move the annotated method to a different bean and inject it. This is the right answer nearly always, because the self-invocation problem is usually a symptom that the class is doing two jobs at two different transactional granularities, and separating them is a genuine improvement rather than a workaround.

Inject the bean into itself. Spring permits this, and with constructor injection you need @Lazy or an ObjectProvider to break the cycle. It works, it is explicit, and it is ugly enough that reviewers ask about it, which is arguably a feature.

Use AopContext.currentProxy(), which requires @EnableAspectJAutoProxy(exposeProxy = true). This couples your business code to Spring's AOP internals and is worth avoiding unless the alternatives are worse.

Switch to AspectJ weaving, for example @EnableTransactionManagement(mode = AdviceMode.ASPECTJ). Weaving modifies the bytecode of the class itself, so there is no proxy to bypass and self-invocation and non-public methods both work. The cost is a load-time weaving agent or a build-time weaving step, which is real operational complexity for what is usually a design problem.

What the interviewer is listening for

Most candidates who have hit this bug can describe the symptom. Fewer can name the mechanism, and it is naming the mechanism that predicts whether they will recognise the next instance of it. Once you know that advice arrives through a wrapper, three other Spring behaviours become obvious rather than mysterious: why a final class or final method cannot be advised by a CGLIB proxy, why injecting your bean by its concrete class fails when Spring chose a JDK dynamic proxy that only implements the interfaces, and why the this inside a bean is never the thing the rest of the application is talking to.

Likely follow-ups

  • At what point in the bean lifecycle does the proxy get created, and what does that mean for @PostConstruct?
  • Why is @Transactional on a private method ignored under proxying but honoured under AspectJ weaving?
  • When does Spring use a JDK dynamic proxy instead of a CGLIB subclass, and what breaks with each?
  • Why did Spring Boot start rejecting circular bean references by default?

Related questions

Further reading

springdependency-injectionaopproxiesbean-lifecycle