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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| @voxgig/sdkgen seneca-provider target | 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 | 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 | 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.
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.
Related how-tos
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
-
Scientist calls the old path
useand the new onetry, 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 -
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