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

Source: https://voxgig.com/howto/migrate-a-vendor-client-to-a-provider

- Audience: platform-team
- Level: advanced
- Languages: typescript
- Verified: 2026-10-15
- Published: 2026-10-15

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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [@voxgig/sdkgen seneca-provider target](https://github.com/voxgig/sdkgen-infrapack) | The vendor publishes an OpenAPI description and you would rather regenerate than port | An entity-shaped provider callers need an adapter for, Node 24 or later, and none of the legacy client's fixes or quirks | The legacy client encodes behavior the spec does not, or the spec is wrong |
| [Big-bang switch](https://martinfowler.com/articles/feature-toggles.html) | One or two callers, a quiet week, and a rollback you have rehearsed | Every difference between the two paths reaches every caller at once, in production | Callers differ in traffic or in what they rely on from the legacy client |
| [Strangler migration](https://martinfowler.com/bliki/StranglerFigApplication.html) | Several callers, each movable on its own schedule | Two paths to run and pay for during the overlap, a router, and a week of comparisons | The 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.

```ts title="legacy.test.ts"
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 () => {},
}))
```

```ts title="provider.test.ts"
// 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.

```ts title="adapter.ts"
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.

```ts title="router.ts"
    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](https://github.com/github/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.

```bash
node migrate.ts
```

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

```bash
node --test legacy.test.ts provider.test.ts
```

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

```bash
node pitfall.ts
```

```text output
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`](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
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.

## Related how-tos

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Enforce one total deadline across all retry attempts](/howto/enforce-a-total-deadline-across-retry-attempts)

- [Map vendor error responses onto message errors](/howto/map-vendor-errors-onto-message-errors)

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

[^1]: Scientist calls the old path `use` and the new one `try`, and
[its README](https://github.com/github/scientist) 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.

[^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](https://martinfowler.com/bliki/StranglerFigApplication.html).
His footnote reports that the new title strangled the old one.