An API changes serviceIds to deliveryOptionIds. The new name describes the
domain better. The implementation becomes easier to read.
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 resultPassing only the first step is cosmetic compatibility. A parser may accept the old field while a later mapper reads only the new one. The request returns success but behaves as if no options were supplied.
The retained regression had both forms. One refactor replaced a request field with a clearer name, so callers using the old field stopped passing validation. Another refactor 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:
- Can an old caller still submit its request?
- Does that request still produce the same relevant decision?
Schema tests answer the first. Boundary-to-decision tests are needed for the second.
Canonicalize aliases once
Supporting two names does not require carrying two concepts through the whole system. Accept both at the transport boundary, choose one canonical representation, and let internal code use only that form:
legacy serviceIds ----------\
> canonical delivery option IDs
modern deliveryOptionIds ---/The retained 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 looks like this:
deliveryOptionIds ?? serviceIds ?? []That expression contains a policy which must be documented: when a caller sends both, the modern field 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 a client can send two different meanings and receive an answer determined by an implementation detail.
After canonicalization, stop branching on the legacy name. Scattered support creates two code paths which drift independently. The compatibility seam should be narrow enough to delete later.
Preserve the meaning, not merely the key
The option identifier in this request selected more than a filter. For one integration, 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 resolverA 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 the enquiry refreshed later. Regression tests pinned the value at the initial request, canonical search data, dispatch payload, queued job, and final resolver.
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 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 retained repair kept newer filters and aliases while restoring the older request shape and account override. That is a useful migration principle: compatibility work need not revert 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. A field migration needs a matrix:
| Request shape | Expected boundary result | Expected internal value |
|---|---|---|
| old field only | accepted | old value canonicalized |
| new field only | accepted | new value preserved |
| neither field | rejected when one is required | none |
| both equal | accepted or rejected by declared policy | one equal value |
| both different | rejected or resolved by declared precedence | deterministic value |
| old field plus old companion data | accepted | companion meaning reaches final decision |
| new field plus new optional filter | accepted | both 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 retained tests do not prove every external caller remained compatible. They prove 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:
- 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:
- accept both forms and canonicalize them;
- document precedence and defaults;
- emit deprecation information through a channel callers can observe;
- measure use of the old form without recording sensitive payloads;
- identify and migrate known callers;
- announce a removal version or date;
- reject the old form in a new contract version; and
- 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 failAn additive alias permits both deployment orders:
old client + compatible server -> works
new client + compatible server -> worksThat temporary redundancy buys operational freedom. The server can deploy first, clients can migrate independently, and rollback does not depend on everyone moving at once.
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. It is not worth pretending the old external name never existed. 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.