Skip to content

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

Preconditions

  • Stable spec-naming scheme. platform@version works; uniqueness + monotonicity matter. Yelp uses android@23.0, ios@<N>, etc.
  • Backend keeps old component majors alive for as long as any pinned spec references them. The moment V0 is deleted, every client pinning V0 breaks.
  • 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

Last updated · 550 distilled / 1,221 read