Circular Dependencies and the Three-Level Cache
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.
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).
@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.

After this article you should be able to answer three questions:
- For the very same A↔B pair, why does field injection boot fine while constructor injection blows up immediately?
- What does each of the three caches store, and why keep a "factory" instead of just storing the object?
- When production throws
BeanCurrentlyInCreationException, what is the sane first move: code, config, or design?
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.

Reproduce the crash. Write two mutually dependent classes, both using constructor injection:
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; }}Start the app and the log is merciless:
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 moreBeanCurrentlyInCreationExceptionliterally 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
Switch to field injection and the same A↔B runs fine:
@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;}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.

Put both styles side by side and the boundary of "can this be rescued" jumps out immediately.
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.
DefaultSingletonBeanRegistry holds three key fields whose names already tell most of the story:
| Level | Field | What it stores | When written / removed |
|---|---|---|---|
| 1 | singletonObjects | Fully usable singletons (Map<String, Object>) | Written by addSingleton when creation completes; kept until destruction |
| 2 | earlySingletonObjects | Early references (instantiated but not fully initialized) | Promoted here after the level-3 factory is called; removed when finally complete |
| 3 | singletonFactories | ObjectFactory (a factory that produces the early reference, not the object) | Written right after instantiation; removed once invoked |
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".
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.
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.

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:

In chronological order, field-injected A↔B unwinds like this (the numbers match the animation):
- Create A: reflect an empty shell A; properties not yet populated
- Expose A's early reference: put the factory that "knows how to fetch A's early reference" into level 3
- A injects B: the container sees A needs B and calls
getBean("b") - Create B: likewise instantiate an empty shell B and expose its factory into level 3
- B injects A: the container sees B needs A and returns to
getBean("a") - Level-3 hit: A is instantiated but not initialized, so the factory from level 3 produces an early reference
- 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
Look at the key snippet of doCreateBean — note "expose the factory" happens before population:
// 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);Now the "three-level lookup" in getSingleton, the real entry point of unwinding:
// 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;}- 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.
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:
getBean("a"); // step 11 reaches acreateBeanInstance("a"); // 1. reflection news it, fields all nulladdSingletonFactory("a", () -> getEarlyBeanReference(a)); // 2. put the IOU on the wallpopulateBean("a"); // 3. population: needs BgetBean("b"); // 4. recurse and build BaddSingletonFactory("b", () -> getEarlyBeanReference(b)); // 5. B pins up its own IOUpopulateBean("b"); // 6. population: needs AgetSingleton("a", true); // 7. level 1 -> 2 -> 3, redeem itaddSingleton("b", b); addSingleton("a", a); // 8. both reach level 1| singletonObjects | {} |
| earlySingletonObjects | {} |
| singletonFactories | {} |
| singletonsCurrentlyInCreation | {} |
preInstantiateSingletonsdoGetBeanThis is the most classic and most disarming question about circular dependencies.
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.
The key is getEarlyBeanReference:
// 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;}- 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"
Why can't two levels do? Suppose level 2 stores "the early object" directly — two traps follow:
| If level 2 stores the early object directly | Consequence |
|---|---|
| When a circular dependency occurs | The 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 time | Every 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 |
- 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
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.
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:
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:
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.
Then why can @Lazy break the cycle when it is still a "dependency"?
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; }}@Lazymakes 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.
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:

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:
# application.yml: explicitly allow circular references (not recommended long-term)spring: main: allow-circular-references: true- 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
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:
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.
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.
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:
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".
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.
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:
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".
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:
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":
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:
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:
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.
Three switches jointly decide the outcome of a cycle. Drag them and the result appears instantly — play with the conclusions first, memorize source later:
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
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.
Beginners drown in long stack traces. Every snippet below is copy-paste searchable — learn to read them first.
This is what Boot's cycle report looks like; the indented tree is the shape of the loop:
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.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".
| Error text (searchable fragment) | Real cause | 30-second self-rescue | Where 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 singletonsCurrentlyInCreation | Look 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 @Lazy | This article, Sections 1 and 5 |
The dependencies of some of the beans in the application context form a cycle | Boot 2.6+ forbids circular references by default and blocks startup in a pre-check | Read the indented tree to name the beans on the cycle, prefer extracting a shared dependency; use @Lazy temporarily rather than flipping the global switch | This 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 wrapped | What got lent early was the raw object, yet a proxy was created later at after-init — the two sides hold different instances | Someone on this cycle needs an AOP proxy; add @Lazy at the injection point to stop the early loan, or inspect custom SmartInstantiationAwareBeanPostProcessor implementations | Section 4 here + Section 4 of article 9 |
Scope 'prototype' is not active for the current thread / repeated BeanCurrentlyInCreationException when prototypes inject each other | Prototype beans are never written into singletonObjects, so there is no warehouse to lend early | Make it a singleton; if you truly need several instances, inject ObjectProvider<T> and call getObject() yourself | Article 9, Section 5 |
UnsatisfiedDependencyException: Error creating bean with name 'a': Unsatisfied dependency expressed through constructor parameter 0 | No 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 path | Articles 6 and 7 |
Startup still explodes on a constructor cycle although spring.main.allow-circular-references=true is set | That 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 dependency | Section 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 scan | Article 7 |
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.
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:
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.
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:
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; }}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)); } }}Expected output (banner omitted; only the two key lines matter):
[result] mail->bob | via SmsService[result] sms->bob | back-ref=trueNow add one config line to explicitly re-enable what Boot 2.6+ forbids by default, and compare:
# src/main/resources/application.ymlspring: main: allow-circular-references: trueThen convert MailService to constructor injection:
@Componentpublic class MailService { private final SmsService sms; public MailService(SmsService sms) { this.sms = sms; } // this one line only}Boot again and this block is guaranteed to appear (copy it into your notes — it is the canonical message):
***************************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)└─────┘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.
Goal: keep the cycle, add exactly one annotation, and get it running.
Hint: write @Lazy on the constructor parameter of MailService (public MailService(@Lazy SmsService sms)); change nothing else.
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.
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.
Acceptance checklist:
- [ ] 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 viaApplicationEventPublisher, lazy retrieval viaObjectProvider<AuditService> - [ ]
application.ymlcontains noallow-circular-references=true - [ ] Zero occurrences of
BeanCurrentlyInCreationExceptionand ofform a cyclein 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)
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.
can you explain "two levels unwind the cycle, so why a third"? The keywords must be proxy uniqueness and getEarlyBeanReference.
for a constructor cycle, what options exist besides refactoring, and which of them change timing versus structure?
why does a prototype scope always fail? The answer must land on "it never enters singletonObjects, so there is no warehouse to lend early".
when Boot prints form a cycle, can you pick the beans on the cycle straight out of the indented tree?
born first, wired later — one slip, redeemed once; constructor cycles ignore the cache, @Lazy only moves the clock.
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.