# How to override a service action without forking its plugin

> Add a second Seneca action on a vendor plugin's pattern, call the prior, keep every field it returns, and refuse to load out of order.

Source: https://voxgig.com/howto/override-a-service-action-without-forking

- Audience: library-maintainer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-20

## 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](https://moleculer.services/docs/0.15/actions.html#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](https://docs.nestjs.com/fundamentals/custom-providers) | 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](https://senecajs.org/docs/tutorials/understanding-prior-actions.html) | 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]

```js title="sale-pricing.mjs"
  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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)
`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.

```bash
node demo.mjs
```

```text output
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.

```js title="system.mjs"
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`](https://senecajs.org/api/#method-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.

```bash
node compare.mjs
```

```text output
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](https://docs.nestjs.com/fundamentals/custom-providers)
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](https://docs.nestjs.com/fundamentals/testing), and it exists for tests.[^3]

In Moleculer the vendor's schema is a [mixin](https://moleculer.services/docs/0.15/services.html#Mixins)
of your service, and an [after hook](https://moleculer.services/docs/0.15/actions.html#Action-hooks)
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.

```js title="override.test.mjs"
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()
})
```

```bash
node --test override.test.mjs
```

```text output
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`](https://nodejs.org/api/child_process.html#child_processexecfilesyncfile-args-options)
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.

```bash
node pitfall.mjs
```

```text output
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

- [Run service startup steps in a fixed order](/howto/run-service-startup-steps-in-order)

- [Document Seneca plugin message patterns inside the repo](/howto/document-seneca-plugin-message-patterns)

- [Test a plugin through every lifecycle stage](/howto/test-a-plugin-through-every-lifecycle-stage)

- [Split a monolith into message-based services with Seneca](/howto/split-a-monolith-into-message-based-services)

- [Switch vendors behind one provider interface](/howto/switch-vendors-behind-one-provider-interface)

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

[^1]: The [tutorial](https://senecajs.org/docs/tutorials/understanding-prior-actions.html) 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 `null` a prior answers with nothing beneath it are described
there as 3.38.0 behaves.

[^2]: Seneca 3.38.0 ships a deprecation notice in
[`lib/errors.ts`](https://github.com/senecajs/seneca/blob/v3.38.0/lib/errors.ts) saying that
`Seneca.parent` has been renamed to `Seneca.prior` and will be removed in Seneca 4.x. The 4.0.0
release candidates on the registry [keep the line as a comment](https://unpkg.com/seneca@4.0.0-rc5/lib/errors.js),
and the repository's master branch has dropped it.

[^3]: NestJS documents `overrideProvider` on the
[testing module](https://docs.nestjs.com/fundamentals/testing), beside `overrideModule`,
`overrideGuard`, `overrideInterceptor`, `overrideFilter`, and `overridePipe`. 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.