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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Moleculer service versions | 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 | 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 | 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 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.
Related how-tos
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
-
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 -
The last two statements the published seneca.js runs on load are assignments:
exports.default = init, thenmodule.exports = init, which replaces the object the first one wrote to. The declaration file still saysexport 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 -
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