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)@Lazyon 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: 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