How-to › Run message-based services

How to migrate a hand-written vendor client to a provider plugin#

Replace each service's copy of a vendor client with one Seneca provider, moving callers through shadow reads until the legacy path's vendor calls reach zero.

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

Billing, support and reports each carry their own copy of the Acme client, and a fix to its retry loop reached two of the three. You build one provider for all of them and run it beside the old clients. During the first vendor outage of the overlap, the vendor’s bill for the outage doubles, because the legacy client and the provider both retry every failed read.

What you get

You will end up with one Seneca provider every service calls, reached through a week of shadow reads and the legacy client’s tests. The vendor’s own count shows the legacy path at zero. This is for you if several services each carry a vendor client.

Short answer

Put the provider live beside the legacy clients and move callers one at a time. Each starts in shadow mode, where the legacy client answers and the provider is asked the same read. Run the legacy client’s tests against the provider, compare shadow reads for a week, and watch the vendor’s count of calls from the legacy path fall to zero. Give a shadow call one attempt, or both paths retry the same failure.

You will need

Node 22.18 or later, which runs the TypeScript samples without a build step, a provider plugin for the vendor, the legacy client’s tests, and a way to count vendor calls by caller. The sample’s vendor counts an x-client header; a real vendor’s usage dashboard or your egress logs do the same job. Writing the provider and generating one from OpenAPI are covered by two pages that are not published yet.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
@voxgig/sdkgen seneca-provider targetThe vendor publishes an OpenAPI description and you would rather regenerate than portAn entity-shaped provider callers need an adapter for, Node 24 or later, and none of the legacy client’s fixes or quirksThe legacy client encodes behavior the spec does not, or the spec is wrong
Big-bang switchOne or two callers, a quiet week, and a rollback you have rehearsedEvery difference between the two paths reaches every caller at once, in productionCallers differ in traffic or in what they rely on from the legacy client
Strangler migrationSeveral callers, each movable on its own scheduleTwo paths to run and pay for during the overlap, a router, and a week of comparisonsThe legacy client is so small that the provider is the same code with a new name

Voxgig maintains Seneca and sdkgen. This page compares a strangler migration to a Seneca provider with a big-bang switch and with generating the provider using sdkgen.

The big-bang switch spends the least time, and puts all of the risk into the morning of the switch. The strangler spends time and vendor calls to find differences on one caller before they reach the rest. Generating the provider removes the porting, and leaves the comparison to do: the generated code knows the spec, not what your services came to depend on.

Keep the legacy client’s tests as the acceptance suite

The services already have tests for the client. Change one thing in them: take the client as a parameter. The same six tests then run against the legacy client and against the provider.

import { clientSuite } from './legacy/client.suite.ts'
import { AcmeClient } from './legacy/acme-client.ts'

clientSuite('legacy client', async (vendor) => ({
  client: new AcmeClient({ url: vendor.url, key: 'sk_test_DEMO' }),
  close: async () => {},
}))
// The legacy client's acceptance suite, unchanged, against the provider.
clientSuite('provider', (vendor) => startProvider(vendor.url))

The provider passes the suite before any caller moves. That proves it does what the tests say. Behavior the tests never checked needs the shadow week.

Give callers the existing interface

Each service constructs its client in one place. Replace that line with an adapter that answers the existing interface by sending the provider’s messages, and the call sites do not change.

export function providerClient(seneca: any): AcmeLike {
  return {
    async getCustomer(id) {
      const reply = await seneca.post('sys:vendor,vendor:acme,get:customer', { id })
      if (reply.ok) return reply.customer
      if (reply.why === 'not_found') return null
      throw new AcmeError(reply.status, `acme ${reply.status || reply.why}`)
    },

The adapter has no retry loop. The provider owns the retry policy, and a second loop here is the first of the two ways the bill doubles.

Shadow reads before moving a caller

A caller in shadow mode keeps the legacy client’s answer. The provider is asked the same read at the same moment, with shadow: true, and the two answers are compared off the request path.

    async getCustomer(id) {
      // Both start together, so the comparison sees the same moment at the vendor. Only
      // the legacy answer reaches the caller; a shadow failure is recorded, never thrown.
      const shadow = seneca.post('sys:vendor,vendor:acme,get:customer', { id, shadow: true })
      const answer = legacy.getCustomer(id)
      const compared = Promise.allSettled([answer, shadow]).then(([a, s]) => {
        if (a.status === 'rejected') return
        if (s.status === 'rejected') return void diffs.push({ caller, id, field: '(error)', legacy: 'ok', provider: String(s.reason) })
        const reply = s.value
        compare(caller, id, a.value, reply.ok ? reply.customer : reply.why === 'not_found' ? null : reply, diffs)
      })
      // Held only until it settles. A list that kept every one would grow by a promise
      // per shadow read for as long as the process runs.
      pending.push(compared)
      const drop = () => {
        const at = pending.indexOf(compared)
        if (at >= 0) pending.splice(at, 1)
      }
      compared.then(drop, drop)
      return answer
    },
    // Writes go one way only. Shadowing a charge would make it twice.
    createCharge: (customer, amount) => legacy.createCharge(customer, amount),

The provider reads shadow and makes one attempt, under its own x-client label, so the vendor counts shadow traffic apart. GitHub’s Scientist library makes the same move for code paths inside one process: the old block’s answer is returned, and the new block’s is compared and recorded.1

Move callers one at a time

migrate.ts runs a week. Reports goes to shadow on day 2, against the provider as first built, which passes the acceptance suite and returns no name for a customer the vendor has none for.

node migrate.ts
day  billing   support   reports   legacy  provider  shadow  diffs
1    legacy    legacy    legacy    19      0         0       0
2    legacy    legacy    shadow    19      0         6       2  reports:c2.name
3    legacy    legacy    shadow    19      0         6       0
4    shadow    shadow    provider  13      6         12      0
5    shadow    provider  provider  7       12        6       0
6    provider  provider  provider  0       19        0       0
7    provider  provider  provider  0       19        0       0

The legacy client filled a missing name with an empty string, which some caller relies on. The acceptance suite never checked it, so the shadow week is where it surfaced: found on day 2, fixed in the provider on day 3. The legacy column reached zero on day 6. That column is the vendor’s count, not yours, so it also catches the caller you forgot to move. Delete the legacy client once the column has read zero for as long as your slowest batch job takes to run.2

Check it worked

The acceptance suite runs twice, then five tests pin the migration’s own rules.

node --test legacy.test.ts provider.test.ts
# tests 17
# suites 0
# pass 17
# fail 0

A shadow read during an outage costs exactly four vendor calls. Three come from the legacy client and one from the shadow. A charge in shadow mode reaches the vendor once. The first-build provider produces exactly one difference, on c2.name, and the list of pending comparisons is empty once they settle. A shadow call to a vendor that never answers times out, is recorded as a difference, and the caller still gets its answer. An error thrown by the mapping function reaches the caller after one vendor call, rather than being retried as if the vendor were down.

When it goes wrong

The vendor’s bill doubles during an outage. Both paths retried the same failure. Count one read through each arrangement:

node pitfall.ts
one read during an outage                vendor calls
  legacy client alone                      3
  provider alone                           3
  both paths, both retrying                6
  shadow read, one attempt                 4
  ported retry loop around the provider    9

Give the shadow call one attempt, because it is a sample that no caller waits on. Never port the old client’s retry loop into the adapter, where it multiplies the provider’s attempts.

A customer is charged twice. A write went down both paths. Route a write to one path only, and retry it only with an Idempotency-Key the vendor honors.

The shadow reports differences on every read. The two answers were read at different moments, or one side adds a field the other drops. Start both calls together, as the router does, and compare the fields callers use rather than whole bodies.

When not to do this

Do not run a week of shadow reads for a client one service uses. Move that service, keep the old client a release behind a flag, and spend the week elsewhere.

Do not take the sdkgen route to skip the comparison. The seneca-provider target ships in @voxgig/sdkgen-infrapack 0.0.18, not in @voxgig/sdkgen itself, and wraps the TypeScript SDK generated from the same spec. Its single-record reads return null for a 404 and pass every other SDK error through, so the adapter and the shadow week remain.

Do not adopt Seneca to get a provider. The adapter and the router work around any shared client module, and Seneca earns its place when services already send messages.

Do not record shadow differences with customer values in production logs. Record the field name and a hash.

Last verified

Verified 2026-10-15 against Node 22.22.0, seneca 3.38.0 and seneca-promisify 3.7.2. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci. The sdkgen behavior is read from the published packages @voxgig/sdkgen 4.34.7, @voxgig/sdkgen-infrapack 0.0.18 and the generated @seneca/github-provider 0.3.5, not from a generation run here.

Footnotes

  1. Scientist calls the old path use and the new one try, and its README notes that it randomizes the order in which they run. It also warns that running them one after the other lets the data change in between. To calibrate, it suggests a first experiment in which both blocks call the old code, which counts the mismatches that arrive with no change at all. ↩︎ Back to text

  2. The pattern’s name has been migrated once itself. Martin Fowler saw strangler figs in the Queensland rain forest in 2001 and wrote about the metaphor a couple of years later, under a title that named only the strangler. People kept the strangling and forgot the fig, so he put the fig into the title of the article. His footnote reports that the new title strangled the old one. ↩︎ 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.