Skip to content

PATTERN Cited by 1 source

Migrate function for component downgrade

Problem

You bumped a component's major version because you needed to change the type of one of its fields. Old clients in the field don't understand the new shape and can't be forcibly upgraded. How does the backend serve both?

Pattern

At every breaking change (major version bump), ship a migrate function that takes an instance of the new major version and returns an instance of the previous major version:

def migrate(model: ComponentV<N+1>) -> ComponentV<N>:
    ...

Auto-generate the stub as part of the same codegen pipeline that produces the component bindings (see patterns/single-json-spec-to-multi-platform-codegen). Make the stub's default behaviour fail closed: throw NotSupportedError or equivalent so forgetting to implement the migration is loud, not silent. Developers implement the migration per-field for the new version.

At serialisation time, the backend decides — based on the client's spec version — that the client speaks V<N> but the backend wants to send V<N+1>. The runtime invokes migrate() to produce the V<N> instance and serialises that.

   build view:  CookbookButtonV1(text=FormattedText(...))
                         │ client spec: android@23.0
                         │ spec says Button max version = "0.8"
                         ▼ runtime calls migrate()
                  CookbookButtonV0(text="Click me to go to Yelp", ...)
                  serialise  →  old client can deserialise

Canonical wiki instance (Yelp Konbini)

Konbini's Button example:

def migrate(model: CookbookButtonV1) -> CookbookButtonV0:
    """
    The migrate function defined here is meant to backport an
    instance of CookbookButtonV1 to an instance of
    CookbookContainerV0. Its default behavior is to throw an
    error signifying that backporting the model is not
    supported. However, we highly recommend replacing this
    behavior with your own custom logic ...
    """
    return CookbookButtonV0(
        text=model.text.toString(),      # V1 FormattedText -> V0 String
        style=model.style,
        on_click=model.on_click,
        size=model.size,
        background_color=model.background_color,
    )

The pattern hinges on text=model.text.toString() — a per-field, domain-aware backport. Losing the http-link information in FormattedText is acceptable here because the V0 client couldn't have rendered links anyway; the string-only representation is strictly less rich but strictly understandable.

Yelp: "This method is called whenever the spec version on the client is lower than the one on the backend. In that case, the backend tries to instantiate a CookbookButtonV1 class but it realizes the client only supports CookbookButtonV0."

Key properties

  • Fail-closed default. The generated default throws. If a developer adds a breaking change but forgets to implement migrate(), the backend's feature error wrapper drops the feature rather than crashing the view — a correct, conservative fail mode.
  • Explicit opt-out. Not implementing migrate() is an explicit choice to drop older clients for this feature. No accident; no silent data-shape mismatch.
  • Field-level fidelity decisions. Losing information is frequently unavoidable when downgrading; the migrate function is where each field's loss is documented as code.
  • Co-lives with the new major. Both V0 and V1 classes exist in the backend codebase; migrate() lives on V1. Old clients keep working; new clients get new capabilities; one codebase.

Open questions / caveats

  • Cross-major chains. The post shows only V1 → V0. If V2.0 ships while some clients still pin specs at V0, can the migrations compose (V2 → V1 → V0) or does each pair need its own pairwise function? Not stated. A safe implementation probably composes; Konbini's exact choice is undocumented.
  • Migration-function tests. Because a bad migration silently renders the wrong thing on older clients, they're the kind of code that most deserves per-field unit tests — but the post doesn't say whether Yelp enforces test discipline here.
  • Removing a major. Eventually you want to delete V0. The pattern doesn't by itself say when that's safe — you need fleet visibility on which spec versions are still deployed.

Composes with

Contrasts

  • Schema-level field-compatibility conventions (Protobuf, Thrift, Avro) — prevent most breaking changes by convention. Migrate functions are a richer alternative for the cases where type-change is unavoidable.
  • API versioning (/v1/ vs /v2/) — coarser unit of versioning (endpoint, not component). Migrate is the finer-grained equivalent at the component level.
  • No-migrate fail-open — some systems silently send unknown fields and hope older clients skip them. This pattern rejects that in favour of explicit, declarative fallback.

When to reach for this pattern

  • Client fleet has upgrade lag (mobile apps, long-lived desktop installs).
  • Interface schemas will plausibly need type changes or renames over time.
  • You already have the codegen substrate (see patterns/single-json-spec-to-multi-platform-codegen) that makes adding migrate() stubs nearly free.

When not to

  • Interfaces are intentionally additive-only (pure field-compat convention suffices).
  • Forced upgrade is acceptable (internal tooling).
  • The engineering cost of maintaining per-component migrate chains outweighs the cost of dropping older clients.

Seen in

Last updated · 550 distilled / 1,221 read