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 });
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 {}
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 {}
The consumer injects the token and receives the bound instance or array:
constructor(@Inject(PAY_PROVIDER) private readonly pay: IPayProvider) {}
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],
}
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:
-
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: truearray is likewise determined during assembly. -
Changing the selection at runtime requires a detour. Reading config inside
useFactoryis fine, but it computes its result during assembly; to change it later you must injectModuleRef, wrap a mutable factory object, or make the whole provider request-scoped. -
Implementation and module boundaries tend to blur. Three payment implementations often end up in the same module's
providersarray, 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', ...);
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 */ }
}
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);
}
And the name translation is a single string replacement:
// onionName = 'pay-stripe:stripe'
// → beanFullName = 'pay-stripe.payProvider.stripe'
onionName.replace(':', '.payProvider.');
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 } } },
});
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
multiaggregation 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
overrideProvideris more direct; Vona reaches the same end with a per-requestonionsDynamicor 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:
- NestJS binds a token to an implementation during assembly — explicit relationships, but the implementation set and binding result are fixed after startup;
- Vona selects an implementation by name at resolution — the consumer holds only a name, and implementations register as peers in independent modules;
- 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
- a-onion scene types (VonaConfig.onions / VonaContext.onionsDynamic)
- a-onion scene checks and execution
- a-pay facade: resolving a provider by name
- a-pay scene decorator definition
- onionName to bean full name utility
- Vona BeanContainer source code
- Captcha scene documentation (selection, rotation, context variation)
- Built-in file providers and their defaults
- Bean scene authoring guide
Top comments (0)