Mastering Spring Bean Circular Dependencies: 3 Real-World Solutions
This article explains Spring's three‑level cache mechanism for resolving bean circular dependencies, dives into why the cache alone is insufficient, and presents three practical scenarios with concrete solutions such as redesigning constructors, using @Lazy, and handling AOP proxy consistency.
Understanding Spring Bean Circular Dependency
Bean circular dependency is a classic problem in large Java projects where two beans depend on each other, causing the container to get stuck during initialization. Spring addresses most singleton setter/field circular dependencies with a three‑level cache, but other scenarios require additional strategies.
How the three‑level cache works
Instantiate Bean A (new instance, still empty).
Put a reference to A into the third‑level cache ( singletonFactories) as a placeholder.
Inject properties of A , discover that A needs Bean B.
Create Bean B using the same process; during B's creation it discovers it needs A.
Early exposure : B finds A's placeholder in the third‑level cache, executes the stored ObjectFactory (which may create a proxy), and moves the reference to the second‑level cache ( earlySingletonObjects), then removes it from the third‑level cache.
B completes its initialization using the early‑exposed A and stores itself in the first‑level cache.
A finishes its property injection using the fully initialized B and moves to the first‑level cache.
The core idea is “early exposure”: before a bean is fully initialized, its partially constructed instance (or proxy) is made available so other beans can reference it, breaking the deadlock for singleton setter/field injection.
Why “three‑level cache” became a buzzword
The three caches— singletonObjects, earlySingletonObjects, and singletonFactories —allow Spring to expose a bean’s early reference, fill properties later, and finally store the fully initialized bean. This mechanism works well for singleton beans with setter/field injection but has limits.
Three practical scenarios
Scenario 1: Constructor injection deadlock – three‑level cache can’t help
Pain point : When both A and B require each other via constructors, the container cannot create a “half‑bean” because the object doesn’t exist until the constructor finishes.
Solution A (recommended) : Refactor the design—move one dependency to setter or field injection so the bean can be created first.
Solution B (quick fix) : Annotate one constructor parameter with @Lazy, causing Spring to inject a proxy and defer actual creation.
Scenario 2: Prototype bean circular dependency – cache ineffective
Pain point : Prototype beans are created anew on each request, so the shared early reference in the cache would either be missing or point to the first instance, breaking the prototype contract.
Solution A : Change the involved beans to singleton scope ( @Scope("singleton")) to reuse the three‑level cache.
Solution B : Use @Lazy on the prototype dependency to inject a proxy, delaying actual creation until first use.
Scenario 3: Multi‑proxy & AOP – ensuring proxy consistency
Pain point : When a bean is wrapped by AOP (e.g., @Transactional) the early reference may be the raw object, while the final bean in the first‑level cache is a proxy, leading to mismatched instances.
Resolution : The ObjectFactory stored in the third‑level cache can generate the proxy during early exposure (via getEarlyBeanReference), guaranteeing that both the early reference and the final bean are the same proxy.
Practical advice
Spring’s three‑level cache is a powerful “emergency fund” for singleton setter/field circular dependencies, but it fails for constructor deadlocks, prototype cycles, or when proxy consistency is required. In those cases, redesign the dependency graph, switch scopes, or apply @Lazy to break the cycle.
Core code illustration (simplified)
public Object getBean(String beanName) {
// 1. Check first‑level cache (singletonObjects)
Object bean = singletonObjects.get(beanName);
if (bean != null) return bean;
// 2. Mark bean as "in creation" to avoid concurrent creation
// ... (thread‑safety logic)
// 3. Instantiate raw bean (new instance)
Object rawBean = createBeanInstance(beanName);
// 4. Expose early reference via third‑level cache
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, rawBean));
// 5. Populate properties (inject dependencies)
populateBean(beanName, rawBean);
// 6. Initialize bean (afterPropertiesSet, init‑methods, etc.)
bean = initializeBean(beanName, rawBean);
// 7. Move to first‑level cache and clean up secondary caches
singletonObjects.put(beanName, bean);
// ... remove from earlySingletonObjects and singletonFactories
return bean;
}
protected Object getEarlyBeanReference(String beanName, Object bean) {
// If AOP proxy is needed, create and return it here
Object exposedObject = bean;
if (/* needs proxy */) {
exposedObject = createProxy(bean);
}
return exposedObject;
}Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
