Spring Circular Dependencies: Why Field Injection Works But Constructor Injection Fails
This article explains Spring's three-level cache mechanism that resolves circular dependencies for field/setter injection, why constructor injection fails with BeanCurrentlyInCreationException, how early exposure and ObjectFactory enable AOP proxy creation, and the impact of Spring Boot 2.6's default circular reference blocking.
The most common circular dependency in Spring projects involves two services injecting each other via @Autowired on fields. This works at runtime, but switching to constructor injection causes immediate startup failure with BeanCurrentlyInCreationException.
Constructor Injection Startup Failure
With constructor injection, dependencies must be resolved before object instantiation. Spring tries to create OrderService, which requires UserService, which in turn requires OrderService — a deadlock. Since Spring Boot 2.6, this fails by default, printing a clear dependency cycle diagram.
Why Setter/Field Injection Succeeds
Field and setter injection follow a different path: Spring first calls the no-arg constructor to create an empty shell (all fields null), then populates dependencies. The object reference exists on the heap before dependencies are filled, allowing early exposure. This is the foundation of the three-level cache design.
OrderService orderService = new OrderService(); // object exists, fields null
// reference can be passed to others now
orderService.setUserService(userService); // dependencies filled laterThe Three Cache Maps
Defined in DefaultSingletonBeanRegistry:
/** Level 1: fully initialized singleton beans */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** Level 3: ObjectFactory that can produce early references */
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
/** Level 2: early references already exposed */
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);Level 1 holds ready-to-use beans. Level 3 holds factories; calling getObject() produces the early reference. Level 2 caches the result after the factory runs once. Level 1 uses ConcurrentHashMap; levels 2 and 3 use plain HashMap because all access is guarded by synchronized (this.singletonObjects) — a coarse-grained lock covering all singleton creation.
Complete Circular Dependency Resolution Flow
Using OrderService ↔ UserService:
// 1. Create orderService
createBean("orderService")
→ instantiate, get empty OrderService
→ addSingletonFactory("orderService", () -> getEarlyBeanReference(...))
→ populateBean, discovers userService dependency
// 2. Switch to create userService
getBean("userService")
→ instantiate
→ addSingletonFactory("userService", ...)
→ populateBean, discovers orderService dependency
// 3. Resolve orderService again
getSingleton("orderService")
→ level 1: miss
→ level 2: miss
→ level 3: get ObjectFactory, call getObject()
→ obtain early reference of orderService
→ move to level 2, remove factory from level 3
→ inject this reference into userService
// 4. Back-fill
userService finishes init → move to level 1
orderService continues population → finishes init → move to level 1All lookups funnel through getSingleton(String beanName, boolean allowEarlyReference), which checks level 1, then level 2, then level 3 (if allowEarlyReference is true). On a level 3 hit, the factory executes, the result moves to level 2, and the factory is removed — ensuring subsequent requests hit level 2 directly.
ObjectFactory and Early AOP Proxy Creation
The factory registered via addSingletonFactory is a lazy callback:
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean)); getEarlyBeanReferencedelegates to all SmartInstantiationAwareBeanPostProcessor instances:
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {
exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);
}
}
return exposedObject;
} AbstractAutoProxyCreatorimplements this method. It records the bean in earlyProxyReferences and calls wrapIfNecessary, generating a proxy if the bean has aspects. This explains why three levels are necessary: without level 3, the early reference would be the raw object, and @Transactional, @Cacheable, or custom aspects would not apply. Level 3 defers the "create early reference" decision until a dependent bean actually requests it, giving the proxy creator a chance to intervene.
Role of the Second-Level Cache
If only two levels existed, every getSingleton call reaching level 3 would invoke ObjectFactory.getObject() again. A bean depended on by three others would produce three distinct proxy instances, breaking singleton semantics. Level 2 caches the first generated reference; subsequent requests hit level 2 directly, guaranteeing the same instance everywhere. Level 3 decides whether to create a proxy; level 2 ensures it happens once .
Why Constructor Injection Cannot Use the Cache
Three-level cache activation requires the object to already exist. The factory is registered in doCreateBean after createBeanInstance but before populateBean. Constructor injection demands all parameters at createBeanInstance time — before the object exists — so there is no reference to expose. Spring detects the cycle during constructor argument resolution and throws BeanCurrentlyInCreationException before any cache interaction.
Spring Boot 2.6+ Default Behavior
The switch spring.main.allow-circular-references defaults to false from Spring Boot 2.6 onward. Circular dependencies now fail at startup instead of being resolved via the three-level cache. The old behavior can be restored temporarily for migration:
spring:
main:
allow-circular-references: trueSpring Framework itself allowed circular references until 5.3 via AbstractAutowireCapableBeanFactory.allowCircularReferences.
@Async Conflict with Early Exposure
When a bean with @Async participates in a circular dependency, the application starts but the async method runs synchronously. AsyncAnnotationBeanPostProcessor creates its proxy in postProcessAfterInitialization, which runs after the early reference is exposed via getEarlyBeanReference. The dependent bean receives the raw object, bypassing the async proxy. In contrast, @Transactional proxies are created by AbstractAutoProxyCreator inside getEarlyBeanReference, so they work correctly in circular scenarios.
Handling Circular Dependencies
Refactor (preferred): Extract a third bean to break the cycle. Mutual service dependencies often indicate unclear responsibility boundaries. Shared mappers or domain objects are common culprits; move common logic to a dedicated domain service.
@Lazy on constructor parameter: Injects a proxy that defers real bean lookup until first use.
Manual ApplicationContext.getBean() : Bypasses detection but leaks container API into business code, complicating dependency analysis.
For detection, explicitly set allow-circular-references=false and examine startup logs. Many legacy projects reveal hidden cycles only when a bean gains an aspect or changes injection style. Cycles may also be profile-specific (e.g., mock beans in dev, real beans in test), so run this check in CI integration tests.
Circular dependencies are cycles in the object graph. Spring's three-level cache merely navigates the cycle in singleton scope via early reference exposure — it does not eliminate the cycle. Understanding the division of labor — level 1 for finished beans, level 2 for exposed references, level 3 for reference factories — makes it easier to diagnose constructor injection failures or proxy malfunctions.
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 Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
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.
