05 · Schema Evolution & Breaking-Change Detection¶
GraphQL APIs are famously "versionless": instead of /v2, one schema evolves continuously.
That only works if you can answer, before every change, will this break someone? — and for
removals, is anyone still using this? Level 2 · 01 showed
which changes are breaking in principle. This lesson is about operating that at scale:
replacing a field in stages, measuring who still uses the old one, and validating real client
operations against the proposed schema.
The expand–contract pattern¶
Every breaking change can be split into non-breaking steps:
- Expand — add the new field alongside the old one. Nothing breaks.
- Migrate — deprecate the old field, update clients, watch usage fall.
- Contract — remove the old field once usage reaches zero (or an agreed cut-off).
Here the change is replacing priceCents: Int! with a price: Money! object (the design from
Level 2 · 01). Steps 1 and 2 are the "v2" schema:
import { ApolloServer, HeaderMap } from "@apollo/server";
import { TypeInfo, visit, visitWithTypeInfo, getNamedType, buildSchema, parse, validate, findBreakingChanges, findDangerousChanges } from "graphql";
// v2 of the schema: priceCents is deprecated in favour of price { amount currency }
const typeDefsV2 = /* GraphQL */ `
type Query { products: [Product!]! }
type Product {
id: ID!
name: String!
priceCents: Int! @deprecated(reason: "Use price { amount currency }. Removal planned after all clients migrate.")
price: Money!
}
type Money { amount: String! currency: String! }
`;
const products = [{ id: "1", name: "Lamp", cents: 2499 }];
const resolvers = {
Query: { products: () => products },
Product: {
priceCents: (p) => p.cents,
price: (p) => ({ amount: (p.cents / 100).toFixed(2), currency: "USD" }),
},
};
// Field-usage tracking: which client uses which Type.field
const usage = new Map(); // "Type.field" -> Map(client -> count)
const usagePlugin = {
async requestDidStart({ request }) {
const client = request.http?.headers.get("apollographql-client-name") ?? "unknown";
return {
async didResolveOperation({ document, schema }) {
const typeInfo = new TypeInfo(schema);
visit(document, visitWithTypeInfo(typeInfo, {
Field() {
const parent = typeInfo.getParentType();
const field = typeInfo.getFieldDef();
if (!parent || !field || field.name.startsWith("__")) return;
const key = `${parent.name}.${field.name}`;
const perClient = usage.get(key) ?? new Map();
perClient.set(client, (perClient.get(client) ?? 0) + 1);
usage.set(key, perClient);
},
}));
},
};
},
};
const server = new ApolloServer({ typeDefs: typeDefsV2, resolvers, plugins: [usagePlugin] });
await server.start();
const send = (client, query) => server.executeOperation(
{ query, http: { method: "POST", headers: new HeaderMap([["apollographql-client-name", client]]), search: "", body: {} } });
// Simulated traffic: an old mobile build, the migrated web app, a partner script
for (let i = 0; i < 3; i++) await send("ios-3.2", "{ products { name priceCents } }");
for (let i = 0; i < 5; i++) await send("web", "{ products { name price { amount currency } } }");
await send("partner-feed", "{ products { id priceCents price { currency } } }");
console.log("usage of deprecated and replacement fields:");
for (const key of ["Product.priceCents", "Product.price", "Money.amount"])
console.log(` ${key.padEnd(20)} ${JSON.stringify(Object.fromEntries(usage.get(key) ?? []))}`);
// Deprecation is visible to tools via introspection
const intro = await server.executeOperation({ query: `{ __type(name: "Product") { fields(includeDeprecated: true) { name isDeprecated deprecationReason } } }` });
console.log("\nintrospection:", JSON.stringify(intro.body.singleResult.data.__type.fields.filter((f) => f.isDeprecated)));
// v3 proposal: remove priceCents
const typeDefsV3 = typeDefsV2.replace(/\s*priceCents: Int! @deprecated\([^)]*\)/, "");
const v2 = buildSchema(typeDefsV2), v3 = buildSchema(typeDefsV3);
console.log("\nv2 -> v3 breaking:", findBreakingChanges(v2, v3).map((c) => c.description));
// Check the real operations clients have sent against v3
const known = { "ios-3.2": "{ products { name priceCents } }", web: "{ products { name price { amount currency } } }", "partner-feed": "{ products { id priceCents price { currency } } }" };
console.log("client operations against v3:");
for (const [client, q] of Object.entries(known)) {
const errs = validate(v3, parse(q));
console.log(` ${client.padEnd(13)} ${errs.length ? "BREAKS: " + errs[0].message : "ok"}`);
}
// Adding an enum value or a union member is "dangerous", not breaking
const e1 = buildSchema(`type Query { s: Status } enum Status { OPEN CLOSED }`);
const e2 = buildSchema(`type Query { s: Status } enum Status { OPEN CLOSED ARCHIVED }`);
console.log("\nadd enum value -> dangerous:", findDangerousChanges(e1, e2).map((c) => c.description));
await server.stop();
Deprecation is a signal, not a mechanism¶
introspection: [{"name":"priceCents","isDeprecated":true,"deprecationReason":"Use price { amount currency }. Removal planned after all clients migrate."}]
@deprecated changes nothing at runtime — priceCents still resolves. It tells tools: IDEs
strike the field through, code generators mark it @deprecated in TypeScript, linters can warn.
Write the reason as an instruction ("use X instead"), and since the October 2021 spec edition you
can also deprecate arguments and input fields.
Who still uses it?¶
The usagePlugin records, for each operation, every Type.field it selects — using TypeInfo
so it knows each field's parent type even inside fragments — keyed by the client's name from the
apollographql-client-name header (a convention Apollo's clients send; any header you control
works). After some simulated traffic:
usage of deprecated and replacement fields:
Product.priceCents {"ios-3.2":3,"partner-feed":1}
Product.price {"web":5,"partner-feed":1}
Money.amount {"web":5}
Now the removal decision has names attached: the web app has migrated; an old iOS build and a
partner's script still read priceCents. The partner is mid-migration — it reads both. Usage is
tracked at validation time (didResolveOperation), so it counts fields requested, which is
what matters for breakage — even a field that errored or was null was depended on.
In production this data comes from your tracing pipeline or a schema registry (Apollo GraphOS records field usage per client from its usage reporting; that wasn't run here). The principle is identical: collect per-field, per-client usage over a long enough window to include rarely used clients — monthly jobs, old app versions.
Contract: check before you remove¶
v2 -> v3 breaking: [
'Standard scalar Int was removed because it is not referenced anymore.',
'Product.priceCents was removed.'
]
findBreakingChanges flags the removal — and a surprise: priceCents was the schema's only Int
field, so the built-in Int scalar disappeared from the schema too. Built-in scalars only appear in
a schema when referenced. It doesn't matter here, but a client that declared a variable $n: Int
in some unrelated operation would now fail validation. The tool's report is worth reading in full.
Static diffing says something could break. Validating real operations says what breaks:
client operations against v3:
ios-3.2 BREAKS: Cannot query field "priceCents" on type "Product". Did you mean "price"?
web ok
partner-feed BREAKS: Cannot query field "priceCents" on type "Product". Did you mean "price"?
If you collect clients' operations (from trusted-document manifests, Level 3 · 06, or from traffic), this check turns "is removing this field safe?" into a CI job with a precise answer. Schema registries automate exactly this ("operation checks" against recent traffic).
Dangerous changes¶
Additions that are valid for every existing query can still break client code: a switch with
no default branch, an exhaustive TypeScript check, a union member the UI can't render. Report
these to client teams; don't block on them.
A practical policy¶
- Every schema change runs breaking/dangerous checks in CI against the production schema.
- Breaking changes require a recorded usage check (zero usage for N days, or named sign-off).
- Deprecations carry a reason and, where it helps, a target date in the reason text.
- Mobile apps get the longest windows — old versions live for months or years.
- When usage can't reach zero (an abandoned partner integration), make an explicit decision and communicate it; don't let a deprecated field linger forever by default.
How It Actually Works¶
TypeInfo is a stack machine: visitWithTypeInfo calls typeInfo.enter(node) before your
visitor and typeInfo.leave(node) after it. On entering a Field, it looks up the field on the
current parent type and pushes the field definition and its return type; on entering a selection
set, it pushes the named type of the current field as the new parent type; inline fragments and
fragment definitions push their type condition. So inside your Field handler,
getParentType() and getFieldDef() are correct even for fields deep inside fragments — the
same mechanism the validation rules use.
findBreakingChanges compares the two schemas' type maps: removed types (including built-in
scalars that are no longer referenced, hence the Int message), changed kinds, and for object,
interface and input types, field-by-field comparisons with the variance rules from Level 2 · 01.
findDangerousChanges runs the same walk looking for additions that existing operations can't
reject: new enum values, new union members, new implementations of interfaces, changed argument
defaults.
Common mistakes¶
- Removing a deprecated field on a schedule without checking usage.
- Measuring usage from resolver calls, which miss fields that were requested but errored or short-circuited.
- Short usage windows that miss monthly jobs and old mobile versions.
- Renaming in place (
priceCents→price) instead of expand–contract. - Not identifying clients, which turns "who uses this?" into guesswork.
Exercise¶
- Extend the usage plugin to record argument usage too (
Query.products(first:)), so you can tell whether an argument can be removed or made required. - Store usage with timestamps and write a report: "fields with zero usage in the last 30 days".
- Turn the operation check into a CLI: given
schema.graphqland a folder of.graphqloperations per client, exit non-zero and name the clients that would break. - Deprecate an argument (
products(sort: String @deprecated(reason: "..."))) and check how it appears in introspection withargs(includeDeprecated: true).