Definition
- Circular dependency -- a situation where bean A depends on bean B and bean B depends on bean A, creating an unresolvable creation loop.
- BeanCurrentlyInCreationException -- the exception Spring throws when it detects a circular dependency during constructor injection.
- Three-level singleton cache -- Spring's internal mechanism (singletonObjects, earlySingletonObjects, singletonFactories) that resolves circular dependencies for field and setter injection by exposing partially constructed bean references.
- @Lazy -- a temporary workaround that injects a proxy instead of the real bean, deferring creation until the first method call.
- Spring Boot 2.6+ -- changed the default to reject circular dependencies even for field injection; re-enable with
spring.main.allow-circular-references=true(discouraged).
Two clerks at National Bank of India, Rajesh and Priya: Rajesh cannot start his shift until Priya is at her desk, and Priya cannot start until Rajesh is at his. Neither starts, creating a deadlock. The fix: both depend on a shared AccountLedger instead of each other. Rajesh reads the ledger, Priya reads the ledger, and neither needs to wait for the other.
Understanding the Concepts
What is a circular dependency?
A circular dependency occurs when two or more beans depend on each other, directly or indirectly. Bean A needs bean B in its constructor, and bean B needs bean A in its constructor. The container cannot create either because it needs the other to be fully constructed first.
package com.bank.service;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class TransferService {
private final FraudCheckService fraudCheck;
// Needs FraudCheckService to be created
public TransferService(FraudCheckService fraudCheck) {
this.fraudCheck = fraudCheck;
}
public void transfer(String from, String to, BigDecimal amount) {
fraudCheck.validate(from, to, amount);
// ... execute transfer
}
}
@Service
public class FraudCheckService {
private final TransferService transferService;
// Needs TransferService to be created -- CIRCULAR!
public FraudCheckService(TransferService transferService) {
this.transferService = transferService;
}
public void validate(String from, String to, BigDecimal amount) {
// Analyzes recent transfers for patterns
}
}
***************************
APPLICATION FAILED TO START
***************************
The dependencies of some of the beans in the application context form a cycle:
transferService -> fraudCheckService -> transferService
Why does constructor injection fail?
With constructor injection, Spring must fully construct a bean before it can be used as a constructor argument. To create TransferService, Spring needs a fully constructed FraudCheckService. To create FraudCheckService, Spring needs a fully constructed TransferService. Neither can be created first, so the container throws BeanCurrentlyInCreationException.
The key insight is that a constructor cannot return until it completes, so there is no point where a partially constructed bean can be shared.
package com.bank;
// Simplified: what the container tries to do internally
public class ContainerSimulation {
public static void main(String[] args) {
// Step 1: Create TransferService
// -> needs FraudCheckService
// -> Step 2: Create FraudCheckService
// -> needs TransferService
// -> TransferService is currently being created!
// -> FAIL: BeanCurrentlyInCreationException
}
}
How does the three-level cache work for field injection?
With field or setter injection, Spring can create the bean instance first (call the no-arg or unrelated constructor), expose an early reference, and inject dependencies later. The three-level cache enables this:
| Cache level | Name | Contents |
|---|---|---|
| Level 1 | singletonObjects | Fully initialized beans |
| Level 2 | earlySingletonObjects | Early-exposed instances (partially initialized) |
| Level 3 | singletonFactories | Factory lambdas that produce early references |
The resolution steps:
- Spring creates
TransferService(no field injection yet). - Spring exposes
TransferService's early reference in the Level 3 factory cache. - Spring tries to create
FraudCheckServiceand needsTransferService. - Spring finds
TransferServicein the Level 3 cache, promotes it to Level 2. FraudCheckServicereceives the early (partially constructed)TransferServicereference.FraudCheckServicefinishes initialization and moves to Level 1.- Spring returns to
TransferService, injects the now-completeFraudCheckService, and moves it to Level 1.
package com.bank.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
// Field injection: circular dependency resolved by three-level cache
// This WORKS but is discouraged
@Service
public class TransferService {
@Autowired
private FraudCheckService fraudCheck; // injected after construction
}
@Service
public class FraudCheckService {
@Autowired
private TransferService transferService; // gets early reference
}
What changed in Spring Boot 2.6+?
Spring Boot 2.6 changed the default behavior to reject circular dependencies even for field and setter injection. The application fails to start with a clear error message showing the cycle. This is because circular dependencies are a design smell indicating overly coupled components.
You can re-enable the old behavior with:
# application.properties -- NOT recommended
spring.main.allow-circular-references=true
This is discouraged because it hides the design problem. The proper fix is to break the cycle.
How do you fix circular dependencies?
There are three approaches, from best to worst:
Fix 1: Extract the shared concern into a third bean (BEST)
The cycle between TransferService and FraudCheckService exists because FraudCheckService reaches into TransferService for data it should access through a separate service. Extract a TransactionHistoryService:
package com.bank.service;
import com.bank.repository.TransferRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
@Service
public class TransactionHistoryService {
private final TransferRepository transferRepo;
public TransactionHistoryService(TransferRepository transferRepo) {
this.transferRepo = transferRepo;
}
public List<Transfer> findRecent(String accountId, int days) {
return transferRepo.findByAccountIdAndDateAfter(accountId, days);
}
}
@Service
public class FraudCheckService {
private final TransactionHistoryService history; // no cycle
public FraudCheckService(TransactionHistoryService history) {
this.history = history;
}
public boolean isSuspicious(String accountId, BigDecimal amount) {
var recent = history.findRecent(accountId, 7);
BigDecimal weeklyTotal = recent.stream()
.map(Transfer::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return weeklyTotal.add(amount).compareTo(new BigDecimal("500000")) > 0;
}
}
@Service
public class TransferService {
private final FraudCheckService fraudCheck;
private final TransactionHistoryService history;
public TransferService(FraudCheckService fraudCheck, TransactionHistoryService history) {
this.fraudCheck = fraudCheck;
this.history = history;
}
public void transfer(String from, String to, BigDecimal amount) {
if (fraudCheck.isSuspicious(from, amount)) {
throw new FraudSuspectedException("Transfer blocked for account " + from);
}
// ... execute transfer
}
}
Fix 2: Event-driven decoupling
package com.bank.service;
import com.bank.event.TransferCompletedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class TransferService {
private final ApplicationEventPublisher events;
public TransferService(ApplicationEventPublisher events) {
this.events = events;
}
public void transfer(String from, String to, BigDecimal amount) {
// ... execute transfer
events.publishEvent(new TransferCompletedEvent(from, to, amount));
}
}
package com.bank.listener;
import com.bank.event.TransferCompletedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class FraudAnalysisListener {
@Async
@EventListener
public void onTransferCompleted(TransferCompletedEvent event) {
// Analyze transfer pattern -- no dependency on TransferService
}
}
Fix 3: @Lazy (temporary workaround)
package com.bank.service;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Service
public class TransferService {
private final FraudCheckService fraudCheck;
public TransferService(@Lazy FraudCheckService fraudCheck) {
this.fraudCheck = fraudCheck; // injects a proxy, not the real bean
}
}
With @Lazy, Spring injects a proxy that defers the actual bean creation until the first method call. This breaks the construction cycle but does not fix the design problem.
What are edge cases and limitations?
- Indirect cycles are harder to detect: A depends on B, B depends on C, C depends on A. The error message shows the full chain.
- Prototype-scoped beans in a cycle always fail because Spring does not cache prototype instances.
- AOP proxies add complexity: the three-level cache must produce the proxy, not the raw bean, when resolving early references.
- @Async beans have a special case:
@Asynccreates a proxy inpostProcessAfterInitialization, which can causeBeanCurrentlyInCreationExceptioneven with field injection if the early reference is not the proxy.
// This combination can fail even with field injection:
@Service
public class NotificationService {
@Autowired
private TransferService transferService;
@Async // proxy mismatch: early reference is raw, final bean is proxy
public void sendNotification(String message) { }
}
What should a senior engineer know?
- Every circular dependency points to a missing abstraction. Breaking the cycle always results in cleaner, more testable code with better separation of concerns.
- The three-level cache is a legacy mechanism from the Spring Framework 2.x era when field injection was common. Modern Spring Boot discourages it.
- Microservices amplify the problem. A circular dependency between services (Service A calls Service B, Service B calls Service A) causes distributed deadlocks and cascading failures. Use event-driven architecture or an API gateway to break service-level cycles.
- Static analysis tools like ArchUnit can enforce no-cycle rules in the build pipeline:
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "com.bank")
public class NoCyclesTest {
@ArchTest
static final ArchRule noCycles = slices()
.matching("com.bank.(*)..")
.should().beFreeOfCycles();
}
What are common interview follow-up questions?
- Can a circular dependency exist between three beans? (Yes: A depends on B, B on C, C on A.)
- Does
@Lazyfix the design problem or just hide it? (Hides it.) - What happens if you set
allow-circular-references=trueand use constructor injection? (Still fails because the three-level cache only works with field and setter injection.) - How would you detect circular dependencies in a large codebase? (ArchUnit, Spring startup logs, IntelliJ dependency matrix.)
- What is the difference between a circular dependency in beans and a circular reference in objects? (Bean cycle prevents creation; object reference cycle is normal in Java and handled by GC.)
Visual Overview
Constructor Injection Cycle (FAILS):
Create TransferService
│
├── needs FraudCheckService (constructor arg)
│ │
│ ├── needs TransferService (constructor arg)
│ │ │
│ │ └── ALREADY BEING CREATED!
│ │ BeanCurrentlyInCreationException
│ │
└───────┘
Three-Level Cache Resolution (field injection):
┌─────────────────────────────────────────────────┐
│ Level 3: singletonFactories │
│ TransferService factory lambda │
│ │
│ Level 2: earlySingletonObjects │
│ TransferService (partially initialized) │
│ ↑ │
│ │ FraudCheckService gets this reference │
│ │
│ Level 1: singletonObjects │
│ FraudCheckService (fully ready) │
│ TransferService (fully ready) │
└─────────────────────────────────────────────────┘
Proper Fix: Extract Third Bean
TransferService ──→ FraudCheckService
│ │
└──→ TransactionHistoryService ←──┘
(shared concern extracted)
Key Takeaway
A circular dependency occurs when two beans depend on each other, creating a loop that the container cannot resolve during construction. With constructor injection, this is always a hard failure because Spring cannot create either bean without the other. Spring Boot 2.6 and later reject circular dependencies by default even for field injection because they indicate overly coupled components that violate the Single Responsibility Principle.
In the National Bank of India's fraud detection system, TransferService depended on FraudCheckService to validate transfers, and FraudCheckService depended on TransferService to analyze recent transfer patterns. Pankaj, the architect, recognized the cycle as a missing abstraction and extracted TransactionHistoryService to store and query transfer records independently. TransferService records transfers through the history service, FraudCheckService queries it for pattern analysis, and neither knows about the other. The cycle was eliminated and the responsibilities became cleaner. Every circular dependency Rajesh has encountered in his career pointed to a missing abstraction, and breaking the cycle always resulted in a better design.
For cases where redesign is not immediately feasible, @Lazy injects a proxy that defers creation until the first method call, and event-driven decoupling eliminates the dependency entirely by publishing events instead of direct calls. Both are workarounds, not solutions. The real fix is always to break the cycle by extracting the shared concern.
Remember one sentence: every circular dependency points to a missing abstraction, and the fix is always to extract the shared concern into a third bean.
---