Skip to content
academia.sh

Lesson 21 / 34

Backward Compatibility

The same change classified oppositely in the request and response directions, a differ that compares two schema versions and detects breaking changes, and deriving the version number from the diff instead of from argument.

Contents

The previous lesson published two versions side by side but did not ask why the version number went up to two. The member field turning into an object was counted as breaking; adding a new option to the branch field was not discussed. When these questions are left to intuition, every team gives a different answer; the version number rests on opinion and gets skipped over with the sentence “this is a small change.”

This lesson defines breakingness, writes a differ, and derives the version number from the diff. The definition’s starting point is a simple observation: the same change can be compatible in the request direction while being breaking in the response direction.

Breakingness Determined by Direction

A contract has two directions. The client sends a body, the server produces a body. Because the client sends, the server defines an accepted set; because the server produces, it also defines a produced set.

If what the server accepts narrows, old clients are left out. If what is accepted widens, old clients are unaffected; what they send is still inside the set. So in the request direction, narrowing is breaking and widening is compatible.

If what the server produces widens, old clients encounter something they do not recognize. If what it produces narrows, clients are unaffected; the fields they read are still in place. So in the response direction, widening is breaking and narrowing is compatible.

The two directions’ rules are exactly each other’s opposite. This inverse relationship is why a single change, like removing the /isbn field, is compatible in the request and breaking in the response: an old client keeps sending the field removed from the request, and the server ignores it; but for the field removed from the response, the old client keeps looking for it and does not find it.

The two schemas below give the loan resource’s state across a version range. Each field states three things: its type, whether it is required, and, if any, the values it can take.

// schemas.mjs — two schema versions of the loan resource
// Field: { type, required, values }.  No values means the value set is unbounded.
export const V1 = {
  version: "1.4.2",
  request: {
    "/member":   { type: "string", required: true },
    "/isbn":     { type: "string", required: true },
    "/branch":   { type: "string", required: false, values: ["central", "shore"] },
    "/dayCount": { type: "number", required: false },
  },
  response: {
    "/id":         { type: "string", required: true },
    "/member":     { type: "string", required: true },
    "/isbn":       { type: "string", required: true },
    "/returnDate": { type: "string", required: true },
    "/status":     { type: "string", required: true, values: ["open", "closed"] },
  },
};

export const V2 = {
  request: {
    "/member":   { type: "string",  required: true },
    "/items":    { type: "array",   required: true },                                    // new required field
    "/branch":   { type: "string",  required: true, values: ["central", "shore", "hill"] },
    "/dayCount": { type: "integer", required: false },                                   // type narrowed
    "/note":     { type: "string",  required: false },                                   // new optional field
  },
  response: {
    "/id":         { type: "string", required: true },
    "/member":     { type: "object", required: true },                                    // type changed
    "/items":      { type: "array",  required: true },                                    // new field
    "/returnDate": { type: "string", required: false },                                   // now optional
    "/status":     { type: "string", required: true, values: ["open", "closed", "overdue"] },
  },
};

The Differ

The differ walks the two schemas, finds every difference, and applies the direction rule. Type changes require a small subtype relationship: every integer is a number, but the reverse is not true. Types with no such relationship between them are breaking in both directions.

// differ.mjs — compares two schema versions, detects breaking changes, derives the version
// Usage: node differ.mjs [tolerant]   ("tolerant": clients tolerate an unknown value)

import { V1, V2 } from "./schemas.mjs";

const TOLERANT = process.argv[2] === "tolerant";

// Type lattice: integer is a subtype of number. A subtype accepts a portion of what the supertype accepts.
const SUBTYPES = { integer: ["number"], date: ["string"] };
const isSubtype = (a, b) => a === b || (SUBTYPES[a] ?? []).includes(b);

// Two directions, two rules. Request: breaking if what the server accepts narrows.
// Response: breaking if what the server produces widens.
function compare(oldSchema, newSchema, direction) {
  const breaking = direction === "request"
    ? { narrowing: true, widening: false, requiredAdded: true, requiredRemoved: false, removal: false }
    : { narrowing: false, widening: true, requiredAdded: false, requiredRemoved: true, removal: true };
  const findings = [];
  const add = (kind, name, description) => findings.push({ kind, direction, name, description });

  for (const [name, o] of Object.entries(oldSchema)) {
    const n = newSchema[name];
    if (!n) { add(breaking.removal ? "breaking" : "compatible", name, "field removed"); continue; }
    if (!o.required && n.required) add(breaking.requiredAdded ? "breaking" : "compatible", name, "was optional, became required");
    if (o.required && !n.required) add(breaking.requiredRemoved ? "breaking" : "compatible", name, "was required, became optional");
    if (o.type !== n.type) {
      const narrowing = isSubtype(n.type, o.type), widening = isSubtype(o.type, n.type);
      const kind = narrowing ? (breaking.narrowing ? "breaking" : "compatible")
        : widening ? (breaking.widening ? "breaking" : "compatible")
        : "breaking";                                       // unrelated types are breaking in both directions
      add(kind, name, `type ${o.type} → ${n.type}` + (narrowing ? " (narrowing)" : widening ? " (widening)" : " (unrelated)"));
    }
    const oldValues = o.values, newValues = n.values;
    if (oldValues && newValues) {
      const removed = oldValues.filter((d) => !newValues.includes(d)), added = newValues.filter((d) => !oldValues.includes(d));
      if (removed.length) add(breaking.narrowing ? "breaking" : "compatible", name, `value set narrowed: ${removed.join(",")} removed`);
      if (added.length) {
        const isBreaking = breaking.widening && !(direction === "response" && TOLERANT);
        add(isBreaking ? "breaking" : "compatible", name, `value set widened: ${added.join(",")} added`);
      }
    }
  }
  for (const [name, n] of Object.entries(newSchema)) {
    if (oldSchema[name]) continue;
    const isBreaking = n.required && breaking.requiredAdded;
    add(isBreaking ? "breaking" : "addition", name, n.required ? "required field added" : "optional field added");
  }
  return findings;
}

const findings = [...compare(V1.request, V2.request, "request"), ...compare(V1.response, V2.response, "response")];
const ORDER = { breaking: 0, addition: 1, compatible: 2 };
findings.sort((a, b) => ORDER[a.kind] - ORDER[b.kind] || a.direction.localeCompare(b.direction));

console.log(`tolerance declaration: ${TOLERANT ? "present" : "absent"}\n`);
console.log("kind       direction  name          change");
for (const f of findings) console.log(`${f.kind.padEnd(10)} ${f.direction.padEnd(10)} ${f.name.padEnd(13)} ${f.description}`);

const counts = { breaking: 0, addition: 0, compatible: 0 };
for (const f of findings) counts[f.kind]++;
const [major, minor, patch] = V1.version.split(".").map(Number);
const next = counts.breaking ? `${major + 1}.0.0` : counts.addition ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;

console.log(`\nbreaking: ${counts.breaking}   addition: ${counts.addition}   compatible: ${counts.compatible}`);
console.log(`current version: ${V1.version}   derived version: ${next}`);
console.log(`if breaking changes were excluded: ${major}.${minor + 1}.0`);
tolerance declaration: absent

kind       direction  name          change
breaking   request    /branch       was optional, became required
breaking   request    /dayCount     type number → integer (narrowing)
breaking   request    /items        required field added
breaking   response   /member       type string → object (unrelated)
breaking   response   /isbn         field removed
breaking   response   /returnDate   was required, became optional
breaking   response   /status       value set widened: overdue added
addition   request    /note         optional field added
addition   response   /items        required field added
compatible request    /isbn         field removed
compatible request    /branch       value set widened: hill added

breaking: 7   addition: 2   compatible: 2
current version: 1.4.2   derived version: 2.0.0
if breaking changes were excluded: 1.5.0

The last two lines show the inverse relationship directly. Removing the /isbn field is compatible in the request, breaking in the response. Adding hill to the /branch field’s value set is compatible in the request; adding overdue to the /status field’s value set is breaking in the response. The same operation lands in the opposite class in the opposite direction.

The /items field in the addition rows is also a consequence of this rule: adding a required field to the response is compatible, because the old client was not reading that field anyway. Adding a required field to the request, however, is breaking, and the table shows exactly that.

The version number is no longer debated but derived. Seven breaking changes raise the major version. The last line shows a separate option: if the breaking changes were excluded from the release and only the additions were shipped, the version would be 1.5.0. These two lines turn the debate “what should we do about the version” into “what are we putting in the release”; the second is an answerable question.

Tolerance Declaration

Widening the value set in the response is the most contested item on the list. Does adding the overdue status break old clients? The answer depends on what the contract says. If the contract says “if an unknown status value arrives, the client must treat it as unrecognized,” the widening is not breaking; if it does not say that, it is breaking.

node differ.mjs tolerant | tail -4
breaking: 6   addition: 2   compatible: 3
current version: 1.4.2   derived version: 2.0.0
if breaking changes were excluded: 1.5.0

The breaking count drops from seven to six. The lesson here is that tolerance is a contract clause. It is not a decision made on the server side; it requires clients to actually be written that way, and that can only be expected if it was declared in the contract beforehand. A tolerance clause added to the contract afterward is itself a breaking change.

What the Diff Does Not Say

The differ tells you what could break, not what will break. To see the distinction, the same schemas are tested with a real old client.

// simulate.mjs — tests the classification by actually running an old client
// Old client sends a v1 body and reads a v1 response; the server is on the v2 schema.
import { V1, V2 } from "./schemas.mjs";

const OLD_REQUEST = { "/member": "U-1001", "/isbn": "978-0262033848", "/branch": "shore", "/dayCount": 14.5 };
const NEW_RESPONSE = { "/id": "O-1", "/member": { id: "U-1001" }, "/items": [{}], "/status": "overdue" };

const typeOf = (d) =>
  Array.isArray(d) ? "array" : typeof d === "object" ? "object"
    : typeof d === "number" ? (Number.isInteger(d) ? "integer" : "number") : "string";

// 1) The old client's body is run through the new schema.
const requestErrors = [];
for (const [name, field] of Object.entries(V2.request)) {
  const d = OLD_REQUEST[name];
  if (d === undefined) { if (field.required) requestErrors.push(`${name}: required field not sent`); continue; }
  if (field.type !== typeOf(d) && !(field.type === "number" && typeOf(d) === "integer")) requestErrors.push(`${name}: sent ${typeOf(d)}, expected ${field.type}`);
  if (field.values && !field.values.includes(d)) requestErrors.push(`${name}: ${d} is not accepted`);
}

// 2) The new response is read with the schema the old client expects.
const responseErrors = [];
for (const [name, field] of Object.entries(V1.response)) {
  const d = NEW_RESPONSE[name];
  if (d === undefined) { if (field.required) responseErrors.push(`${name}: expected field missing from response`); continue; }
  if (field.type !== typeOf(d)) responseErrors.push(`${name}: got ${typeOf(d)}, expected ${field.type}`);
  if (field.values && !field.values.includes(d)) responseErrors.push(`${name}: unrecognized value ${d}`);
}

console.log("— the old client's body on the new server —");
for (const h of requestErrors) console.log(`  ${h}`);
console.log(`  fields rejected: ${requestErrors.length}`);

console.log("\n— the new server's response on the old client —");
for (const h of responseErrors) console.log(`  ${h}`);
console.log(`  fields not readable: ${responseErrors.length}`);
— the old client's body on the new server —
  /items: required field not sent
  /dayCount: sent number, expected integer
  fields rejected: 2

— the new server's response on the old client —
  /member: got object, expected string
  /isbn: expected field missing from response
  /returnDate: expected field missing from response
  /status: unrecognized value overdue
  fields not readable: 4

The differ had found three breaking changes in the request direction; only two of them materialized on this client. The /branch field makes the difference: it went from optional to required, but this client was already sending it. The change is breaking by the contract — it raises the version number — but its cost for this client is zero.

Contract and cost are separate measures. The version number looks at the contract and rises even if not a single client is affected; the release plan looks at cost. The migration cost calculation in the Design Systems course established the same distinction: breakingness is the contract’s measure, the number of affected call sites is reality’s measure.

The four items in the response direction, however, materialized exactly. This shows another asymmetry between the directions: breakingness in the request direction depends on what the client sends, breakingness in the response direction depends on what it reads. Clients are generally not required to send every field in the schema, but the fields they read are fixed; this is why breaking changes in the response direction turn into real cost at a higher rate.

Summary

  • The contract’s two directions are subject to opposite rules: it is breaking in the request direction if what the server accepts narrows, and breaking in the response direction if what it produces widens.
  • The same change falls into opposite classes in the two directions; removing a field from the request is compatible, removing it from the response is breaking.
  • Adding a required field to the response is compatible, adding a required field to the request is breaking; type narrowing is breaking in the request, type widening is breaking in the response.
  • The version number is derived from the diff result; when breaking changes are excluded from the release, the derived number changes too, moving the debate from the version to the release scope.
  • Tolerance for an unknown value is a contract clause and must be declared beforehand; adding it afterward is itself a breaking change.
  • The differ tells you what could break; which client actually breaks is a separate measurement, and breakingness in the response direction turns into real cost at a higher rate.

Next Step

The differ detected seven breaking changes and raised the version to 2.0.0, but left one question unanswered: how long will the old version live? Raising the version number does not migrate old clients on its own; for them to migrate, they first have to learn about the situation, then find time for the transition. The next lesson turns this duration into a policy: it adds headers that announce deprecation in the response itself, models usage telemetry to calculate the share of consumers still on the old version at the end of the transition period, and shows how the sunset date is chosen based on that share.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close