Index

Integration Without Illusions

Renaming a Field Is a Protocol Migration

An API changes serviceIds to deliveryOptionIds. The new name describes the domain better, and the implementation is easier to read. It is probably the better name. That is also beside the point.

An existing client still sends:

{
  "serviceIds": ["express"]
}

The request now fails validation before any application code runs. Nothing about the client’s intent changed. The server chose a cleaner name and turned that preference into a breaking protocol change.

Once another process sends a field, renaming it is no longer a refactor. It is a migration involving deployed callers whose release schedule the server may not control.

Compatibility begins before the application layer

A boundary migration has at least four parts:

accepted input -> canonical request -> internal propagation -> observable result

Passing only the first step is cosmetic compatibility. A parser may accept the old field while a later mapper reads only the new one, so the request returns success but behaves as if no options were supplied. That is arguably worse than rejecting it: the caller gets a plausible lie instead of a useful error.

The repository history contains both versions of this mistake. One refactor replaced a request field with a clearer name, so callers using the old field stopped passing validation. Another still accepted a per-account override at the boundary but dropped it while translating and queueing the search. The request shape survived; its meaning did not.

Compatibility therefore needs two questions:

  1. Can an old caller still submit its request?
  2. Does that request still produce the same relevant decision?

Schema tests answer the first question. Only a boundary-to-decision test can answer the second.

Canonicalize aliases once

Supporting two names does not require dragging two concepts through the whole system. Do the untidy compatibility work once, at the transport boundary, then let internal code use one canonical representation:

legacy serviceIds ----------\
                             > canonical delivery option IDs
modern deliveryOptionIds ---/

The correction made each field optional only when the other was present. A small boundary method returned the modern value when supplied and otherwise returned the legacy value.

A synthetic equivalent is almost boring:

deliveryOptionIds ?? serviceIds ?? []

The null-coalescing expression looks harmless, but it contains protocol policy: when a caller sends both fields, the modern one wins. Other defensible policies are:

  • reject requests containing both fields;
  • require both values to be equal;
  • merge both sets and remove duplicates; or
  • prefer the old field during a transition.

Silent precedence is safe only when it is deliberate and tested. Otherwise the caller can send two different meanings and get whichever answer happened to fall out of an implementation detail.

After canonicalization, stop branching on the legacy name. Scattered support creates two code paths which will drift independently. Keep the seam narrow enough that deleting it later is an ordinary change, not another migration.

Preserve the meaning, not merely the key

The option identifier in this request selected more than a filter. It also selected account-specific credentials supplied in a separate override map.

The relevant path was longer than the request object:

RPC input
  -> transport data
  -> search request
  -> domain search
  -> cached enquiry
  -> refresh dispatch
  -> queued refresh
  -> provider resolver

A refactor omitted the override at several translations. The final resolver therefore fell back to shared configuration even though the caller’s request had been accepted.

The repair propagated the override through every owned boundary, persisted the necessary account context with the cached enquiry, and supplied it when that enquiry refreshed later. Regression tests pinned the value at the initial request, canonical search data, dispatch payload, queued job, and final resolver. Merely proving that validation accepted the old key would have missed the actual bug.

This is semantic compatibility. The promise was not “the JSON field parses.” The promise was “this search uses the caller’s selected account context now and when refreshed.”

Sensitive override contents create an additional constraint. They should not be copied into logs, cache keys, or telemetry merely to prove propagation. Tests can use opaque markers and hashes. Production code should carry the minimum secret-bearing structure to the component which owns credential selection.

Additive changes can still break old behavior

Adding a field is often described as backward compatible. It usually is, but only when old callers may omit it and the default preserves old behavior.

It can still break compatibility when:

  • validation makes the new field required;
  • a new default changes old requests;
  • the field changes cache identity but old entries are reused;
  • a serializer starts emitting a value old consumers reject;
  • precedence changes when both old and new forms appear; or
  • an internal translation drops an older field while adding the new one.

The repair kept newer filters and aliases while restoring the older request shape and account override. Compatibility work does not require reverting every improvement. Preserve the old observable contract, keep additive behavior whose defaults are safe, and canonicalize both into one current model.

Test a compatibility matrix, not one example

One legacy happy-path test is too narrow. It proves that one example happened to work, which is not the same thing as defining the migration. Use a matrix:

Request shapeExpected boundary resultExpected internal value
old field onlyacceptedold value canonicalized
new field onlyacceptednew value preserved
neither fieldrejected when one is requirednone
both equalaccepted or rejected by declared policyone equal value
both differentrejected or resolved by declared precedencedeterministic value
old field plus old companion dataacceptedcompanion meaning reaches final decision
new field plus new optional filteracceptedboth current meanings preserved

Then test the farthest observable boundary which matters. If the value selects a data source, assert which source is resolved. If it changes a query, assert the result set. If it must survive a queue, inspect the deserialized job or execute the worker boundary.

The regression tests do not prove every external caller remained compatible. They prove that the known old alias reaches the search pipeline and the retained account context reaches later refresh and resolver boundaries. No caller inventory, deprecation telemetry, or safe removal date is retained.

A compatibility layer needs an exit condition

Keeping an alias forever has a cost. Deprecation without an exit condition is just permanent compatibility code with a warning label:

  • documentation carries two names;
  • validation and generated schemas become noisier;
  • callers can disagree about precedence;
  • telemetry must distinguish old and new use; and
  • future maintainers may mistake the alias for a second concept.

Removal needs evidence, not impatience. A practical sequence is:

  1. accept both forms and canonicalize them;
  2. document precedence and defaults;
  3. emit deprecation information through a channel callers can observe;
  4. measure use of the old form without recording sensitive payloads;
  5. identify and migrate known callers;
  6. announce a removal version or date;
  7. reject the old form in a new contract version; and
  8. delete the compatibility seam only after the agreed window.

If callers cannot be inventoried or upgraded, keeping a small alias may be cheaper than introducing version negotiation. If the old and new meanings have actually diverged, an alias is dishonest and a versioned endpoint or operation is the cleaner boundary.

Prefer boring compatibility over involuntary coordination

A hard cutover can be reasonable when one team deploys every caller and server atomically. It can also be reasonable before a contract has any consumers.

Once releases are independent, a rename forces coordinated deployment:

old client + new server -> fails
new client + old server -> may fail

An additive alias permits both deployment orders:

old client + compatible server -> works
new client + compatible server -> works

That temporary redundancy buys operational freedom. The server can deploy first, clients can migrate independently, and rollback does not depend on everyone moving at once. A few lines of deliberately boring translation are cheap compared with a coordinated deployment across owners.

The public reproduction executes the alias matrix and carries a synthetic account marker through four translation stages. It does not reproduce a real RPC framework, queue, credential, client population, or deployment.

A better internal name may be worth adopting. Pretending the old external name never existed is not. Accept the deployed language at the edge, translate it once, and prove that its meaning survives all the way to the decision the caller depends on.