The pricing plugin your checkout loads from npm answers role:shop,cmd:price with the list
price, and this quarter’s sale needs ten percent off one range of products. The plugin has no
option for a sale. Copying its source into your repository gets you the sale today and leaves
you merging every future vendor fix by hand. The copy stopped being the vendor’s code the moment
you edited it.
What you get
You will end up with a plugin that changes one vendor message on the way in and out, calls the vendor’s action for the rest, and refuses to load out of order. This is for you if your service loads plugins you do not own.
Short answer
Seneca lets a plugin add a second action on a pattern another plugin already handles. Messages
reach the second action, and this.prior(msg) inside it calls the one beneath. Change the
message before that call, change the reply after it, and return every field the prior
returned. Check seneca.has(pattern) when the plugin is defined, so an override loaded before
the plugin it overrides fails at startup instead of being skipped.
You will need
Node 22 or later, and seneca with seneca-promisify, which makes actions async functions
and this.prior a promise. You also need a plugin you did not write that handles the pattern
you want to change. Verified 2026-10-21 against Node 22.22.0, seneca 3.38.0, seneca-promisify 3.7.2,
@nestjs/core 12.2.0, and Moleculer 0.15.2. Seneca’s own tutorial on prior actions is the
reference for the mechanism.1 The sample’s vendor plugin is a file under third-party/
standing in for a package in node_modules, and every command on this page runs offline. That
plugin handles two messages: role:shop,cmd:price is the one overridden, and
role:shop,cmd:quote asks for a price by message, which is the detail the comparison turns on.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Moleculer mixins and action hooks | The vendor ships a service schema you mix into your own, and the change belongs before or after the handler | The vendor schema is copied into your service, so the vendor service cannot also run on its own, and a hook that forgets to return hands callers undefined | You need to skip the original handler, or the vendor’s service runs as a node of its own |
| NestJS custom provider overrides | The vendor code is a Nest module and the callers you care about are in your module | The override holds inside your module only, and the original is a second instance of the vendor class, built with dependencies you supply | A consumer inside the vendor module has to see the change, outside a test |
| Seneca plugin priors | The vendor code is a Seneca plugin and every caller of the pattern, the vendor’s own included, should see the change | No single file says what a pattern does: its behavior is the sum of every plugin that added it, in load order, and a wrong load order is silent | The change is for one caller only, or you need types checked across the boundary |
Voxgig maintains Seneca. This page compares it with NestJS custom providers and Moleculer mixins.
The three differ in how far the change reaches. A Seneca prior and a Moleculer hook change the pattern for every caller in the process, the vendor’s own code included. That reach costs readability, because the behavior of one message is spread across plugins. A NestJS custom provider is scoped by the module system, which keeps the change legible and stops it at the vendor module’s boundary, so the vendor’s internal consumers keep the original.
Wrap the vendor action and call the prior
The vendor plugin adds role:shop,cmd:price and role:shop,cmd:quote. Your plugin adds
role:shop,cmd:price again, and from then on messages for that pattern reach your action
first. this.prior(msg) sends the message on to the vendor’s action and resolves to its
reply.2
seneca.message('role:shop,cmd:price', async function price(msg) {
// On the way in: callers send ' AB-1 ', the catalog is keyed on 'ab-1'. A sku that is
// not a string goes on as it came, so the vendor's own answer to it is unchanged.
if (typeof msg.sku === 'string') msg.sku = msg.sku.trim().toLowerCase()
const out = await this.prior(msg)
// On the way out: an error reply passes through untouched, and a priced reply
// keeps every field the vendor returned.
if (!out.ok || !SALE.skus.has(out.sku)) return out
const price = Math.round((out.price * (100 - SALE.percent)) / 100)
return { ...out, price, list_price: out.price, sale: SALE.name }
})
Everything before the prior call is the way in, and everything after it is the way out. The
vendor’s action receives the normalized sku, because the prior is sent the same message
object. Only a string is normalized. Spreading
out into the reply is the rule that keeps the vendor’s reply whole: a field the vendor adds in
a future release passes through without a change here. An error
reply is returned as it came, so the sale never decorates a failure.
node demo.mjs
price for " AB-1 " {"ok":true,"sku":"ab-1","price":900,"currency":"EUR","list_price":1000,"sale":"autumn"}
price for "ab-2" {"ok":true,"sku":"ab-2","price":2500,"currency":"EUR"}
price for "zz-9" {"ok":false,"why":"unknown_sku","sku":"zz-9"}
vendor quote, qty 2 {"ok":true,"sku":"ab-1","qty":2,"unit":900,"total":1800,"currency":"EUR"}
chain for role:shop,cmd:price: sale_pricing/price -> shop_pricing/price
The first line shows both directions: the sku arrived padded and upper-cased and left
normalized, and the price left reduced with the list price beside it. Lines two and three are
the vendor’s replies, untouched. On line four, the vendor’s own quote action asks for a price
by sending role:shop,cmd:price, so it gets the sale price without a change to the vendor’s
code. In Seneca the override reaches every sender of the pattern, inside
the vendor’s plugin as much as in yours.
Load the override after the plugin it overrides
Seneca resolves priors when a pattern is added, not when a message arrives. The second plugin to add a pattern sits on top of the first, so the vendor plugin has to be loaded before yours. Loaded the other way round, yours sits beneath the vendor’s action, which does not call its prior, so the sale is skipped in silence.
export async function start({ override = true } = {}) {
const seneca = Seneca({ legacy: false }).quiet().use('promisify').use(shop_pricing)
if (override) seneca.use(sale_pricing)
await seneca.ready()
return seneca
}
// The actions that answer a pattern, top first: each entry is plugin/function, and the
// last one is the action that has no prior.
export function chain(seneca, pattern) {
const names = []
for (let def = seneca.find(pattern); def; def = def.priordef) {
names.push(`${def.plugin_name}/${def.func.name}`)
}
return names
}
Two things guard the order. At definition time the plugin asks
seneca.has whether the exact pattern exists, and
throws when it does not. Seneca treats that as a fatal plugin failure: the process exits with
code 2 once the death_delay option has elapsed, 11 seconds by default. Then chain reads the
order back from the running instance. seneca.find returns the action definition for a pattern,
and its priordef is the definition beneath it. The last demo line is that chain, your action
first and the vendor’s second. Print it in a startup log or pin it in a test, because the
instance is the one place where the effective behavior of a pattern can be read in full.
Run the same override in NestJS and Moleculer
The comparison applies the same sale to the same vendor code in three frameworks, then asks the caller and the vendor’s own quote for a price.
node compare.mjs
override caller pays vendor quote original reached by
Seneca prior 900 900 await this.prior(msg)
NestJS custom provider 900 1000 a second PricingService under your own token
Moleculer mixin and hook 900 900 implicit, the hook runs after the handler
In NestJS the override is a custom provider
registered in your module under the vendor’s token. A useFactory builds it around an instance
of the vendor class, injected under a token of your own. Nest resolves providers per
module, so your Checkout gets the decorated service while the vendor module’s own
QuoteService keeps the original, which is why the vendor quote stays at 1000. The one override
that reaches every module is overrideProvider on the
testing module, and it exists for tests.3
In Moleculer the vendor’s schema is a mixin
of your service, and an after hook
on price edits the reply, so the vendor’s ctx.call('shop.price') reaches the hooked action
too. A hook cannot skip the handler, and it has to return the reply: one that edits res in
place and returns nothing hands every caller undefined.
Check it worked
Keep two of the seven tests when you adapt the sample. One builds a second instance with the vendor plugin alone and asserts, field for field, that every reply the override leaves alone is the vendor’s reply. The other asserts that every key of the prior’s reply is present in the override’s reply.
test('nothing the prior returned is dropped on the way out', async () => {
const vendorOnly = await start({ override: false })
const seneca = await start()
for (const sku of ['ab-1', 'ab-2', 'zz-9']) {
const before = Object.keys(await price(vendorOnly, sku))
const after = Object.keys(await price(seneca, sku))
for (const key of before) assert.ok(after.includes(key), `${sku}: the override dropped ${key}`)
}
await seneca.close()
await vendorOnly.close()
})
node --test override.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2408.946775
# pass 7 with # fail 0. The remaining tests pin the sale price with every vendor field
present, the vendor quote at a unit price of 900, the chain as sale_pricing/price then
shop_pricing/price, and constructor as an unknown sku. The last one runs order.mjs as a child with
execFileSync
and asserts the exit code 2 of a process that loads the plugins in the wrong order.
When it goes wrong
Three failures, run from one script. The first two report nothing.
node pitfall.mjs
prior reply thrown away
price {"price":900,"sale":"autumn"}
quote {"price":900,"sale":"autumn"}
override loaded first, no guard
price {"ok":true,"sku":"ab-1","price":1000,"currency":"EUR"}
chain shop_pricing/price -> unguarded_sale/price
override loaded first, with the guard
plugin_define_failed: sale_pricing: load shop_pricing first, there is no role:shop,cmd:price to override
exit code 2
The caller gets a price without ok, sku, or currency. That override called the prior and
built its reply from scratch, so everything the vendor returned was thrown away. The vendor’s
own quote then read the missing ok as a failure and passed the half reply on. Spread the
prior’s reply into yours, and keep the field-survival test.
The caller pays the list price without a warning at startup. The chain line says why: the vendor’s action is on top, and the override sits beneath it as a prior the vendor’s action does not call. Seneca fixed that order when the patterns were added. The guard is what makes the mistake visible.
With the guard in place, the process loaded in the wrong order stops at startup with
plugin_define_failed and the plugin’s own message, and exits with code 2. The override loaded
with no vendor plugin at all fails the same way. Without the guard, this.prior(msg) resolves
to null in that case, as the tutorial says it will, and the next line throws
Cannot read properties of null.
When not to do this
Do not override a pattern to change the behavior for one caller. A prior changes the message
for every sender in the process, the vendor’s own code included. That is the point of the
mechanism, and the reason it is wrong for a special case. Give that caller a more specific
pattern instead, such as role:shop,cmd:price,channel:kiosk, which Seneca matches ahead of the
general one and which leaves the vendor’s action as it was.
Do not stack priors from several plugins on one pattern without a test that prints the chain.
Seneca resolves the order once, at load time, and three plugins that each add
role:shop,cmd:price behave differently in each of the six load orders.
Do not adopt Seneca to get this. If your services are Nest modules, a custom provider changes what your module sees and keeps the type checking. Adopting a message framework to decorate one class is a large change for a small problem.
Do not treat the prior’s reply as yours to rebuild. The field-survival test exists because a swallowed reply reaches production.
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, and Moleculer 0.15.2. Every output block is what the command
preceding it printed, run in the page’s code directory after npm ci. The vendor plugin is a
file in that directory standing in for an installed package, and the Nest and Moleculer samples
apply their decorators and hooks as function calls in plain JavaScript.
Footnotes
-
The tutorial that documents priors shows a transcript from Seneca 0.6.4, timestamped September 2015, and the page has not needed another. The add order, the non-strict default that makes a general pattern the prior of a sub-pattern, and the
nulla prior answers with nothing beneath it are described there as 3.38.0 behaves. ↩︎ Back to text -
Seneca 3.38.0 ships a deprecation notice in
lib/errors.tssaying thatSeneca.parenthas been renamed toSeneca.priorand will be removed in Seneca 4.x. The 4.0.0 release candidates on the registry keep the line as a comment, and the repository’s master branch has dropped it. ↩︎ Back to text -
NestJS documents
overrideProvideron the testing module, besideoverrideModule,overrideGuard,overrideInterceptor,overrideFilter, andoverridePipe. All six live in@nestjs/testing, so a running app is left with the module boundary as its only scope, which is the scope the comparison measured. ↩︎ Back to text