You run a payments API that thousands of businesses have integrated against. Your team needs to ship a breaking change: a field that was a boolean is being replaced by a richer string enum. Some integrations are years old and you cannot force everyone to migrate at once.
Design a versioning system that lets you ship this change — and future breaking changes — without ever silently corrupting an existing integration. Focus on the server-side mechanics: how versions are identified per request, how the transformation is applied, and what the maintenance cost looks like as the number of versions grows.
Practice against the follow-up probes
- How does the server know which version of the response to produce for a given request, and what are the failure modes of each approach?
- Walk me through what happens in your system when a client sends a request with no version header at all.
- Your transformation pipeline now has forty version change modules accumulated over five years. A new engineer adds one in the wrong order. What breaks, and how do you prevent it?
- Some breaking changes can't be expressed as a pure response transformation — for example, the semantics of how a webhook event is triggered have changed. How does your versioning abstraction handle that?
- At what point, and by what process, would you retire an old version? What are the risks on both sides of that decision?
Show answer guide
What the interviewer is probing
This question probes whether the candidate can separate version identification (per-request routing) from version transformation (response rewriting) and reason about each independently. Strong candidates will surface the fixed-cost property — that adding a new version should not require touching old code — and grapple honestly with the cases where clean encapsulation breaks down (side-effectful changes, webhook semantics, schema migrations). Decision quality is tested by the version-retirement probe: it forces the candidate to weigh correctness and compatibility guarantees against long-term maintenance debt.
What a strong answer covers
- Version identification — three sources in priority order: explicit header on the request (developer testing or per-request override), OAuth app version if the request is acting on behalf of a user, else the account's pinned version set at first API call. The pinned-at-first-call pattern is important: it means a newly signed-up user always gets the latest behavior without any configuration, while existing integrations are shielded from surprises.
- Canonical representation: internally, the API always generates responses in the current schema — the latest version is the source of truth. Versioned responses are produced by transforming down from current, never by maintaining parallel codepaths for each version. This keeps feature code clean.
- Transformation pipeline: model each breaking change as a self-contained module (e.g., a class or record) that declares: the version it belongs to, which resource types it affects, and a transformation function
f(current_response) → older_response. Modules are stored in a time-ordered list. To serve versionV, walk backwards from current through every module dated afterVand apply each transformation in sequence. Each module is written assuming it receives data shaped as it was when it was authored, not the raw current shape — this is the key invariant that makes modules composable without coupling. - The boolean→enum case specifically: the current schema has
status: "verified" | "unverified" | "pending". The version change module for the old version mapsstatus→verified: (status === "verified"). A client on the old pinned version sees the boolean; a client on any newer version sees the enum. No branching in the main codebase. - Side-effectful changes — the hard case: some changes can't be expressed as a pure response transformation. Examples: a webhook is now fired under different conditions, or a field change alters idempotency behavior. These must be flagged explicitly (e.g., an annotation on the module) and handled with conditional logic elsewhere in the codebase. This is unavoidable but should be the exception; the system should make the cost of a side-effectful change visible so engineers are incentivized to design around it.
- Maintenance cost growth: with many modules, the risks are (a) a module inserted in the wrong order corrupts responses for version ranges between two dates, and (b) modules that can't be encapsulated accumulate as scattered conditionals. Mitigations: enforce module ordering with an automated test that applies the full pipeline against golden fixtures for each version; lint for un-annotated side-effect conditionals outside modules.
- Version retirement: set a deprecation date, email users whose pinned version is below a sunset threshold, provide a migration guide, and sunset with enough lead time (e.g., 12–18 months). Risks of retiring too aggressively: break silent integrations; risks of never retiring: the transformation pipeline grows without bound and the oldest modules become load-bearing archaeology no one understands. A pragmatic criterion: retire when the module count for a version range exceeds a threshold and active traffic to that version drops below a floor.
- Common wrong turns: (1) maintaining parallel response-generation codepaths per version — doesn't scale; (2) putting version logic inline in controller code — spreads the maintenance cost invisibly; (3) treating version as just a URL prefix with no transformation system — forces users into big-bang upgrades.
The underlying concept
Backward-compatible API evolution is fundamentally a transformation problem: the internal model evolves forward, and versioned responses are produced by composing a sequence of invertible (or at least directional) transforms from the current shape to the requested historical shape. The key design invariant is that each transform module is written against a stable interface — the shape of data at the time the breaking change was made — so modules remain independently understandable even as the current schema continues to evolve. This is analogous to database migration scripts run in reverse: you never rewrite old migrations, you write new ones. The account-pinned-at-first-call pattern solves a usability problem orthogonal to the transformation mechanics: it removes the need for new integrators to configure a version while still guaranteeing that existing integrators never receive an unexpected schema change. The long-run tension in any such system is between compatibility guarantees (never break existing users) and accumulated complexity (every old version is debt); retirement policies are the pressure-release valve, and their design is as important as the versioning mechanics themselves.
Source
Derived from APIs as infrastructure: future-proofing Stripe with versioning