# How to switch vendors behind one provider interface

> Put two vendors behind one Seneca message, route by tenant and country, normalize both replies into one shape that keeps every field, and diff the two replies.

Source: https://voxgig.com/howto/switch-vendors-behind-one-provider-interface

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Put both vendors behind one Seneca message. Each vendor plugin adds the same pattern plus its own vendor property, such as `sys:topup,cmd:send,vendor:heron`, and a router adds the pattern without it, picking a vendor by tenant, then country. Normalize each reply into one shape and keep the fields it does not map. Then send one message to each vendor and diff the replies.

---
## You will need

Node 22.18 or later, which runs the TypeScript samples by stripping their types, and a client for
each vendor. Verified 2026-10-21 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2,
@nestjs/core 12.2.0, Moleculer 0.15.2, and TypeScript 5.9.3. The two gateways, Kestrel and
Heron, are stand-ins that answer in process with fixed values, so no command here touches a
network. Wrapping one vendor as a provider plugin is the subject of a page that is not published
yet.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Moleculer service versions](https://moleculer.services/docs/0.15/services.html#Name-amp-Version) | Each vendor runs as its own service, possibly on its own node, behind one routing service | A version field meant for API versions names vendors instead, with no check that both reply in one shape | The vendors share one process and one codebase, where a typed interface costs nothing extra |
| [NestJS strategy pattern with a factory](https://docs.nestjs.com/fundamentals/custom-providers) | Vendors are classes in one TypeScript codebase, and the compiler should reject a gateway that returns the wrong shape | A class, a provider, and a factory entry per vendor, and a vendor pin only where the service reads one | Vendors deploy separately, or callers in other languages send the messages |
| [Seneca pattern routing](https://senecajs.org/api/#method-add) | Callers already send messages, and one message should be able to pin a vendor without the router listing every vendor | No compiler sees across a pattern, so a plugin that returns a vendor's raw body compiles and ships | The gateways are classes in one typed codebase, and compile-time checks matter more than per-message pins |

Voxgig maintains Seneca. This page compares it with a NestJS strategy factory and Moleculer
service versions.

The three route the same five messages to the same vendors, so the choice turns on what checks
the reply. A Nest strategy implements an interface, so `tsc` rejects a gateway that returns its
vendor's raw body, at the cost of a class and a provider per vendor. Seneca and Moleculer choose
at runtime, from a message property or an action name, which lets one message pin a vendor, with
only your tests to check what comes back.

## Give each vendor plugin the same pattern plus a vendor property

Each vendor plugin adds `sys:topup,cmd:send` with one more property, `vendor`, set to its own
name. A [Seneca pattern](https://senecajs.org/api/#method-add) matches any message that carries
its properties, and the most specific match wins, so a message with `vendor:heron` reaches
Heron's plugin and no other action.

```ts title="providers.ts"
export function kestrel_provider(this: any, options: { api: ReturnType<typeof makeKestrel> }) {
  this.message('sys:topup,cmd:send,vendor:kestrel', async function send(msg: any) {
    if (!isE164(msg.phone)) return { ok: false, why: 'invalid_phone', phone: msg.phone }
    const body = await options.api.topup({ msisdn: msg.phone.slice(1), amount: msg.amount, currency: msg.currency })
    return fromKestrel(body)
  })
}

export function heron_provider(this: any, options: { api: ReturnType<typeof makeHeron> }) {
  this.message('sys:topup,cmd:send,vendor:heron', async function send(msg: any) {
    if (!isE164(msg.phone)) return { ok: false, why: 'invalid_phone', phone: msg.phone }
    const body = await options.api.recharge({ recipient: msg.phone, value: msg.amount, currency: msg.currency })
    return fromHeron(body)
  })
}
```

Both plugins refuse a number that is not in E.164 form with its plus. Kestrel takes the digits
alone, so without the check `slice(1)` would cut the first digit from a number sent without a
plus and top up someone else's phone. Each plugin hands its vendor's body to a normalizer, which
is where the one reply shape is made:

```ts title="topup.ts"
export function fromHeron(body: HeronBody): TopupResult {
  const { id, state, recipient, value, pin, ...extra } = body
  return {
    ok: true,
    id: `heron/${id}`,
    status: HERON_STATE.get(state) ?? 'pending',
    phone: recipient,
    sent: { amount: value.sent, currency: value.sent_currency },
    delivered: { amount: value.received, currency: value.received_currency },
    ...(pin === undefined ? {} : { pin }),
    extra,
  }
}
```

The destructuring names the fields the shape maps and collects the rest into `extra`. Heron's
`operator_ref` lands there, and so will any field Heron adds later. `pin` is promoted instead: a
caller has to show it to the customer, so it is an optional field of the shape rather than a
detail in `extra`. `id` carries the vendor's name, so support can find the transaction while
callers treat the id as opaque.

## Route a message that names no vendor

The router adds the pattern without `vendor`, so it receives every message no vendor plugin
matches. It picks a vendor by tenant, then by country, then a fallback, and sends the message on
with that vendor named. The route tables are plain objects, so `chooseVendor` reads only their
own keys.

```ts title="providers.ts"
// Tenant, then country, then the fallback. Only a table's own keys count, so a tenant called
// 'constructor' falls through to its country instead of routing to Object.
export function chooseVendor(routes: Routes, msg: { tenant?: unknown; country?: unknown }): string {
  const own = (table: Record<string, string>, key: unknown) =>
    typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined
  return own(routes.byTenant, msg.tenant) ?? own(routes.byCountry, msg.country) ?? routes.fallback
}

export function topup_router(this: any, routes: Routes) {
  this.message('sys:topup,cmd:send', async function route(this: any, msg: any) {
    // A message that names a vendor arrives here only when no plugin adds that vendor.
    // Routing it would send it to a vendor the caller did not ask for, or back here.
    if (msg.vendor !== undefined) return { ok: false, why: 'unknown_vendor', vendor: msg.vendor }
    const vendor = chooseVendor(routes, msg)
    const { tenant, country, phone, amount, currency } = msg
    return this.post('sys:topup,cmd:send', { vendor, tenant, country, phone, amount, currency })
  })
}
```

The guard on its first line matters. A message that names a vendor reaches the router only when
no plugin adds that vendor, and routing it anyway sends it to a vendor the caller did not ask
for.

```bash
node demo.ts
```

```text output
shop-ie, Irish number          kestrel/K-20431  delivered 10 EUR
shop-ng, Nigerian number       heron/88213      delivered 1000 NGN, pin 4417 0921 3365
shop-ng-b2b, Nigerian number   kestrel/K-20432  delivered 1000 NGN
pinned to heron, Irish number  heron/88214      delivered 10 EUR
pinned to osprey               {"ok":false,"why":"unknown_vendor","vendor":"osprey"}
```

Tenant beats country and country beats the fallback because the router's code says so, in that
order. The same rules written as two Seneca patterns, `country:NG` and `tenant:shop-ng-b2b`,
would both match a message that carries both. Seneca picks `country`, the property that sorts
first, even when the tenant pattern names more properties. Keep routing rules that can overlap
in code, where their order is visible.

## Run the same routing in NestJS and Moleculer

The comparison sends the same five messages through a Nest strategy factory and through
Moleculer versioned services, and prints which vendor answered each.

```ts title="compare/nest.ts"
class TopupModule {}
Module({
  providers: [
    KestrelGateway,
    HeronGateway,
    {
      provide: GATEWAYS,
      useFactory: (...all: TopupGateway[]) => new Map(all.map((gateway) => [gateway.vendor, gateway])),
      inject: [KestrelGateway, HeronGateway],
    },
    TopupService,
  ],
})(TopupModule)
```

Nest builds a map of strategies with a
[factory provider](https://docs.nestjs.com/fundamentals/custom-providers), and `TopupService`
picks one per message. The sample applies Nest's decorators as function calls, because Node
strips types and does not transform decorators.[^1] In Moleculer, each vendor is a `topup`
service with the vendor's name as a string version, so the actions are `kestrel.topup.send` and
`heron.topup.send`, and a `topup` service with no version routes between them.

```bash
node compare.ts
```

```text output
message                        Seneca patterns    NestJS factory     Moleculer versions
shop-ie, Irish number          kestrel            kestrel            kestrel
shop-ng, Nigerian number       heron              heron              heron
shop-ng-b2b, Nigerian number   kestrel            kestrel            kestrel
pinned to heron, Irish number  heron              heron              heron
pinned to osprey               unknown_vendor     unknown_vendor     ServiceNotFoundError
```

The three agree on every routed message. They differ on the unknown vendor, which Seneca and Nest
answer as data and Moleculer throws as a
[`ServiceNotFoundError`](https://moleculer.services/docs/0.15/errors.html), and on what the
compiler can see. Each file in `drift/` makes the same mistake, returning Heron's body without
normalizing it:

```bash
npx tsc -p drift --pretty false
```

```text output
drift/raw-heron-gateway.ts(8,9): error TS2416: Property 'send' in type 'RawHeronGateway' is not assignable to the same property in base type 'TopupGateway'.
  Type '(req: TopupRequest) => Promise<HeronBody>' is not assignable to type '(req: TopupRequest) => Promise<TopupResult>'.
    Type 'Promise<HeronBody>' is not assignable to type 'Promise<TopupResult>'.
      Type 'HeronBody' is missing the following properties from type 'TopupResult': ok, status, phone, sent, and 2 more.
```

The one error is in the Nest strategy. The Seneca plugin in the same directory compiles, because
`this` in a plugin is `any`, so nothing constrains what an action returns. That is the trade the
comparison is about: Seneca selects per message at runtime, and a test, not the compiler, has to
catch a reply in the wrong shape. Seneca's own type declarations add a second gap, because they
describe an import that Node does not perform, so the sample casts once at the import.[^2]

## Check it worked

Send the same message once with each vendor value and diff the two replies.

```bash
node diff.ts
```

```text output
the same top-up through each vendor: +2348030000001, 1000 NGN

field      kestrel           heron
ok         true              true
id         kestrel/K-20431   heron/88213       differs
status     delivered         delivered
phone      +2348030000001    +2348030000001
sent       1000 NGN          1000 NGN
delivered  1000 NGN          1000 NGN
extra      fee               operator_ref      differs
pin        -                 4417 0921 3365    differs
```

Only `id`, `extra`, and `pin` differ. `id` and `extra` are vendor detail by design. `pin`
differs because Heron delivers this top-up as a voucher and Kestrel as credit, which is why it is
an optional field of the shape rather than a field the normalizer drops. The first test pins that
list. Nine more cover the routing, the voucher PIN, the guard, the field coverage, an unknown
status, a tenant called `constructor`, a number without its plus, and how Seneca decides
between crossing patterns.

```bash
node --test vendors.test.ts
```

```text output
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2200.29148
```

`# pass 10` and `# fail 0`. The coverage test lists the fields each normalizer maps, and asserts
that every other field of a vendor's body arrives in `extra`.

## When it goes wrong

Three failures, from one script.

```bash
node pitfall.ts
```

```text output
normalized to the fields both vendors share
  heron returned  id, state, recipient, value, operator_ref, pin
  caller got      ok, id, status, phone, sent, delivered
  pin             dropped: the customer paid for a voucher and never sees its code
normalized with the rest kept
  caller got      ok, id, status, phone, sent, delivered, pin, extra
  pin             4417 0921 3365, extra: operator_ref

heron not loaded
  pinned to heron, no guard   answered by kestrel/K-20431
  routed to heron, no guard   maxparents: Message has too many parent messages (33). There may be an infinite loop.
  pinned to heron, guarded    {"ok":false,"why":"unknown_vendor","vendor":"heron"}
  routed to heron, guarded    {"ok":false,"why":"unknown_vendor","vendor":"heron"}
```

The customer pays for a voucher and never sees its code. The normalizer mapped only the fields
both vendors share, so Heron's `pin` and `operator_ref` were dropped on the way to every caller.
Collect the unmapped fields with a rest element, promote any field a caller needs, and keep the
coverage test.

A message pinned to Heron is answered by Kestrel. The router had no guard, so it routed a
message whose vendor was not loaded, and the fallback answered. Refuse a named vendor that no
plugin handles.

Every message routed to Heron fails with `maxparents` in a region where Heron is not loaded. The
unguarded router sent the message back to itself, because no plugin matched `vendor:heron`, until
Seneca stopped the chain at 33 parent messages.[^3] The same guard fixes both.

## When not to do this

Do not put two vendors behind one interface when callers need what only one of them offers. A
shape that hides a field callers bill on, or a PIN they must display, sends them back to asking
which vendor replied.

Do not adopt Seneca for this alone. If the gateways are classes in one TypeScript service, a
Nest strategy gives you the same routing plus a compiler that rejects a gateway returning the
wrong shape.

Do not express overlapping routing rules as Seneca patterns. Seneca decides between patterns
that cross by the alphabetical order of property names, whichever names more properties, rather
than by your intent.

Do not let the router decide for a message that names a vendor. A pin the router can overrule is
a preference, and the caller who set it will not find out it was ignored.

## Related how-tos

- [Map vendor error responses onto message errors](/howto/map-vendor-errors-onto-message-errors)

- [Migrate a hand-written vendor client to a provider plugin](/howto/migrate-a-vendor-client-to-a-provider)

- [Kill-switch one vendor integration without a deploy](/howto/kill-switch-a-vendor-without-a-deploy)

## Last verified

Verified 2026-10-21 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2, @nestjs/core
12.2.0, @nestjs/common 12.2.0, Moleculer 0.15.2, and TypeScript 5.9.3. Every output block is
what the command preceding it printed, run in the page's code directory after `npm ci`. Kestrel
and Heron are invented for the sample and answer in process, so no vendor's API was called. The
whole sample also type-checks under `strict`, with the one cast at the seneca import.

[^1]: Node's [TypeScript documentation](https://nodejs.org/api/typescript.html) gives the reason
in two sentences. Decorators are a TC39 Stage 3 proposal, so Node does not transform them, and it
will not support them until JavaScript does. A Nest class run directly by Node therefore calls
`Injectable()(TopupService)` by hand, which is the call the `@` form exists to spell.

[^2]: The last two statements the published [seneca.js](https://unpkg.com/seneca@3.38.0/seneca.js)
runs on load are assignments: `exports.default = init`, then `module.exports = init`, which
replaces the object the first one wrote to. The [declaration file](https://unpkg.com/seneca@3.38.0/seneca.d.ts)
still says `export default init`. TypeScript believes the first line and Node runs the second,
so an ES module that imports seneca receives a function its types describe as a module.

[^3]: The stop is an option, `limits.maxparents`, set to 33 in seneca.js with no comment beside
it. The [error text](https://unpkg.com/seneca@3.38.0/lib/errors.js) says there may be an
infinite loop, which undersells this case: every parent in the chain is the same message, sent
by the router to itself.