Interview Prep Zoneby CuriouserLabs

Question 3 of 5 · staff level

How does Spring handle circular dependencies, and what do you do when you hit one?

🎤 Say this first

For singleton setter/field injection, Spring historically broke cycles with early references: A is instantiated, a reference is exposed via the early-reference mechanism (the 'three-level cache'), B gets that half-initialized A, then A finishes. Constructor cycles can't be broken — you can't hand out what isn't constructed — so they fail at startup, and since Spring Boot 2.6 circular references are disallowed by default even where technically breakable. The right response is architectural: a cycle means responsibilities are tangled — extract the shared piece into C, invert one direction with events, or split interfaces. @Lazy is the tactical escape hatch, not the fix.

The full picture

  • Mechanism (when enabled): singleton cache (finished beans) → early-singleton cache (post-processed early refs) → singleton factories (lambdas that can produce an early ref — and crucially, an early proxy if the bean will be AOP-proxied, keeping identities consistent).
  • Why constructor injection 'suffers' cycles: it doesn't — it reveals them at boot instead of letting a half-initialized object escape. That's a feature; NPEs from setter-cycle escapes are far worse.
  • Fix hierarchy: (1) redesign — extract C from the A↔B overlap, or replace one edge with ApplicationEventPublisher; (2) @Lazy on one injection point (proxy defers resolution to first use); (3) ObjectProvider<B> for lookup-on-demand; (4) spring.main.allow-circular-references=true — a flag whose presence in a repo should trigger a design conversation.
Tactical vs structural fix
// Tactical: break the cycle at wiring time
public AService(@Lazy BService b) { this.b = b; }  // proxy until first call

// Structural: A and B both needed pricing -> extract it
@Service class PricingService { … }                 // C
@Service class AService { AService(PricingService p) {…} }
@Service class BService { BService(PricingService p) {…} }

// Or invert an edge with events:
orderCompleted -> publisher.publishEvent(new OrderCompleted(id));
@EventListener void on(OrderCompleted e) { … }      // B no longer known to A

🔄 Likely follow-up questions

  • Why does the early-reference mechanism need bean factories rather than just an early-instance map? (AOP proxies)
  • What changed in Spring Boot 2.6 and why did the team flip the default?
  • How do you find cycles across modules before they bite? (ArchUnit, dependency graphs)