Why Spring’s Circular Dependency Can Crash Your Service and How to Fix It

A backend admin service failed to start due to a Spring circular‑dependency error where a bean was injected in its raw form before AOP proxy creation, exposing the limits of Spring’s three‑level cache and prompting a detailed analysis of the root cause and practical solutions.

DeWu Technology
DeWu Technology
DeWu Technology
Why Spring’s Circular Dependency Can Crash Your Service and How to Fix It

Background

In a pre‑release environment a backend admin service failed to start with an UnsatisfiedDependencyException that highlighted a circular dependency involving the bean spuCheckDomainServiceImpl and AOP proxy creation (e.g., @Transactional, @Validated).

Related Knowledge Overview

Circular Dependency

A circular dependency occurs when two or more beans depend on each other directly or indirectly, forming a loop. The common scenarios are:

Self‑dependency (a bean depends on itself).

Mutual dependency (Bean A depends on Bean B and vice‑versa).

Indirect dependency (a longer chain of references creates a loop).

Constructor‑injection circular dependencies cannot be resolved because Spring needs a fully instantiated object before it can inject dependencies, resulting in BeanCurrentlyInCreationException:

@Service
public class A { public A(B b) {} }

@Service
public class B { public B(A a) {} }

Field (setter) injection for singleton beans can be resolved because Spring can expose a partially constructed bean via its early reference cache. Prototype beans, however, are created lazily and may require explicit getBean() or @Autowired to avoid the loop.

Using @Lazy only delays the injection; the underlying circular reference still triggers the same exception when the bean is finally needed.

Circular dependency illustration
Circular dependency illustration

Spring Bean Creation Process

Spring creates a bean in three main steps:

Instantiate the bean (usually via the no‑arg constructor).

Populate bean properties (dependency injection occurs here).

Initialize the bean (invoke init methods and apply AOP proxies).

The core logic is implemented in doCreateBean:

protected Object doCreateBean(final String beanName, final RootBeanDefinition mbd, final @Nullable Object[] args) throws BeanCreationException {
    // 1. instantiate bean
    Object bean = createBeanInstance(beanName, mbd, args);
    // 2. expose early reference if needed (three‑level cache)
    if (earlySingletonExposure) {
        addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
    }
    // 3. populate properties
    populateBean(beanName, mbd, bean);
    // 4. initialize bean (AOP, init‑methods)
    Object exposedObject = initializeBean(beanName, bean, mbd);
    return exposedObject;
}

During step 2 Spring may place an ObjectFactory into the third‑level cache so that other beans can obtain an early reference before the bean is fully initialized.

Case Analysis

Code Analysis

The problematic classes are simplified as follows:

@RestController
public class OldCenterSpuController {
    @Resource private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

@RestController
public class TimeoutNotifyController {
    @Resource private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}

@Component
public class NewSpuApplyCheckServiceImpl {
    @Resource private SpuCheckDomainServiceImpl spuCheckDomainServiceImpl;
}

@Component
@Validated
public class SpuCheckDomainServiceImpl {
    @Resource private NewSpuApplyCheckServiceImpl newSpuApplyCheckServiceImpl;
}

Both SpuCheckDomainServiceImpl and NewSpuApplyCheckServiceImpl depend on each other, forming a circular reference. When the container starts from TimeoutNotifyController, the bean is fetched twice:

First doGetBean cannot find the bean, so doCreateBean creates it and puts a raw instance into the third‑level cache.

During property population, NewSpuApplyCheckServiceImpl is created, which again calls doGetBean for SpuCheckDomainServiceImpl. This time the raw instance is moved to the second‑level cache.

After initializeBean runs, @Validated triggers a MethodValidationPostProcessor that creates a proxy. The proxy replaces the raw instance, so the bean returned to the caller differs from the early reference stored in the second‑level cache.

Spring detects that other beans have already been injected with the raw instance and throws UnsatisfiedDependencyException:

Bean with name 'spuCheckDomainServiceImpl' has been injected into other beans [...] in its raw version as part of a circular reference, but has eventually been wrapped.
Three‑level cache flow diagram
Three‑level cache flow diagram

Problem Analysis

Spring solves simple circular dependencies with a three‑level cache (singletonObjects, earlySingletonObjects, singletonFactories). The cache works as long as the bean exposed early is the final version. When an AOP proxy (e.g., from @Validated) is created **after** the early reference is stored, other beans keep the raw instance while the final bean is a proxy, causing inconsistency.

In contrast, @Transactional creates its proxy **during** the early‑reference phase, so the proxy is stored in the cache and the problem does not appear.

Solution

Short‑term: Remove @Validated from SpuCheckDomainServiceImpl or annotate the dependency with @Lazy to defer injection until after proxy creation.

Long‑term: Refactor the code to obey DDD layering and eliminate circular dependencies. Use a custom detection tool that scans bean definitions after context refresh and reports cycles.

@Component
@ConditionalOnProperty(value = "circular.dependency.analysis.enabled", havingValue = "true")
public class TimingCircularDependencyHandler extends AbstractNotifyHandler<NotifyData>
        implements ApplicationContextAware, BeanFactoryAware {
    // builds a dependency graph and runs DFS to find cycles
}

Summary

Spring’s three‑level cache can resolve many circular dependencies, but when a bean requires an AOP proxy that is created **after** the early reference is exposed (as with @Validated), the container ends up injecting a raw instance into other beans, leading to UnsatisfiedDependencyException. Proper architectural design, early detection of dependency cycles, and careful use of proxy‑creating annotations are essential to avoid such startup failures.

Overall bean creation flow
Overall bean creation flow
JavaAOPbackend developmentSpringdependency-injectionCircular DependencyBean Creation
DeWu Technology
Written by

DeWu Technology

A platform for sharing and discussing tech knowledge, guiding you toward the cloud of technology.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.