How-to › Run message-based services

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.

Audience
Platform team
Level
advanced
Topic
Wrap external APIs as providers
Languages
TypeScript
Verified

Your top-up service calls two gateways, and every caller knows which one, because the replies differ. One says COMPLETED and nests the amount under sent, the other says success and sometimes adds a voucher PIN. Moving Nigerian traffic to the second vendor means changing every caller, and the caller you miss shows customers a failed top-up that went through.

What you get

You will end up with one message both vendors answer, a router that picks a vendor by tenant and country, and one reply shape that loses no vendor field. This is for you if your callers branch on which vendor replied.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Moleculer service versionsEach vendor runs as its own service, possibly on its own node, behind one routing serviceA version field meant for API versions names vendors instead, with no check that both reply in one shapeThe vendors share one process and one codebase, where a typed interface costs nothing extra
NestJS strategy pattern with a factoryVendors are classes in one TypeScript codebase, and the compiler should reject a gateway that returns the wrong shapeA class, a provider, and a factory entry per vendor, and a vendor pin only where the service reads oneVendors deploy separately, or callers in other languages send the messages
Seneca pattern routingCallers already send messages, and one message should be able to pin a vendor without the router listing every vendorNo compiler sees across a pattern, so a plugin that returns a vendor’s raw body compiles and shipsThe 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 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.

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:

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.

// 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.

node demo.ts
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.

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, 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.

node compare.ts
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, and on what the compiler can see. Each file in drift/ makes the same mistake, returning Heron’s body without normalizing it:

npx tsc -p drift --pretty false
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.

node diff.ts
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.

node --test vendors.test.ts
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.

node pitfall.ts
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.

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.

Footnotes

  1. Node’s TypeScript documentation 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. ↩︎ Back to text

  2. The last two statements the published 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 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. ↩︎ Back to text

  3. The stop is an option, limits.maxparents, set to 33 in seneca.js with no comment beside it. The error text 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.