When two Spring beans keep asking for each other first, the container gets stuck — interviewers love this because it tests both diagnosis and design judgment.
Question: What is a circular dependency in Spring Boot?
Answer: A circular dependency happens when bean A needs bean B, and bean B also needs bean A, so Spring cannot finish building either one cleanly. In Spring Boot 2.6 and later, this usually fails by default during startup with a BeanCurrentlyInCreationException. The safest fix is to break the loop by redesigning the beans; quick workarounds like @Lazy or ObjectProvider only defer the problem.
Interview-Ready Answer: In Spring Boot, a circular dependency means two or more beans depend on each other in a loop, like A → B → A. If the cycle is through constructors, Spring cannot create either bean, so startup fails; since Boot 2.6, that failure is the default behavior unless you explicitly change it. My first choice is to remove the cycle by refactoring, and if I need a temporary bridge, I use @Lazy or ObjectProvider to defer one side.
A circular dependency is not just “two classes reference each other in code.” It becomes a Spring problem when both beans are needed during container startup. Spring must fully create bean A before bean B, but bean B also needs bean A, so the graph has no clean starting point.
spring.main.allow-circular-references=false.Memory detail: the “early reference” trick is a Spring-only safety net, not a real solution. It means one bean may see another bean before it is fully initialized, which is why circular references are considered a design smell.
| Option | What it does | Verdict |
|---|---|---|
| Refactor | Split responsibilities | Best long term |
@Lazy | Injects a proxy | Good temporary fix |
ObjectProvider | Looks up later | Good for optional use |
allow-circular-references=true | Re-enables legacy behavior | Last resort |
Important note: @DependsOn only changes startup order; it does not remove the cycle. It can make the failure appear in a different place, but it does not solve the root problem.
@Lazy when one side is rarely touched and you need a quick bridge during migration.ObjectProvider when the dependency is truly optional or only needed in one method.@PostConstruct or a business method keeps calling back and forth.@Lazy adds one proxy hop, which is tiny in practice; the real cost is architectural complexity, not CPU time.Version clue: Boot 2.5 and earlier generally allowed circular references by default; Boot 2.6 changed the default to false, so older apps suddenly started failing on upgrade. That is a classic interview trap.
Real-World Example: Imagine a checkout service in an e-commerce app. OrderService creates orders and calls PaymentService to charge the card. Later someone adds a “check order status” call inside PaymentService that reaches back into OrderService. In a local test, it looks harmless. In production after a Spring Boot 2.6 upgrade, the whole app fails to start, Kubernetes keeps restarting the pod, and the logs show an UnsatisfiedDependencyException ending in BeanCurrentlyInCreationException.
The user impact is brutal: checkout pages return 500s, no order can be placed, and every deploy is blocked until the cycle is removed. The fix is usually to extract a third service, such as OrderQueryService or PaymentPolicyService, so neither bean needs to know too much about the other. If the team needs a fast temporary release, one side can be marked @Lazy, but the long-term repair is to untangle the responsibility split.
What goes wrong when misunderstood: teams often think “just turn the flag on.” That may let the app boot, but it leaves a fragile design in place. The next refactor, proxy change, or scope change can break it again, and the outage shows up as startup failure, not a neat unit-test error.
package com.example.circulardependency;
import org.springframework.beans.BeansException;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@SpringBootApplication
public class CircularDependencyApplication {
public static void main(String[] args) {
SpringApplication.run(CircularDependencyApplication.class, args);
}
@Bean
CommandLineRunner demo(OrderService orderService) {
return args -> {
System.out.println("=== Working graph: cycle broken with @Lazy ===");
System.out.println(orderService.placeOrder("ORD-1001"));
System.out.println();
System.out.println("=== Failure demo: constructor cycle without @Lazy ===");
// This separate context intentionally recreates the bad design.
// Constructor injection cannot use an early reference, so Spring fails fast.
try (AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext()) {
ctx.register(BrokenCycleConfig.class);
ctx.refresh();
System.out.println("Unexpected: context started, which means the cycle was not reproduced.");
} catch (BeansException ex) {
Throwable root = rootCause(ex);
System.out.println("Expected startup failure: " + root.getClass().getSimpleName());
System.out.println(root.getMessage());
}
};
}
private static Throwable rootCause(Throwable ex) {
Throwable current = ex;
while (current.getCause() != null) {
current = current.getCause();
}
return current;
}
}
@Service
class OrderService {
private final PaymentService paymentService;
// @Lazy tells Spring to inject a proxy here instead of creating PaymentService immediately.
// That lets OrderService finish construction first, which breaks the loop.
OrderService(@Lazy PaymentService paymentService) {
this.paymentService = paymentService;
}
String placeOrder(String orderId) {
return "Order " + orderId + " placed -> " + paymentService.charge(orderId);
}
String currentStatus(String orderId) {
return "status=READY for " + orderId;
}
}
@Service
class PaymentService {
private final OrderService orderService;
PaymentService(OrderService orderService) {
this.orderService = orderService;
}
String charge(String orderId) {
// If this class were to call back into placeOrder(), we would create a runtime loop.
return "payment captured; checked " + orderService.currentStatus(orderId);
}
}
@Configuration(proxyBeanMethods = false)
class BrokenCycleConfig {
@Bean
BrokenA brokenA(BrokenB brokenB) {
return new BrokenA(brokenB);
}
@Bean
BrokenB brokenB(BrokenA brokenA) {
return new BrokenB(brokenA);
}
}
class BrokenA {
private final BrokenB brokenB;
BrokenA(BrokenB brokenB) {
this.brokenB = brokenB;
}
String info() {
return "A -> " + brokenB.getClass().getSimpleName();
}
}
class BrokenB {
private final BrokenA brokenA;
BrokenB(BrokenA brokenA) {
this.brokenA = brokenA;
}
String info() {
return "B -> " + brokenA.getClass().getSimpleName();
}
}Follow-up & Tricky Questions:
@Lazy or ObjectProvider. That keeps the fix local instead of changing how the whole app behaves.UnsatisfiedDependencyException, and the root cause is commonly BeanCurrentlyInCreationException. The root cause matters because it points to the bean loop, not just a missing bean.@Lazy a real fix? It is a useful workaround, not the best long-term architecture. It breaks the creation loop by inserting a proxy, but if the two classes truly depend on each other’s business logic, refactoring is cleaner.@DependsOn solve it? No. It only changes order; it does not remove the dependency loop, so the container still has to resolve the same circular graph.@Lazy can hide the cycle at startup while the design problem still exists.allow-circular-references=true? No. That only relaxes startup behavior for some singleton cases; it does not make constructor cycles magically safe, and it should not be your long-term plan.Common Mistakes:
@DependsOn as the fix. Correction: It only changes initialization order; it does not break the loop.allow-circular-references=true as the answer for new code. Correction: Treat it as a temporary migration switch, not a design strategy.@PostConstruct interactions too.Memory Hook: Think of two people at a narrow door, each saying “you go first.” Nobody moves. Spring’s fix is either to open a side door (@Lazy) or redesign the hallway so they are not blocking each other.
Cheat Sheet:
allow-circular-references=false.@Lazy breaks the loop with a proxy; ObjectProvider breaks it with late lookup.BeanCurrentlyInCreationException.Practice Tasks:
@Lazy from the code example and observe the startup failure.PaymentService back to OrderService with a third service that owns the shared logic.ObjectProvider<PaymentService> instead of constructor injection.