DEV Community

Cover image for How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection
Uncle Pushui
Uncle Pushui

Posted on

How Cabloy/Vona Avoids NestJS Circular Dependencies: A Source-Code Walkthrough of Lazy Property Injection

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
}
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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 });
  };
}
Enter fullscreen mode Exit fullscreen mode
  • It reads the property type from the design:type metadata generated by TypeScript's emitDecoratorMetadata, 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.addUse to 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
}
Enter fullscreen mode Exit fullscreen mode

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__
}
Enter fullscreen mode Exit fullscreen mode

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];
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Three implementation highlights:

  1. 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;
  2. The cache slot is an instance-level plain object SymbolBeanInstancePropsLazy — after first access, the property degrades to "pseudo-static" with zero lookup cost thereafter;
  3. The enumerable: true / configurable: true combination 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;
}
Enter fullscreen mode Exit fullscreen mode
  • 'app' / 'ctx' fall into _getBeanSelectorInner: the container instance cache key is beanFullName or beanFullName#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.init static parameters → useOptions.selector string expression (evaluation context includes self/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;
}
Enter fullscreen mode Exit fullscreen mode

Cache key generation:

function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) {
  if (!withSelector) return beanFullName;
  const isSelectorValid = !isNilOrEmptyString(selector);
  return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Getters never execute during the instantiation phase (no mutual touching);
  2. 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;
}
Enter fullscreen mode Exit fullscreen mode

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
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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(...), or this.app.scope(...) does not by itself require adding the target module to the caller's vonaModule.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:

  1. Zero constructor dependencies — the instantiation phase touches no dependencies;
  2. Lazy property getter — dependency resolution is deferred to first access;
  3. 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.


References and Source Code

NestJS

CabloyJS / Vona

Top comments (2)

Collapse
 
eliasdevv profile image
elias-devv •

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

Collapse
 
uncle-pushui profile image
Uncle Pushui •

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