PATTERN Cited by 1 source
Spec-version negotiation for backward compat¶
Problem¶
Your backend emits structured interface objects (UI components, RPC messages, API responses) that multiple generations of clients — old and new app versions, in the field, that cannot be forcibly upgraded — must be able to decode. You can't ask every user to upgrade before shipping a change. Some mechanism is needed to let the backend decide, per request, which version of each interface to send.
Naive approaches fail:
- Always send the newest version — breaks older clients.
- Always send the oldest version — pins the backend to the slowest-moving client; no-one gets new capabilities.
- Feature flag per user — solves rollout but not schema-compatibility; fl ags don't describe which shape the client speaks.
Pattern¶
Bundle a spec file into every client build listing the
versions of every component interface that build supports.
Every request carries a spec context (e.g.
android@23.0). At serialisation time the backend consults
the named spec and picks, per component, the highest version
that spec allows. When the backend wants to send a newer
version than the spec supports, it invokes the component's
migrate
function to downgrade.
Client (v23.0) Backend
────────────── ───────
bundled spec: android@23.0 view = build_view()
{Button: "0.8", for component in view:
ButtonStyle: "0.1"} spec = lookup(request.spec)
allowed_version =
│ request + spec context spec[component.name]
│ "android@23.0" if component.version >
▼ allowed_version:
view bytes component = migrate(component)
▲ serialise(component)
│ serialised w/ component.version <= spec
│
client deserialises using its
generated-for-spec-23.0 libraries
Key properties:
- Spec is immutable per app build. No runtime negotiation, no probing — the client advertises exactly the capability surface it shipped with.
- Backend holds the compat logic. Clients just declare what they support; the backend picks what to send.
- Migration discipline at every major bump. The migrate function is the only mechanism that keeps old clients alive across major versions. Default generated behaviour throws — developers must explicitly opt in.
Canonical wiki instance (Yelp Konbini)¶
Konbini generates the client spec file and bundles it into each of its four libraries:
{
"version": "23.0",
"interfaces": { "cookbook.Button": "1.0" },
"enumerations": { "cookbook.ButtonStyle": "0.1" }
}
Every CHAOS request sends a Konbini context of the form
spec_name@spec_version, e.g. android@23.0. Yelp:
"When the backend receives it, it will look at version 23.0
of the Android spec and see which version of the
CookbookButton can be sent back to the client without
breaking it." If the backend wants to send
CookbookButtonV1 but the spec only allows V0, the
auto-generated migrate() is invoked to backport. Yelp:
"This method is called whenever the spec version on the
client is lower than the one on the backend."
Composes with¶
- patterns/single-json-spec-to-multi-platform-codegen — the spec file is itself auto-generated alongside the language-specific bindings; same CI pipeline produces both.
- patterns/migrate-function-for-component-downgrade — the fallback when spec's allowed version is lower than backend's current version.
- concepts/register-based-client-capability-matching — at the feature altitude, CHAOS's Register mechanism also gates features by client capability (platform + required library classes). Spec-version operates at the component-version altitude below Registers. The two compose: Register decides which feature to serve; spec-version decides which version of each component in that feature.
Preconditions¶
- Stable spec-naming scheme.
platform@versionworks; uniqueness + monotonicity matter. Yelp usesandroid@23.0,ios@<N>, etc. - Backend keeps old component majors alive for as long
as any pinned spec references them. The moment
V0is deleted, every client pinningV0breaks. - Every major bump ships a migrate — or the backend has to have a policy for feature-drop on unsupported clients.
- Release-train discipline — new specs ship with new client builds; rollout percentage of new specs corresponds directly to how much backend-side compat burden can be shed.
Tradeoffs¶
- Backend-side complexity. Every major bump adds a
migrate(); the backend is a conservative sum-of-all-live- specs system until old specs are fully deprecated. - Spec-management overhead. Each platform needs a fresh spec published per component/enum/action version bump.
- Fleet visibility. Operators have to reason about the
distribution of spec versions in the fleet (who's still on
android@22.x?) — spec version becomes a first-class operational axis.
When to reach for this pattern¶
- Heterogeneous client fleet with app-store upgrade lag.
- Component / interface shapes that will plausibly need breaking changes over time.
- Backend has enough engineering resource to maintain multiple live majors + their migrate chains.
When not to¶
- Single-client or fast-upgrade fleet (e.g. a single web app — deploy newer interface + newer client together).
- API where forced upgrade is acceptable (e.g. internal tooling).
Seen in¶
- sources/2026-04-22-yelp-how-yelp-keeps-server-driven-ui-consistent-across-four-platforms
— canonical wiki first source. Yelp's Konbini bundles
client spec files into each generated library; CHAOS
requests carry
spec_name@spec_versioncontext; backend chooses compatible component versions and falls back tomigrate()for downgrade.