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 BeanPostProcessor — this 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 {
{
}
{
(Invoice invoice : invoices) {
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.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).