Circular dependency is not about "can it be resolved?" — it is about "when does resolution happen?"
NestJS circular dependencies are a frequently discussed topic: two Services inject each other through their constructors, and the instantiation moment falls into a "chicken-or-egg" deadlock that can only be broken with forwardRef(). Cabloy/Vona does not have this problem at the architectural level — not because it implements smarter cycle detection, but because it defers dependency resolution to first access.
This article uses Cabloy/Vona's BeanContainer and @Use decorator source code to break down the complete implementation of lazy property injection.
The Short Conclusion
| Dimension | NestJS Constructor Injection | Vona Lazy Property Injection |
|---|---|---|
| Injection timing | Synchronous during instantiation | Deferred to first property access |
| Circular dependency | Requires forwardRef() + deferred cycle resolution |
Naturally safe, no special handling |
| Constructor parameters | Dependencies passed as constructor arguments | Zero constructor dependencies, only getters |
| Resolution entry | Container recursively resolves during resolve()
|
Getter triggers container cache lookup |
| Caching strategy | Stored in container after instantiation | Entered into container cache before __init__
|
In one sentence: NestJS is "inject at construction time, resolve eagerly" — cycles deadlock during instantiation. Vona is "register at declaration time, resolve on access" — the instantiation phase never touches dependencies, and circular dependencies are dissolved by timing.
The Root of NestJS Circular Dependencies
First, let us understand where NestJS's problem lies.
The Timing Dilemma of Constructor Injection
NestJS dependency injection happens during the constructor phase. When the container resolves A:
@Injectable()
class ServiceA {
constructor(private readonly b: ServiceB) {} // Constructor parameter = dependency
}
The container needs to create B before it can create A. But if B's constructor also depends on A:
@Injectable()
class ServiceB {
constructor(private readonly a: ServiceA) {}
}
The resolution chain becomes: new A() → needs B → new B() → needs A → A is not yet constructed → deadlock.
forwardRef Is a Workaround, Not an Elimination
NestJS uses forwardRef() and InjectionToken to defer the resolution order, letting one side complete instantiation first, then injecting the other side afterward. But this is merely a workaround:
- Developers must manually identify cycles and annotate with
forwardRef; - Over-long cycle chains may still encounter "instance not ready" problems;
- It adds implicit complexity to the code.
Vona's design chose a different path: it does not give circular dependencies any opportunity to arise in the first place.
Vona's Three Lines of Defense
Vona structurally does not have an "instantiation-phase" circular dependency problem. The core difference is that dependency resolution is moved from instantiation time to first access time. Three mechanisms ensure this:
1. No Constructor Injection
Vona's Service/Bean always extends BeanBase, and the constructor accepts no dependencies. After instance creation, the container fills in app / ctx references, and dependencies are declared on properties via the @Use(...) decorator.
2. Lazy Property Injection (the Key Mechanism)
Dependencies declared with @Use are not injected at Bean creation time. Instead, a getter is installed using Object.defineProperty. On first property access, the getter resolves the target Bean from the container and caches the result.
3. Container Itself Is Runtime Lookup
BeanContainer.create returns a lazy Proxy. this.bean.xxxService, this.scope('module-x') all resolve from the container by bean full name at access time, producing no static wiring.
Source Code Breakdown: Five Stages
Below, we break down the source code across five stages: declaration → storage → mounting → resolution → caching.
Stage 1: Declaration — @Use Decorator Does Only Two Things
// use.ts
export function Use(options?: IDecoratorUseOptions | string): PropertyDecorator & MethodDecorator {
return function (target: object, prop: MetadataKey, descriptor?: PropertyDescriptor) {
// ① Read property type from TS design:type metadata
const beanClass = appMetadata.getDesignType(target, prop) as Constructable;
// ② Class → bean full name mapping
let beanFullName: string | undefined = options.beanFullName;
if (!beanFullName) {
beanFullName = appResource.getBeanFullName(beanClass);
}
// ③ Register metadata — no instantiation happens here
appResource.addUse(target, { ...options, prop, beanFullName, descriptor });
};
}
- It reads the property type from the
design:typemetadata generated by TypeScript'semitDecoratorMetadata, so@Use()without arguments can still determine the target Bean; - The class → bean full name (
module-name.scene.name) mapping must exist, otherwise it throws directly; - It calls
appResource.addUseto register metadata — no instantiation occurs.
Stage 2: Storage — Metadata Map Keyed by Property Name, Inherited Along the Prototype Chain
// resource.ts
addUse(target: object, options: IDecoratorUseOptionsBase) {
registerMappedClassMetadataKey(target, SymbolDecoratorUse);
const uses = appMetadata.getOwnMetadataMap(true, SymbolDecoratorUse, target);
uses[options.prop] = options;
}
getUses(target: object): Record<MetadataKey, IDecoratorUseOptionsBase> | undefined {
return appMetadata.getMetadata(SymbolDecoratorUse, target); // Note: getMetadata
}
Writing uses getOwnMetadataMap (attached to the class prototype, keyed by prop), reading uses getMetadata (traverses up the prototype chain) — this means subclasses automatically inherit all @Use dependencies declared by the parent class.
Stage 3: Mounting — Only Install Getter After Instantiation, No Resolution
The strict ordering in the Bean instance creation flow:
// beanContainer.ts
private _createBeanInstance(...) {
const beanInstance = this._prepareBeanInstance(...); // new + attach app/ctx
this[SymbolBeanContainerInstances][key] = beanInstance; // ① Enter container cache first
this._initBeanInstance(beanFullName, beanInstance, args); // ② Inject getter → __init__
}
This ordering ensures: even if a Bean accesses a dependency property during __init__, triggering reverse resolution, the container can directly hit the cache and return the already-registered instance.
The core implementation of injection:
// beanContainer.ts
private _injectBeanInstancePropLazy(beanInstance, prop, targetBeanFullName, useOptions) {
const self = this;
Object.defineProperty(beanInstance, prop, {
enumerable: true, // Property is enumerable (visible in serialization/spread)
configurable: true, // Redefinable for HMR
get() {
if (!beanInstance[SymbolBeanInstancePropsLazy])
beanInstance[SymbolBeanInstancePropsLazy] = {};
if (!beanInstance[SymbolBeanInstancePropsLazy][prop]) {
// First access: actually resolve, then cache to instance-private slot
beanInstance[SymbolBeanInstancePropsLazy][prop] =
self._injectBeanInstanceProp(beanInstance, targetBeanFullName, useOptions);
// App-level container additionally registers this dependency edge to HMR
self.containerType === 'app' &&
self.app.meta.hmr.addBeanInstanceProp(beanInstance, prop, targetBeanFullName);
}
return beanInstance[SymbolBeanInstancePropsLazy][prop];
},
});
}
Three implementation highlights:
-
The getter closure captures the container that installed it (
self) — properties installed by the app container always go through app resolution, while those installed by the ctx (request-level) container go through ctx resolution; -
The cache slot is an instance-level plain object
SymbolBeanInstancePropsLazy— after first access, the property degrades to "pseudo-static" with zero lookup cost thereafter; - The
enumerable: true / configurable: truecombination is deliberate: enumerable ensures consistent instance export behavior, configurable lets HMR tear down and reinstall.
Stage 4: Resolution — Three Injection Scopes
First access triggers _injectBeanInstanceProp:
// beanContainer.ts
private _injectBeanInstanceProp(
beanInstance,
targetBeanFullName: string,
useOptions: IDecoratorUseOptionsBase,
) {
const injectionScope = useOptions.injectionScope ?? 'app';
if (injectionScope === 'app') {
targetInstance = this.app.bean._getBeanSelectorInner(...); // Application-level singleton
} else if (injectionScope === 'ctx') {
targetInstance = this._getBeanSelectorInner(...); // Request-level singleton
} else if (injectionScope === 'new') {
targetInstance = this._newBeanInner(...); // New instance each time
}
return targetInstance;
}
-
'app'/'ctx'fall into_getBeanSelectorInner: the container instance cache key isbeanFullNameorbeanFullName#selector, creating only if=== undefined; -
'new'creates a new instance each time, but since the outer getter already caches, only one resolution happens per property lifecycle; - The selector parameter is taken from three sources by priority: the decorated property's descriptor getter →
useOptions.initstatic parameters →useOptions.selectorstring expression (evaluation context includesself/app/ctx).
Stage 5: Caching — Container Instance Cache
The caching logic of _getBeanSelectorInner:
// beanContainer.ts
_getBeanSelectorInner<T>(
beanFullName: Constructable<T> | string,
withSelector?: boolean,
...args
): T {
const beanOptions = appResource.getBean(beanFullName as any);
const fullName = beanOptions.beanFullName;
const key = __getSelectorKey(fullName, withSelector, args[0]);
if (this[SymbolBeanContainerInstances][key] === undefined) {
this._newBeanInner(true, fullName, withSelector, ...args);
}
return this[SymbolBeanContainerInstances][key] as T;
}
Cache key generation:
function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) {
if (!withSelector) return beanFullName;
const isSelectorValid = !isNilOrEmptyString(selector);
return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`;
}
Complete Timing of A↔B Circular Dependency
Using two Services A and B that @Use each other as an example, here is the complete timing:
| Moment | Action | Result |
|---|---|---|
| Declaration |
@Use registers A→B, B→A mappings |
No instance interaction |
Business triggers _getBean('A')
|
new A(), install lazy getter, enter container cache |
A fully exists |
First access a.serviceB
|
Getter executes → container creates B (B's construction and getter installation also do not touch A), enters cache | B fully exists |
B internally accesses b.serviceA
|
Getter executes → container cache hit → returns existing A | Cycle closes, no reentrance |
Two invariants guarantee cycle safety:
- Getters never execute during the instantiation phase (no mutual touching);
-
The dependency target is guaranteed to be registered in the container cache at resolution time (the "enter cache before init" ordering covers even the aggressive case of accessing dependencies during
__init__).
Real Code Example
Vona's @Use Usage
import { BeanBase, Use } from 'zova';
import { Service } from 'zova-module-a-bean';
@Service()
export class BeanRouter extends BeanBase {
@Use()
$$sysRouter: SysRouter;
@Use()
$$modelPageData: ModelPageData;
}
Note: zero constructor parameters; all dependencies are declared on properties via @Use. Resolution is triggered only when this.$$sysRouter is accessed.
Equivalent Path in Vona Backend Business Code
In the current repository, backend business code more commonly uses this.scope('module-x').service.yyy and this.bean.xxx instead of property-level @Use — these go through the same container's runtime lookup path:
// BeanContainer.create returns a Proxy
static create(app: VonaApplication, ctx: VonaContext | undefined) {
const beanContainer = new BeanContainer(app, ctx);
return new Proxy(beanContainer, {
get(obj, prop) {
if (typeof prop === 'symbol') return obj[prop];
if (obj[prop]) return obj[prop];
return obj._getBean(prop as any); // Resolves on access
},
});
}
This is essentially the "method-call version" of the same lazy resolution mechanism. The two paths differ only in declaration style (decorator metadata vs string lookup); the resolution and caching mechanisms are fully shared.
Failure Boundary and HMR
Lazy property injection is not without boundaries to be aware of:
_removeBean only destroys the instance in the container cache and calls its __dispose__; the cached reference in other Bean instances' SymbolBeanInstancePropsLazy is not automatically cleared — this is exactly why HMR needs addBeanInstanceProp to register dependency edges for rewiring.
So in hot-reload scenarios, the framework can use the registered dependency edges to find all properties that reference the replaced Bean, tear down old getters, install new ones, clear cache slots, and complete dependency rewiring.
Module-Level Circular Dependencies
Beyond instance-level circular dependencies, Vona also makes design-level provisions at the module level. Per the documentation:
Using
this.$scope.<fixedModule>,app.scope(...), orthis.app.scope(...)does not by itself require adding the target module to the caller'svonaModule.dependencies. Lookup does not create a module dependency edge, so lookup alone cannot create a circular dependency.
This means module-level circular dependencies are also avoided by design: cross-module lookups do not produce dependency declarations and do not constitute module dependency edges, so modules cannot form circular dependencies merely by "using each other's services."
Summary
The root cause of NestJS's circular dependency problem lies in its design choice of "constructor injection + eager resolution": dependencies must be resolved at instantiation time, circular references inevitably lead to deadlock, and forwardRef can only defer the resolution order.
Vona's lazy property injection mechanism dissolves this problem through three design decisions:
- Zero constructor dependencies — the instantiation phase touches no dependencies;
- Lazy property getter — dependency resolution is deferred to first access;
- Enter cache before init — guarantees the dependency target is always ready at resolution time.
Circular dependencies in Vona require no forwardRef, no special annotation, no developer identification of cycles — they are naturally safe by timing. This is not smarter cycle detection; it is an architectural choice that gives circular dependencies no opportunity to arise.
Top comments (2)
i get that resolution is deferred until the injector builds the graph, which sidesteps the import cycle at load time, curious, how does this hold up with lazy‑loaded modules or dynamic providers that modify the graph at runtime
Good question — and the answer differs for each case, because there's no "injector pass that
builds the graph" here in the first place. Dependencies are only declared; the container keeps
an instance cache and resolves on demand. Nothing is assembled up front, so there is no
assembly order to preserve and nothing a late addition could invalidate.
Lazy-loaded modules: Vona doesn't register modules on demand at runtime. Modules are enumerated
at startup, and only a module's top-level bean is instantiated then — everything else is created
on first access. So the laziness sits below module registration rather than at it.
Dynamic providers — several interchangeable implementations behind one interface — are a
first-class pattern rather than a runtime mutation. Implementations register as peers under a
named scene (auth, captcha, pay, image, file), each in its own module, and the consumer asks for
the scene by name. The container resolves whichever implementation is active at that moment, and
which one is active is a configuration or request-level decision.
The decisive point: the consumer holds a name, not a reference to any implementation. So adding
implementations adds no dependency edges at all, and none of them can form a cycle with its
consumer — the same reason the mechanism never needed a cycle