Circular Dependencies and the Three-Level Cache
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:
- Spring begins creating
orderService. Calls constructor. Constructor succeeds (no dependency onNotificationServicein the constructor).orderServiceis now an incomplete object: constructed but not injected. - Spring registers an
ObjectFactoryfororderServicein Level 3 (singletonFactories). This factory returns the raworderServiceinstance. - Spring proceeds to
populateBean()fororderService. Discovers@Autowired NotificationService notificationServicefield. - Spring calls
getBean("notificationService").notificationServiceis not in any cache. Creation begins. - Spring calls
NotificationServiceconstructor. Constructor succeeds.notificationServiceis now incomplete. - Spring registers an
ObjectFactoryfornotificationServicein Level 3. - Spring proceeds to
populateBean()fornotificationService. Discovers@Autowired OrderService orderServicefield. - 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 raworderServicereference. Promotes to Level 2. Returns. notificationServicereceives the earlyorderServicereference. Field injection completes.BeanPostProcessorsrun.notificationServicemoves to Level 1.- Back in step 3:
orderServicereceives the fully initializednotificationService. Field injection completes.BeanPostProcessorsrun.orderServicemoves 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:
- Spring begins creating
orderService. Resolves constructor parameterNotificationService. notificationServiceis not in any cache. Spring begins creating it.- Spring resolves constructor parameter
OrderServicefornotificationService. orderServiceis 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).- 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.