DEV Community

Cover image for NestJS Dynamic Providers vs Cabloy/Vona Provider Scene: Two Ways to Bind One Interface to Many Implementations
Uncle Pushui
Uncle Pushui

Posted on

NestJS Dynamic Providers vs Cabloy/Vona Provider Scene: Two Ways to Bind One Interface to Many Implementations

Both frameworks answer "one interface, many implementations, pick one at runtime". They disagree not on the implementation technique, but on when the binding happens.

"One interface, several implementations, choose one by configuration or context" is a question every backend framework has to answer. Payments must switch between Stripe, PayPal, and a mock. File storage must switch between local disk and object storage. Captcha must switch between an image puzzle and an SMS code.

NestJS answers it with Dynamic Providers; Cabloy/Vona answers it with a Provider Scene. Both work, but the assembly models are fundamentally different — and that difference propagates all the way to runtime switching, module decoupling, and the more fundamental question of what a consumer actually holds a reference to.


The Short Conclusion

Dimension NestJS Dynamic Providers Vona Provider Scene
Binding timing Once, during module initialization On every resolution, by name
Expression { provide: TOKEN, useClass: X } @PayProvider() registers into a scene
Many implementations per interface Aggregated via multi: true, or one token each N registered under one scene, peers by default
Where implementations live Usually in one module's providers array Each implementation is a separate module
What the consumer references An injected token A name
Runtime switching Effectively fixed after init (overrideProvider only applies while compiling a test module) VonaConfig.onions + per-request ctx.onionsDynamic
Contribution to dependency structure Participates in the assembly graph Produces no static dependency edges

In one sentence: NestJS binds a token to an implementation in the single assembly phase; Vona defers "which implementation" to every resolution, so it never has an assembly order that could be broken.


The Problem: One Interface, Many Implementations

First, the shared problem. Business code wants to be written like this:

// The consumer depends only on the abstraction
const result = await payService.startPayment({ provider: 'stripe', orderId, amount });
Enter fullscreen mode Exit fullscreen mode

It should not care how Stripe's SDK, PayPal's OAuth flow, or the mock's fixtures are implemented, and adding a new payment method should not force a change here.

The divergence appears at one step: how payService obtains the stripe implementation object.


NestJS's Answer: Bind During Assembly

NestJS expresses this relationship as a provider definition, most commonly useClass:

// pay.module.ts
@Module({
  providers: [
    { provide: PAY_PROVIDER, useClass: StripePayProvider },
  ],
  exports: [PAY_PROVIDER],
})
export class PayModule {}
Enter fullscreen mode Exit fullscreen mode

When several implementations must coexist, multi: true aggregates them into an array:

@Module({
  providers: [
    { provide: PAY_PROVIDER, useClass: StripePayProvider, multi: true },
    { provide: PAY_PROVIDER, useClass: PaypalPayProvider, multi: true },
    { provide: PAY_PROVIDER, useClass: MockPayProvider, multi: true },
  ],
})
export class PayModule {}
Enter fullscreen mode Exit fullscreen mode

The consumer injects the token and receives the bound instance or array:

constructor(@Inject(PAY_PROVIDER) private readonly pay: IPayProvider) {}
Enter fullscreen mode Exit fullscreen mode

useFactory can also express "pick one by configuration":

{
  provide: PAY_PROVIDER,
  useFactory: (config: ConfigService) =>
    config.get('PAY_DRIVER') === 'paypal'
      ? new PaypalPayProvider()
      : new StripePayProvider(),
  inject: [ConfigService],
}
Enter fullscreen mode Exit fullscreen mode

The Key Constraint: Binding Happens Once

Whether you use useClass, useValue, or useFactory, that binding logic runs once, during module assembly: when NestFactory.create() bootstraps the application, the container resolves every module's providers into instances and wires them, and the same instances are reused for the rest of the process lifetime.

Three practical consequences follow:

  1. The set of implementations is fixed at startup. You cannot "register" a new payment implementation at runtime — module assembly is not a runtime behaviour, and the length of a multi: true array is likewise determined during assembly.
  2. Changing the selection at runtime requires a detour. Reading config inside useFactory is fine, but it computes its result during assembly; to change it later you must inject ModuleRef, wrap a mutable factory object, or make the whole provider request-scoped.
  3. Implementation and module boundaries tend to blur. Three payment implementations often end up in the same module's providers array, so "add a payment method" becomes "edit the existing payment module". Separate modules are possible, but that requires deliberate effort rather than being the framework's default shape.

overrideProvider(TOKEN).useClass(...) is available only while compiling a testing module with Test.createTestingModule(). That is an assembly-time behaviour, not a runtime capability.


Vona's Answer: Select at Resolution Time

Vona expresses this relationship as a scene: each business area owns a named provider scene and a matching decorator. For payments:

// a-pay/src/lib/payProvider.ts
export const PayProvider = createBeanDecorator('payProvider', ...);
Enter fullscreen mode Exit fullscreen mode

Implementations then live in separate modules, siblings of the domain module:

Area Scene Implementations (each its own module)
Auth authProvider auth-simple, auth-oauth
Captcha captchaProvider captcha-simple
Pay payProvider pay-stripe, pay-paypal, pay-mock
Image imageProvider image-native, image-cloudflare
File fileProvider file-native, file-cloudflare

Each implementation is simply "a bean registered under a scene":

// pay-stripe/src/bean/payProvider.stripe.ts
@PayProvider()
export class PayProviderStripe extends BeanBase implements IPayProviderExecute {
  async startPayment(options) { /* Stripe SDK */ }
}
Enter fullscreen mode Exit fullscreen mode

What the Consumer Holds Is a Name, Not a Reference

The pay facade obtains implementations by asking the container for a name:

// a-pay/src/bean/bean.payProvider.ts
get<N extends keyof IPayProviderRecord>(providerName: N) {
  const beanFullName = beanFullNameFromOnionName(providerName, 'payProvider');
  return this.app.bean._getBean(beanFullName);
}
Enter fullscreen mode Exit fullscreen mode

And the name translation is a single string replacement:

// onionName = 'pay-stripe:stripe'
// → beanFullName = 'pay-stripe.payProvider.stripe'
onionName.replace(':', '.payProvider.');
Enter fullscreen mode Exit fullscreen mode

So pay-stripe:stripe becomes a container key only at the moment of the call, and the container resolves the instance on demand. The consuming module never imports any concrete implementation — it knows a name.

Selection Happens at Resolution Time, Not Assembly Time

Which implementation is active is decided by three layers, all read at resolution time:

1. Configuration — VonaConfig.onions

Each scene keeps its own enabled entries and options in config, and the framework uses them to decide whether an implementation is usable in the current context.

2. Request scope — ctx.onionsDynamic

A single request can override the selection, for example to disable one aspect in a test:

await app.bean.executor.performAction('post', '/api/test', {
  onions: { interceptor: { 'a-ratelimit:rateLimit': { enable: false } } },
});
Enter fullscreen mode Exit fullscreen mode

3. Scene resolver

For the captcha scene, a scene can select one provider, rotate across several, or vary the choice by user state and business context.

On top of that, each implementation carries enable / match / ignore metadata, filtered at resolution time by checkOnionOptionsEnabled() to decide whether it is active in the current runtime.

The Unit of Extension Is a Module

Adding a payment method means adding a module (scaffolded for you): one new implementation registered into the payProvider scene, with no change to existing implementations or to the consumer.


The Comparison: How Binding Timing Decides Everything

Angle NestJS assembly-time binding Vona resolution-time selection
When the token becomes an implementation At module initialization On every get()
Who changes when adding the Nth implementation Usually the existing module's providers A new independent module
Runtime switching Needs ModuleRef / request scope / mutable factory A config entry or a request-level override
What the consumer holds An already-resolved instance A name
Can an implementation form a dependency cycle with its consumer Possible (recursive resolution during assembly) No (resolved by name at runtime)

The last row deserves attention. In Vona, the only path from consumer to implementation is "name → container lookup" at runtime: registering the Nth implementation adds zero edges to the dependency structure.

That is exactly why Vona needs no cycle-breaking mechanism at all: there is no recursive resolution during assembly, and no static edge whose number changes with the number of implementations.


When to Use Which

  • When N implementations must coexist and rotate within one request path (risk-control strategy chains, notification channel rotation): NestJS's multi aggregation works well; on the Vona side, a scene resolver takes on "rotate / choose by user state" logic of this kind.
  • When the implementation is long-lived, replaceable infrastructure (payment, storage, captcha, auth methods): Vona's Provider Scene fits better, because each implementation is naturally an independent module and switching is a configuration change rather than an edit to an existing module.
  • When you only need to swap one dependency in a test: NestJS's overrideProvider is more direct; Vona reaches the same end with a per-request onionsDynamic or a config override.

Neither model is strictly better: NestJS's assembly-time binding makes relationships explicit and statically checkable; Vona's resolution-time selection makes extension and switching looser. What actually matters is whether your extension point is determined at startup or variable at runtime.


Summary

NestJS Dynamic Providers and Vona Provider Scene both answer "one interface, many implementations", but they land in different places:

  1. NestJS binds a token to an implementation during assembly — explicit relationships, but the implementation set and binding result are fixed after startup;
  2. Vona selects an implementation by name at resolution — the consumer holds only a name, and implementations register as peers in independent modules;
  3. That difference in timing decides runtime switching, module decoupling, and the more fundamental conclusion about whether an implementation can form a dependency cycle with its consumer.

Put another way, Vona did not make Dynamic Providers smarter; it moved the binding later. As long as the binding happens at resolution time, many implementations cost the dependency structure nothing.


References and Source Code

NestJS

CabloyJS / Vona

Top comments (0)