Skip to main content
spring internals

Circular Dependencies and the Three-Level Cache

8 min read Chapter 12 of 78

Circular Dependencies and the Three-Level Cache

Bean A depends on Bean B. Bean B depends on Bean A. This is a circular dependency. In a language without a framework, this is a compilation error or a stack overflow. In Spring, the outcome depends on how the dependency is injected.

Field and setter injection: Spring resolves it silently using the three-level singleton cache. Constructor injection: Spring throws BeanCurrentlyInCreationException. Spring Boot 3.x: Spring throws regardless of injection style, unless you explicitly opt in.

The SaaS backend’s OrderService processes orders and dispatches notifications. NotificationService queries order status to build notification content. This is a circular dependency. Whether it works, fails, or fails silently with a partially initialized bean depends entirely on the injection mechanism.

The Three Maps in DefaultSingletonBeanRegistry

DefaultSingletonBeanRegistry maintains three ConcurrentHashMap instances. Together, they form the singleton resolution cache:

// Level 1: singletonObjects
// Fully initialized, ready-to-use singletons.
// Post-construction, post-injection, post-BeanPostProcessor.
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

// Level 2: earlySingletonObjects
// Beans that have been instantiated (constructor returned) but not yet
// populated (fields/setters not injected, BeanPostProcessors not run).
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

// Level 3: singletonFactories
// ObjectFactory instances that can produce an early reference to a bean.
// The factory may return the raw instance or a proxy-wrapped version.
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

The lookup sequence in getSingleton(String beanName, boolean allowEarlyReference):

protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // Level 1: fully initialized
    Object singletonObject = this.singletonObjects.get(beanName);
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        // Level 2: early reference (already materialized from factory)
        singletonObject = this.earlySingletonObjects.get(beanName);
        if (singletonObject == null && allowEarlyReference) {
            synchronized (this.singletonObjects) {
                // Double-check within lock
                singletonObject = this.singletonObjects.get(beanName);
                if (singletonObject == null) {
                    singletonObject = this.earlySingletonObjects.get(beanName);
                    if (singletonObject == null) {
                        // Level 3: invoke factory, promote to level 2
                        ObjectFactory<?> singletonFactory =
                                this.singletonFactories.get(beanName);
                        if (singletonFactory != null) {
                            singletonObject = singletonFactory.getObject();
                            this.earlySingletonObjects.put(beanName, singletonObject);
                            this.singletonFactories.remove(beanName);
                        }
                    }
                }
            }
        }
    }
    return singletonObject;
}

Level 3 exists separately from Level 2 because the factory may apply AOP wrapping. If bean A needs early exposure and it has AOP advice, the factory calls getEarlyBeanReference() on each SmartInstantiationAwareBeanPostProcessor. AbstractAutoProxyCreator checks if a proxy is needed and returns the proxy instead of the raw bean. The factory runs at most once per bean, then the result is promoted to Level 2 and the factory is removed.

How Setter/Field Injection Cycles Resolve

Walk through the sequence for OrderService (field-injected) depending on NotificationService, which depends back on OrderService:

  1. Spring begins creating orderService. Calls constructor. Constructor succeeds (no dependency on NotificationService in the constructor). orderService is now an incomplete object: constructed but not injected.
  2. Spring registers an ObjectFactory for orderService in Level 3 (singletonFactories). This factory returns the raw orderService instance.
  3. Spring proceeds to populateBean() for orderService. Discovers @Autowired NotificationService notificationService field.
  4. Spring calls getBean("notificationService"). notificationService is not in any cache. Creation begins.
  5. Spring calls NotificationService constructor. Constructor succeeds. notificationService is now incomplete.
  6. Spring registers an ObjectFactory for notificationService in Level 3.
  7. Spring proceeds to populateBean() for notificationService. Discovers @Autowired OrderService orderService field.
  8. Spring calls getBean("orderService"). Checks Level 1: not there. Checks if currently in creation: yes. Checks Level 2: not there. Checks Level 3: factory exists. Invokes factory. Gets the raw orderService reference. Promotes to Level 2. Returns.
  9. notificationService receives the early orderService reference. Field injection completes. BeanPostProcessors run. notificationService moves to Level 1.
  10. Back in step 3: orderService receives the fully initialized notificationService. Field injection completes. BeanPostProcessors run. orderService moves to Level 1.

The cycle is broken at step 8. notificationService receives an orderService that has been constructed but not yet injected. This is a partially initialized object. If notificationService calls a method on orderService during its own initialization (in @PostConstruct, for example), that method may see null fields. This is the hidden cost of circular dependency resolution: initialization order becomes observable and fragile.

Why Constructor Injection Cycles Are Unresolvable

Constructor injection resolves dependencies before the constructor returns. The bean object does not exist until construction completes. There is no early reference to share.

Walk through the sequence for OrderService (constructor-injected) depending on NotificationService (constructor-injected) depending back on OrderService:

  1. Spring begins creating orderService. Resolves constructor parameter NotificationService.
  2. notificationService is not in any cache. Spring begins creating it.
  3. Spring resolves constructor parameter OrderService for notificationService.
  4. orderService is not in Level 1 or Level 2. isSingletonCurrentlyInCreation("orderService") returns true. Level 3 check: no factory registered, because the factory is only registered after the constructor returns (step 2 never completed).
  5. Spring detects the cycle. Throws BeanCurrentlyInCreationException.

The key difference: with setter/field injection, the constructor completes before dependency resolution, so a Level 3 factory can be registered. With constructor injection, the constructor cannot complete because it needs the dependency first.

Spring Boot’s Default Behavior

Spring Boot 3.x prohibits circular dependencies by default. The property spring.main.allow-circular-references defaults to false. Even setter/field injection cycles will fail with:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  orderService
↑     ↓
|  notificationService
└─────┘

This is deliberate. The Spring team considers circular dependencies a design flaw. The three-level cache exists for backward compatibility, not as a feature to rely on.

To re-enable circular dependency resolution (not recommended):

spring.main.allow-circular-references=true

@Lazy: The Proxy Workaround

@Lazy on an injection point tells Spring to inject a proxy instead of the real bean. The proxy defers resolution until the first method call. This breaks constructor injection cycles because the constructor receives a proxy (which exists immediately), not the real bean.

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(@Lazy NotificationService notificationService) {
        this.notificationService = notificationService;
        // notificationService is a CGLIB proxy here, not the real instance
    }

    public void processOrder(Order order) {
        // First method call triggers real resolution
        notificationService.sendOrderConfirmation(order);
    }
}

The proxy is created by AutowiredAnnotationBeanPostProcessor when it detects @Lazy. It builds a TargetSource-based proxy that calls getBean() on first invocation. The real NotificationService is resolved at that point, long after both constructors have completed.

Cost: one proxy object per @Lazy injection point. Each method call goes through the proxy indirection. The proxy holds a reference to the BeanFactory to perform lazy resolution. If the real bean’s initialization fails, the failure is deferred until the first method call, making stack traces harder to trace to root cause.

The Failure Mode

// BROKEN: Mutual constructor injection cycle
@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public OrderResult processOrder(String tenantId, Order order) {
        PaymentResult payment = paymentService.charge(tenantId, order.total());
        return new OrderResult(order.id(), payment.transactionId());
    }
}

@Service
public class PaymentService {

    private final OrderService orderService;

    public PaymentService(OrderService orderService) {
        this.orderService = orderService;
    }

    public PaymentResult charge(String tenantId, BigDecimal amount) {
        // Needs orderService to check order validity before charging
        return new PaymentResult(UUID.randomUUID().toString());
    }
}

Spring Boot 3.x output:

***************************
APPLICATION FAILED TO START
***************************

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  orderService defined in file [OrderService.class]
↑     ↓
|  paymentService defined in file [PaymentService.class]
└─────┘

Even with allow-circular-references=true, this fails because both use constructor injection. No early reference can be produced for either bean.

The Correct Pattern

The correct fix is not @Lazy. The correct fix is eliminating the cycle. If PaymentService needs to validate an order before charging, the validation logic belongs in a third service that both can depend on:

// CORRECT: Extract the shared concern into a separate service
@Service
public class OrderValidationService {

    private final OrderRepository orderRepository;

    public OrderValidationService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public boolean isValid(String orderId) {
        return orderRepository.findById(orderId)
                .map(order -> order.status() != OrderStatus.CANCELLED)
                .orElse(false);
    }
}

@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    public OrderResult processOrder(String tenantId, Order order) {
        PaymentResult payment = paymentService.charge(tenantId, order);
        return new OrderResult(order.id(), payment.transactionId());
    }
}

@Service
public class PaymentService {

    private final OrderValidationService validationService;

    public PaymentService(OrderValidationService validationService) {
        this.validationService = validationService;
    }

    public PaymentResult charge(String tenantId, Order order) {
        if (!validationService.isValid(order.id())) {
            throw new InvalidOrderException(order.id());
        }
        return new PaymentResult(UUID.randomUUID().toString());
    }
}

The dependency graph is now acyclic. OrderService depends on PaymentService. PaymentService depends on OrderValidationService. OrderValidationService depends on OrderRepository. No cycles. Constructor injection works. Spring Boot’s default circular dependency check passes.

If you genuinely cannot refactor (legacy code, third-party library), @Lazy is the escape hatch:

// ACCEPTABLE: @Lazy when refactoring is not possible
@Service
public class LegacyOrderService {

    private final LegacyPaymentService paymentService;

    public LegacyOrderService(@Lazy LegacyPaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

Understand the trade-off. The proxy defers initialization failure. You will not know that LegacyPaymentService failed to initialize until the first call to paymentService.charge(). In production, that could be the first customer transaction. Startup validation, one of Spring’s strongest guarantees, is partially bypassed.

Circular dependencies are a dependency graph problem, not an injection mechanism problem. The three-level cache is a compatibility mechanism, not a solution. Refactor the cycle out of your design. The compiler and Spring’s startup validator will thank you.