Circular Dependencies and the Three-Level Cache

bee2026-10-0844 min read0 views
A needs B and B needs A — why is Spring calm? The lookup order of the three caches, how early references get promoted, and the classic question of why three levels instead of two.
1 / 133
Section
0. Thirty seconds to get it
2 / 133

In plain words: a circular dependency means two classes need each other — A's job relies on B, and B's job relies back on A. Picture two new hires who both insist "I only start once he has started": ordered normally, neither can ever show up. The Spring container really does hit this knot, and it owns a whole craft for untying it — but whether that craft can save you depends on whether you wrote the dependency as constructor injection or field injection.

3 / 133
类比|Analogy

the three-level cache works like a warehouse pickup slip. The goods (an object whose wiring is not finished) have not arrived in full, yet you can already pin a slip on the wall; when a downstream bean comes to collect, the slip lets it register the goods right away, and once the factory finishes assembling, the slip is swapped for the real item. Three things matter: a slip can exist before the goods do (the object is instantiated), each slip is redeemed exactly once (it is then promoted and archived), and the slip says whether you get the genuine article or a display model (raw object versus AOP proxy).

4 / 133
类比|Analogy

@Lazy is like borrowing a neighbour's drill. You and the neighbour each hold the only key to the other's door — "drill me first, then I open" never resolves. The fix is to cut a small pass-through hatch in your own wall: the drill stays where it is, you can use it through the hatch whenever you actually need it, and you fetch the physical tool only on the day you drill. @Lazy changes when you take delivery, never whether you depend on him.

5 / 133
Diagram
Figure · Map of this article
Figure · Map of this article
6 / 133

After this article you should be able to answer three questions:

7 / 133
  1. For the very same A↔B pair, why does field injection boot fine while constructor injection blows up immediately?
  2. What does each of the three caches store, and why keep a "factory" instead of just storing the object?
  3. When production throws BeanCurrentlyInCreationException, what is the sane first move: code, config, or design?
8 / 133
Section
1. First, the crash scene: constructor-injected A↔B fails on startup
9 / 133

Start with a "why": why does Spring go to the trouble of a three-level cache? Because circular dependencies are an extremely common structure in real projects — two classes need each other, and interface-oriented layering makes that mutual need look natural. Natural or not, at startup it can blow up in your face.

10 / 133
Diagram
Figure 1 · The A↔B knot
Figure 1 · The A↔B knot
11 / 133

Reproduce the crash. Write two mutually dependent classes, both using constructor injection:

12 / 133
java
package com.example.cycle;import org.springframework.stereotype.Component;@Componentpublic class A {    private final B b;    public A(B b) {        // constructor injection: creating A requires B first        this.b = b;    }}@Componentpublic class B {    private final A a;    public B(A a) {        // constructor injection: creating B requires A first        this.a = a;    }}
13 / 133

Start the app and the log is merciless:

14 / 133
Code
Codetext
Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException:    Error creating bean with name 'a': Requested bean is currently in creation:    Is there an unresolvable circular reference?    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:355)    at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:225)    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:325)    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:519)    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)    ... 30 more
Notes
  • BeanCurrentlyInCreationException literally says "the bean you want is currently being created" — the container notices A is not finished when it needs A
  • The key is not the name but the fact that with constructor injection, "creating A" cannot be paused once it begins: B must arrive as an argument, and B needs A as an argument first — neither can be handed off half-built
15 / 133

Switch to field injection and the same A↔B runs fine:

16 / 133
java
@Componentpublic class A {    @Autowired    private B b;   // new an empty A first, then stuff b in later — a deliverable half-product}@Componentpublic class B {    @Autowired    private A a;}
17 / 133

The difference is one sentence: field injection allows "create the object first, inject properties later", so the container can lend it out before it is complete. The three-level cache is the transit warehouse prepared for that early loan.

18 / 133
Diagram
Figure · Field injection is savable; constructor injection is not
Figure · Field injection is savable; constructor injection is not
19 / 133

Put both styles side by side and the boundary of "can this be rescued" jumps out immediately.

20 / 133
类比|Analogy

constructor injection is like signing for a delivery — the courier must hand you the whole parcel (the dependency) before you are willing to sign (the object is born). Field injection is like a street food stall — take a ticket first (the empty shell is already standing there), the dish is cooked afterwards. When a cycle appears, the "take a ticket" road goes through and the "sign on arrival" road jams completely.

21 / 133
Section
2. What the three levels actually are
22 / 133

DefaultSingletonBeanRegistry holds three key fields whose names already tell most of the story:

23 / 133
Table
LevelFieldWhat it storesWhen written / removed
1singletonObjectsFully usable singletons (Map<String, Object>)Written by addSingleton when creation completes; kept until destruction
2earlySingletonObjectsEarly references (instantiated but not fully initialized)Promoted here after the level-3 factory is called; removed when finally complete
3singletonFactoriesObjectFactory (a factory that produces the early reference, not the object)Written right after instantiation; removed once invoked
24 / 133

Pin down the two terms for readers who have never written Java. Instantiation is just an ordinary Java new A() — memory is allocated, the constructor has run, the object exists, and every field is still null. Initialization is the later stretch where dependencies get pushed in, callbacks run, and the object becomes genuinely usable. All the magic of the three-level cache happens in the seam between "instantiated" and "not yet initialized".

25 / 133
类比|Analogy

level 1 singletonObjects is a shop's finished-goods shelf (assembled, take it as-is); level 2 earlySingletonObjects is the assembly bench for items whose ticket has been called (unboxed, not assembled); level 3 singletonFactories is the wall of pickup slips (the goods are nowhere in sight — the slip merely says how to produce them). The lookup always goes shelf → bench → slip, and a redeemed slip is archived on the bench while being voided on the wall.

26 / 133
Key point

levels 1 and 2 store an "object", but level 3 uniquely stores a "factory". That awkward-looking design is exactly the core of Section 5 — it defers the decision of "whether to build a proxy" until it is truly needed.

27 / 133
Section
3. Source-level timeline: how field-injected A↔B resolves
28 / 133
Animation
Animation · Lookup and promotion
Animation · Lookup and promotion
29 / 133

The same knot seen from the other side — six steps that keep your eyes on what each of the three maps holds at that moment:

30 / 133
Animation
Animation · How the A→B→A knot comes undone
Animation · How the A→B→A knot comes undone
31 / 133

In chronological order, field-injected A↔B unwinds like this (the numbers match the animation):

32 / 133
  1. Create A: reflect an empty shell A; properties not yet populated
  2. Expose A's early reference: put the factory that "knows how to fetch A's early reference" into level 3
  3. A injects B: the container sees A needs B and calls getBean("b")
  4. Create B: likewise instantiate an empty shell B and expose its factory into level 3
  5. B injects A: the container sees B needs A and returns to getBean("a")
  6. Level-3 hit: A is instantiated but not initialized, so the factory from level 3 produces an early reference
  7. Promotion and wrap-up: A's early reference is promoted to level 2; B takes it and completes, entering level 1; A then completes too and enters level 1
33 / 133

Look at the key snippet of doCreateBean — note "expose the factory" happens before population:

34 / 133
java
// AbstractAutowireCapableBeanFactory#doCreateBean (excerpt)// (1) Instantiate: get a raw object with all fields emptyBeanWrapper instanceWrapper = createBeanInstance(beanName, mbd, args);Object bean = instanceWrapper.getWrappedInstance();// (2) Expose the early reference: a "factory" is stored, not the object itselfif (earlySingletonExposure) {    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));}// (3) Populate: if a dependency loops back, it hits the factory abovepopulateBean(beanName, mbd, instanceWrapper);// (4) Initialize: Aware, before/after processing, init methodsObject exposedObject = initializeBean(beanName, bean);
35 / 133

Now the "three-level lookup" in getSingleton, the real entry point of unwinding:

36 / 133
Code
Codejava
// DefaultSingletonBeanRegistry#getSingleton (simplified)protected Object getSingleton(String beanName, boolean allowEarlyReference) {    Object singletonObject = this.singletonObjects.get(beanName);       // (1) level 1    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {        singletonObject = this.earlySingletonObjects.get(beanName);     // (2) level 2        if (singletonObject == null && allowEarlyReference) {            ObjectFactory<?> factory = this.singletonFactories.get(beanName); // (3) level 3            if (factory != null) {                singletonObject = factory.getObject();                   // produce the early reference                this.earlySingletonObjects.put(beanName, singletonObject); // promote to level 2                this.singletonFactories.remove(beanName);                // remove level 3            }        }    }    return singletonObject;}
Notes
  • The lookup order is always level 1 → level 2 → level 3, and it continues downward only if the bean is "currently in creation"
  • On a level-3 hit, it immediately promotes to level 2 and removes level 3, so each object's factory is invoked exactly once
  • Level 2 exists so repeated requests for the same early reference need not re-invoke the factory — which leads to the next section's follow-up

Tip: isSingletonCurrentlyInCreation checks the singletonsCurrentlyInCreation set. That is the very check that backs the BeanCurrentlyInCreationException from step four — a bean entering creation a second time is stopped right here.

37 / 133

The animation gives the overview, but "what each of the three maps holds at this instant" only becomes clear one stop at a time. The debugger's left side is the real call sequence for a field-injected A↔B; the right side refreshes all three tables at every step. Press next through all nine and watch cell ⑦, the single moment an IOU gets redeemed:

38 / 133
Stepper
StepperField-injected A and B, replayed cell by cell1 / 9
Nine steps; cell 7 is the one and only time the IOU is torn off the wall
Code under debug
1getBean("a"); // step 11 reaches a
2createBeanInstance("a"); // 1. reflection news it, fields all null
3addSingletonFactory("a", () -> getEarlyBeanReference(a)); // 2. put the IOU on the wall
4populateBean("a"); // 3. population: needs B
5getBean("b"); // 4. recurse and build B
6addSingletonFactory("b", () -> getEarlyBeanReference(b)); // 5. B pins up its own IOU
7populateBean("b"); // 6. population: needs A
8getSingleton("a", true); // 7. level 1 -> 2 -> 3, redeem it
9addSingleton("b", b); addSingleton("a", a); // 8. both reach level 1
Variables now
singletonObjects{}
earlySingletonObjects{}
singletonFactories{}
singletonsCurrentlyInCreation{}
Call stack
1preInstantiateSingletons
2doGetBean
1Starting point: all three tables empty. The container walks beanDefinitionNames and the first name is a. Who starts first never matters — whichever bean enters first is guaranteed to run into the other.
39 / 133
Section
4. Why three levels instead of two (the main act)
40 / 133

This is the most classic and most disarming question about circular dependencies.

41 / 133

Conclusion first: two levels suffice to unwind the loop (storing the early object); the third level exists for the sole reason of coexisting with AOP proxying.

42 / 133

The key is getEarlyBeanReference:

43 / 133
Code
Codejava
// AbstractAutowireCapableBeanFactory#getEarlyBeanReference (excerpt)protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {    Object exposedObject = bean;    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {        for (SmartInstantiationAwareBeanPostProcessor bp : getBeanPostProcessorCache().smartInstantiationAware) {            exposedObject = bp.getEarlyBeanReference(exposedObject, beanName);        }    }    return exposedObject;}
Notes
  • Inside getEarlyBeanReference, AbstractAutoProxyCreator's counterpart is invoked: if the bean needs an AOP proxy, the proxy is created early right here
  • In other words, level 3 does not store "the early object" but "a factory that decides on demand whether to output the raw object or a proxy"
44 / 133

Why can't two levels do? Suppose level 2 stores "the early object" directly — two traps follow:

45 / 133
Table
If level 2 stores the early object directlyConsequence
When a circular dependency occursThe early reference is taken while it is still unknown whether it will end up proxied; by the time the after-init stage discovers it must be proxied, it is too late — the injector kept the raw object and the proxy never takes effect
Force a proxy at exposure timeEvery bean is forced to build a proxy early whether or not a cycle occurs, breaking the model "AOP proxies are created uniformly at after-init" and possibly producing two proxy instances
46 / 133
  • Level 3 defers "whether to build a proxy" to the exact moment a circular dependency needs it: if nobody references it in a cycle, it flows normally and the proxy is created at "after-init"; if someone does, the factory produces it early (exactly once)
  • Thus, whether or not a cycle occurs, there is exactly one proxy — this is why the third level is indispensable
47 / 133
Note

so the standard answer is "two levels can unwind the loop; the third preserves proxy uniqueness while unwinding". Answering only "the three-level cache solves circular dependencies" loses points.

48 / 133

How does that "defer the decision" machine actually turn? Click through it once and it becomes obvious — cell ④ is the entire reason a third level exists:

49 / 133
Diagram
FlowThe life of one pickup slip: how the proxy decision gets deferred1 / 5
Go from ① to ⑤; if nobody ever redeems a slip, cell ④ simply never happens
→
→
→
→
① Hang the slip the moment instantiation ends
addSingletonFactory writes down 'how to produce this bean's early reference' as a factory and pins it on the wall. Every field is still null, but the object already has a usable address — that is the first gate on whether it can be rescued at all.
All clearTwo levels suffice to unwind the cycle; the third level buys the guarantee 'a proxy is created exactly once'.
50 / 133

While we are here, give that judge from cell ④ — the one deciding whether to wrap a proxy — its own look. Its formal name is AutoProxyCreator (a BeanPostProcessor whose sole job is deciding whether a bean should become an AOP proxy); switch the lab below to "Candidate check" to watch it match advice against types:

51 / 133
Kernel lab
TeaVMRaw object or proxy: what comes out earlyidle
Then open 'Proxy factory' to see how a proxy is assembled on the spot; this cell decides whether cell ⑤ has to report an error
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
52 / 133
Section
5. Why constructor injection cannot be solved, and how @Lazy breaks the cycle
53 / 133

Why is constructor injection unsolvable? Back to Section 1: constructor injection requires the dependency to exist before instantiation, while the essence of a cycle is "A needs B and B needs A" — neither can finish instantiating before the other. At that point the container has not even exposed an early reference — addSingletonFactory is called after createBeanInstance, but the constructor happens before that. No object means no reference to lend; the knot is unsolvable.

54 / 133

Then why can @Lazy break the cycle when it is still a "dependency"?

55 / 133
Code
Codejava
package com.example.cycle;import org.springframework.context.annotation.Lazy;import org.springframework.stereotype.Component;@Componentpublic class A {    private final B b;    public A(@Lazy B b) {   // injects a proxy of B, not B itself        this.b = b;    }}
Notes
  • @Lazy makes the container inject a proxy object instead of the real B. Creating A obtains a "placeholder proxy" and need not create B immediately, breaking the cycle
  • Only when A first actually calls b.xxx() does the proxy fetch (and create) the real B from the container
  • The cost: if B truly has a problem, the error is deferred to the first runtime call instead of startup — harder to diagnose

Caution: @Lazy only "bypasses" the circular dependency, it does not "solve" it. It moves the problem from startup to runtime, and the bidirectional coupling in the design remains.

56 / 133

The punchline of that drill analogy is the hatch: the drill never moves, you can reach it any time, and you only go fetch the physical tool on the day you actually drill. The animation plays that timeline — watch frame ③, where the ring is cut, and frame ⑥, where the error is postponed to:

57 / 133
Animation
Animation · Breaking the cycle with @Lazy: a service door, not the drill
Animation · Breaking the cycle with @Lazy: a service door, not the drill
58 / 133
Section
6. Spring Boot 2.6+ forbids circular references by default
59 / 133

Since Spring Boot 2.6, allow-circular-references defaults to false — meaning the three-level cache machinery still lives in Spring Framework, but Boot does not let it take effect by default:

60 / 133
Code
Codeyaml
# application.yml: explicitly allow circular references (not recommended long-term)spring:  main:    allow-circular-references: true
Notes
  • When disabled, even field-injected A↔B fails at startup with a clear hint to "consider refactoring or use @Lazy"
  • The community trade-off: a circular dependency almost always signals poor responsibility division, so Boot uses "deny by default" to force developers to face it, rather than quietly covering it with a cache
  • If you truly have legacy baggage, use the line above as a temporary pass — but record it as technical debt
61 / 133
Section
7. Three design-level fixes
62 / 133

Rather than leaning on the cache, eliminate the cycle by design. What actually decides the choice is what each option really changes — finish this round of pairing and you will not need to memorise any table:

63 / 133
Match
MatchEach untying trick and the one thing it really changesMatched 0/6 · Missed 0
The left column is code you can write tonight; the right column is the difference it makes inside the container
Pick a card on the left first
64 / 133
Key point

the priority order is always "extract a shared dependency > event-driven > @Lazy". The first two change the structure; @Lazy changes only the timing. Being able to state this priority order usually earns more credit in an interview than reciting the three-level cache.

65 / 133
Section
8. Traps: three boundaries of the three-level cache
66 / 133
Trap

the three-level cache works only for "singleton + field/setter injection". A prototype bean fails immediately, because it never enters singletonObjects and therefore has no warehouse to "lend early" from; constructor injection fails too (Section 5). Only singletons whose dependencies are injected after instantiation can be rescued.

67 / 133

That boundary deserves a hands-on check. The lab below lays out the instance counts across the four scopes — switch to "Count the instances" and you will see a prototype's counter tick up on every single fetch while the bean never enters the pool. No pool, no pickup slip:

68 / 133
Kernel lab
TeaVMWhy a prototype can never break the cycleidle
Compare the singleton and prototype counters first, then switch to 'Who destroys it' for Article 9's 'the container lets go the moment it is built'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
69 / 133
Trap

do not play games with @Async proxies inside a circular dependency. Method-level proxies like @Async also rely on being created at "after-init"; if produced early via the level-3 cache during a cycle, you may get a not-fully-enhanced proxy, showing up as "sometimes async does not work".

70 / 133
Warning

do not "cure" circular dependencies with the cache — use it to "understand" them. Leaving allow-circular-references permanently on and sprinkling @Lazy everywhere hides design flaws inside a cache. The healthy relationship is always one-way dependency.

71 / 133
Section
9. Try it: the full unwinding of the three-level cache
72 / 133

The demo below lays out what each level holds at every step. Switch to "no cache" first and watch the cycle fail on the spot; then switch back to "three-level cache" and compare step by step what each cache stores:

73 / 133
Kernel lab
TeaVMThe full unwinding of the three-level cacheidle
First watch 'no cache' fail, then switch back to 'three-level cache' to compare what each level holds
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
74 / 133
Section
10. First move when you hit a circular dependency?
75 / 133
Decision
Decisionyou inherit a legacy project that fails at startup with `BeanCurrentlyInCreationException`, and you confirm it is an A↔B circular dependency. To restore a runnable state fastest, what is the most reasonable first move?
76 / 133
Section
11. Hands-on labs: lay the unwinding out in front of you
77 / 133

Diagrams are not enough. Run these four kernel experiments in order — thirty seconds each. The first one shatters the illusion that "Spring untangles every cycle".

78 / 133
Kernel lab
TeaVMCircular deps · caches on vs offidle
Switch to 'Caches OFF' and watch A↔B fail outright at step five
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
79 / 133

The very same A↔B pair follows completely different timelines under four injection styles. Run constructor first, then field, then cycle, and you will see the boundary "can a half-product be handed over" with your own eyes:

80 / 133
Kernel lab
TeaVMThree injection stylesidle
Cycle through constructor / setter / field, then open 'With a cycle'
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
81 / 133

We keep saying the early reference is exposed after instantiation and before population. Verify it against the full eight-phase timeline — note where addSingletonFactory sits, exactly between "instantiation" and "population":

82 / 133
Kernel lab
TeaVMBean lifecycle · locate the exposure stepidle
Find which two phases the factory lands between; then switch to prototype and see it never enter the pool
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
83 / 133

One layer deeper: how does the container even know class A must be built? Scanning annotations first produces a recipe list — a BeanDefinition, the metadata object describing how to build a bean, not the bean itself. Once you see that layer, you understand a cycle is a problem of the wiring phase, unrelated to scanning or registration:

84 / 133
Kernel lab
TeaVMFrom @Component to BeanDefinitionidle
Start with scan and registry, then use 'scope / lazy attrs' to see how creation timing changes
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
85 / 133
Section
11.1 Switch to the command line: interrogate the cycle
86 / 133

Once the buttons are done, ask the container yourself. This console is wired to the real in-browser kernel and every echoed line is computed on the spot — the order below is the order of a real production triage:

87 / 133
Console
88 / 133
Tip

run lab circular cache and lab circular nocache back to back. For the very same A↔B, the first one is rescued by a pickup slip before it ever reaches cell seven, while the second has no slip to tear at all — that contrast is more direct than any source walkthrough, and it is exactly the two ends of the debugger above.

89 / 133
Section
12. Sandbox: read the fate before the fix
90 / 133

Three switches jointly decide the outcome of a cycle. Drag them and the result appears instantly — play with the conclusions first, memorize source later:

91 / 133
Sandbox
SandboxCan this cycle be untangled at all
Result
startup: OK
# level 1 singletonObjects : {a=A, b=B}
# level 2 earlySingletonObjects: empty (cleared when done)
# level 3 singletonFactories : empty (slip already redeemed)
Verdict: singleton + field injection = the three-level cache's home turf
Boots fine: A's pickup slip saves B, then both reach level 1
92 / 133

How to read this sandbox: judge scope first, then injection style, and only then the switch. All four prototype rows are instant losses; among singleton rows only constructor dies. The cells the three-level cache can actually rescue are forever "singleton + field/setter injection" — every other dial merely changes when the error appears.

93 / 133
Section
13. Error quick-reference
94 / 133

Beginners drown in long stack traces. Every snippet below is copy-paste searchable — learn to read them first.

95 / 133

This is what Boot's cycle report looks like; the indented tree is the shape of the loop:

96 / 133
text
The dependencies of some of the beans in the application context form a cycle:   userService defined in file [UserServiceImpl.class]┌─────┐|  orderService defined in file [OrderServiceImpl.class]↑     ↓|  userService (above)└─────┘Action:Relying upon circular references is discouraged and they are prohibited by default.Update your application to remove the dependency cycle between beans. As a lastresort, it may be possible to break the cycle via a constructor argument beingmarked @Lazy. Alternatively, consider changing some injections to be setter orfield based, or use ObjectProvider<T> as a dependency.
97 / 133

Only three reading rules: ① the lines boxed by ┌─────┐ … ↑ ↓ … └─────┘ are the ones actually on the cycle; anything outside the box is merely the entry point; ② whichever bean is printed first is not the villain — it is just the one created first; ③ the Action: block is the official fix checklist, and @Lazy is explicitly labelled a "last resort".

98 / 133
Table
Error text (searchable fragment)Real cause30-second self-rescueWhere to dig deeper
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?The same bean re-entered the creation flow and was stopped by singletonsCurrentlyInCreationLook for UnsatisfiedDependencyException: ... constructor argument in the trace — if present it is constructor injection, which the cache cannot help with; switch to field/setter or add @LazyThis article, Sections 1 and 5
The dependencies of some of the beans in the application context form a cycleBoot 2.6+ forbids circular references by default and blocks startup in a pre-checkRead the indented tree to name the beans on the cycle, prefer extracting a shared dependency; use @Lazy temporarily rather than flipping the global switchThis article, Sections 6 and 7
Error creating bean with name 'a': Bean with name 'a' has been injected into other beans [b] in its raw version as part of a circular reference, but has eventually been wrappedWhat got lent early was the raw object, yet a proxy was created later at after-init — the two sides hold different instancesSomeone on this cycle needs an AOP proxy; add @Lazy at the injection point to stop the early loan, or inspect custom SmartInstantiationAwareBeanPostProcessor implementationsSection 4 here + Section 4 of article 9
Scope 'prototype' is not active for the current thread / repeated BeanCurrentlyInCreationException when prototypes inject each otherPrototype beans are never written into singletonObjects, so there is no warehouse to lend earlyMake it a singleton; if you truly need several instances, inject ObjectProvider<T> and call getObject() yourselfArticle 9, Section 5
UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through constructor parameter 0No candidate bean found for a constructor argument (often co-occurring with a cycle)First check whether you are self-invoking with this.xxx(), then whether @Component is missing or the package is outside the scan pathArticles 6 and 7
Startup still explodes on a constructor cycle although spring.main.allow-circular-references=true is setThat switch only permits cycles "the three-level cache can actually unwind"If you keep constructor injection, forget the switch; use @Lazy or refactor to a one-way dependencySection 6 here
BeanDefinitionOverrideException: Invalid bean definition with name 'a'The same bean name was registered twice (duplicate @ComponentScan, or XML mixed with annotations)This is not a circular dependency — do not touch the cache switch; unify names or drop the duplicate scanArticle 7
99 / 133
Tip

when searching an error, quote only the first fragment after the colon (e.g. has been injected into other beans) — hit rates are far higher than searching the whole sentence, because different Spring versions append extra clauses.

100 / 133

The first row of that table is this article's headline exception. Below is its complete crime scene — do not read the verdict yet, pick the frame you think is the culprit:

101 / 133
Triage
Error triageBeanCurrentlyInCreationException

A class gains a third constructor dependency and the very first local run dies. Three phrases keep repeating in the log, and none of them is the answer by itself: currently in creation, form a cycle, Action.

APPLICATION FAILED TO START
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'a' defined in file [A.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'b': Requested bean is currently in creation: Is there an unresolvable circular reference?
at org.springframework.beans.factory.support.ConstructorResolver.resolveConstructorArguments(ConstructorResolver.java:689)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:601)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:355)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:225)
at com.example.cycle.B.<init>(B.java:14)
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| a defined in file [A.class]
↑ ↓
| b defined in file [B.class]
└─────┘
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
102 / 133
Section
14. Checkpoints
103 / 133
Quiz
Check yourselfField-injected A↔B singletons boot successfully. What is the single most important precondition?
Pick one — you get feedback right away
104 / 133
Quiz
Check yourselfWhy must level 3 store an ObjectFactory instead of simply storing the early object like level 2?
Pick one — you get feedback right away
105 / 133
Section
15. Exercises
106 / 133
Section
Tier 1 · Follow along
107 / 133

Create two mutually dependent classes with field injection, boot it, then convert one to constructor injection and copy both logs into your notes. Fully runnable code:

108 / 133
java
package com.example.cycle;import org.springframework.stereotype.Component;@Componentpublic class MailService {    @org.springframework.beans.factory.annotation.Autowired    private SmsService sms;          // field injection: born first, wired later    public String send(String to) {        return "mail->" + to + " | via " + sms.getClass().getSimpleName();    }}@Componentpublic class SmsService {    @org.springframework.beans.factory.annotation.Autowired    private MailService mail;        // the reverse edge, forming a Mail ↔ Sms cycle    public String send(String to) {        return "sms->" + to;    }}
109 / 133
java
package com.example.cycle;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplicationpublic class CycleDemo {    public static void main(String[] args) {        try (ConfigurableApplicationContext ctx = SpringApplication.run(CycleDemo.class, args)) {            MailService mail = ctx.getBean(MailService.class);            SmsService sms = ctx.getBean(SmsService.class);            System.out.println("[result] " + mail.send("bob"));            System.out.println("[result] " + sms.send("bob") + " | back-ref="                    + (mail != null && sms != null));        }    }}
110 / 133

Expected output (banner omitted; only the two key lines matter):

111 / 133
text
[result] mail->bob | via SmsService[result] sms->bob | back-ref=true
112 / 133

Now add one config line to explicitly re-enable what Boot 2.6+ forbids by default, and compare:

113 / 133
yaml
# src/main/resources/application.ymlspring:  main:    allow-circular-references: true
114 / 133

Then convert MailService to constructor injection:

115 / 133
java
@Componentpublic class MailService {    private final SmsService sms;    public MailService(SmsService sms) { this.sms = sms; }   // this one line only}
116 / 133

Boot again and this block is guaranteed to appear (copy it into your notes — it is the canonical message):

117 / 133
Code
Codetext
***************************APPLICATION FAILED TO START***************************Description:The dependencies of some of the beans in the application context form a cycle:   mailService defined in file [com/example/cycle/MailService.class]┌─────┐|  smsService defined in file [com/example/cycle/SmsService.class]↑     ↓|  mailService (above)└─────┘
Notes

Note: read both output lines — via SmsService proves A holds B, and back-ref=true proves B really holds A. The sign that the knot came undone is not "fewer log lines", it is both directions being callable.

118 / 133
Section
Tier 2 · Variant
119 / 133

Goal: keep the cycle, add exactly one annotation, and get it running.

120 / 133

Hint: write @Lazy on the constructor parameter of MailService (public MailService(@Lazy SmsService sms)); change nothing else.

121 / 133

You should observe three things: ① startup succeeds and form a cycle disappears; ② printing sms.getClass().getName() yields a proxy class name such as ...$$SpringCGLIB$$0 rather than SmsService; ③ deliberately put throw new IllegalStateException("boom") inside SmsService's constructor and you will find startup no longer fails — the exception is deferred until the first real sms.send(...) call. That is exactly the price of @Lazy moving the problem from startup time to runtime.

122 / 133
Section
Tier 3 · Build one
123 / 133

Build a tiny "dual-write notifier": UserService and AuditService depend on each other (user changes must be audited, auditing must look users up). The end state must have no cycle and no @Lazy.

124 / 133

Acceptance checklist:

125 / 133
  • [ ] You can draw the dependency arrows before and after, and every arrow points the same way afterwards
  • [ ] You tried at least all three options and wrote down the trade-off: extract a shared ChangeLog, event-driven via ApplicationEventPublisher, lazy retrieval via ObjectProvider<AuditService>
  • [ ] application.yml contains no allow-circular-references=true
  • [ ] Zero occurrences of BeanCurrentlyInCreationException and of form a cycle in the startup log
  • [ ] You can explain why event-driven turns a two-way call into a one-way notification (the publisher never knows the listener)
126 / 133
Section
16. Self-check
127 / 133
Self-check

without looking anything up, name the three cache fields and what each stores — level 1 finished product, level 2 early reference, level 3 the factory. Miss one and go back to Section 2.

128 / 133
Self-check

can you explain "two levels unwind the cycle, so why a third"? The keywords must be proxy uniqueness and getEarlyBeanReference.

129 / 133
Self-check

for a constructor cycle, what options exist besides refactoring, and which of them change timing versus structure?

130 / 133
Self-check

why does a prototype scope always fail? The answer must land on "it never enters singletonObjects, so there is no warehouse to lend early".

131 / 133
Self-check

when Boot prints form a cycle, can you pick the beans on the cycle straight out of the indented tree?

132 / 133
Mnemonic

born first, wired later — one slip, redeemed once; constructor cycles ignore the cache, @Lazy only moves the clock.

133 / 133
Summary

a circular dependency is no monster but a puzzle you can reason through. Remember three things — the precondition for unwinding is "singleton + field/setter injection" (the object can be born before being wired), while constructor injection and prototype cannot be rescued; the division of the three levels is level 1 = finished product, level 2 = early reference, level 3 = the factory producing that reference; and the meaning of level three is deferring "whether to build a proxy" until it is truly needed, guaranteeing exactly one proxy. In engineering, though, the best answer is always to keep dependencies one-way — a cache can cushion, but it cannot cushion a bad design.