Why /v2 Never Ships
Why /v2 Never Ships
Versioned REST has a familiar ritual: freeze v1, build v2, run both, nag clients to migrate, and eventually discover that a warehouse integration still calls v1 four years later. The URL version is cheap to create and expensive to retire — every day it lives, someone must backport fixes and document two of everything.
GraphQL's alternative is a single endpoint whose schema evolves in place. The mechanism is field-level granularity: a client depends only on the fields and arguments it selects, so the surface you must preserve for that client is the ~15 fields it uses, not the whole API. Adding anything is free, and removal follows the runway described in the schema evolution rules topic — deprecate, measure, gate, delete.
The fork you avoid is concrete. A second version means:
- Two schemas to compose, two sets of resolvers to maintain, two documentation sets — and every new feature either lands twice or waits for a backport.
- Two codegen outputs per client, with developers copying types across the boundary because the "wrong" version's types leak into a shared package.
- Gateway routing and caching rules keyed by path, plus support burden split across both.
- No natural end date: without telemetry proving zero traffic, the old endpoint becomes permanent infrastructure.
One clarification that interviews reward: nothing in the GraphQL specification forbids /v2/graphql. Versionlessness is an operational strategy that teams choose because they have the machinery to make it safe — not a property the protocol confers. A team without usage data and CI checks is not running a versionless API; they are running an unversioned one, which is a different and worse thing.
The Machinery Behind Safe Continuous Change
The Machinery Behind Safe Continuous Change
Continuous evolution only works while five components stay in place. Take one away and the schema stops being safe to touch.
1. Ownership. Schema files under CODEOWNERS, with a design review for anything that touches public types. A versionless API has no release train to hide behind, so the review that used to happen at "v2 design time" moves into every schema pull request. Ownership also answers the question nobody asks until it hurts: who decides that a deprecated field is ready to die.
2. Registered operations. Clients submit their operation documents — persisted queries, checked-in operation artifacts, or generated operation manifests from codegen builds. Registration is what turns "someone might use this field" into "three operations in two repos use it." Unregistered traffic (see the blind spot below) is handled by sampling raw query text at the gateway.
3. Field usage telemetry. Per-field, per-client request counts and recency:
| Metric | Answers |
|---|---|
requestedBy (count of operations) |
How many callers would break if removed |
lastRequestedAt |
Whether anyone still cares |
requestsPerDay |
Blast radius if it does break |
clients[] |
Who to contact before removal |
4. A written deprecation policy. How long a field lives between @deprecated and deletion, where the announcement lands, who signs off. Without a written window, deprecations accumulate: schemas rot into museums of fields with zero traffic because deleting anything felt like a decision requiring a meeting.
5. Enforcement in CI. The rule that blocks merges that remove fields still present in registered operations. Everything above is measurement; this is the point where measurement has consequences. The build mechanics are in schema checks in CI.
The blind spot to design around: telemetry sees only what you instrument. Third-party integrations sending ad hoc queries, cached scripts, and anything that bypasses your operation registry are invisible. Mitigations: sample raw query documents at the gateway, log field paths server-side for a percentage of requests, and treat "zero usage" as "zero observed usage" — which is why removal notices go out even when the dashboard reads zero.
When a Second Endpoint Is the Honest Answer
When a Second Endpoint Is the Honest Answer
Evolution has a limit: you can only add your way out of a problem if the old meaning can keep existing. Some changes cannot be expressed additively, and pretending otherwise produces a schema that lies.
Justify a new endpoint when:
- Semantics must change on an existing field.
orders(limit: Int)becomes cursor-paginated with different semantics: both behaviors cannot live under one name honestly — addingordersAfter(cursor)works only if the old shape can eventually be deleted. - The auth or tenancy model changes. Different scoping rules per request are a different API, and clients written against the old assumptions would silently over-fetch or under-fetch.
- Cost/billing resets. Metering new economics onto old field names makes invoices impossible to explain.
- The unmeasurable population is large. If telemetry cannot see a significant share of traffic (public third-party API with anonymous callers), the "measure to zero" runway never completes, and a versioned surface with its own contract is safer than a silent break.
- A protocol-level feature changes request handling. Batch semantics, file uploads, or a new transport that old gateways cannot parse.
Running both during migration:
- Keep
/graphql(v1) frozen: additive-only, no new fields, explicit sunset date. - Ship
/v2/graphqlwith the reworked schema; register operations separately so usage is measured per version. - Migrate client teams by app version, watching v1 traffic as the burn-down chart.
- Kill v1 when traffic stays at zero for a defined window — the same telemetry discipline, applied to a whole endpoint instead of one field.
The tradeoff is honest and one-directional: a fork doubles schema surface, docs, codegen, and support, so it buys time rather than avoiding work. Adopt it when coordination with unmeasurable clients outweighs the doubling cost — and put the sunset date in the first announcement, or the fork becomes permanent.
Interview framing: answer in layers. First: GraphQL prefers field-level evolution with deprecation and telemetry, because clients only depend on what they select. Second: enforcement comes from schema checks in CI. Third: a new endpoint remains legitimate for semantic resets and unmeasurable client populations — and you would still run it with a published sunset date and per-version usage data.
Practice Problems
Analytics.onboardedAt (deprecated 6 weeks ago, reason points to analytics.firstSeenAt) shows the usage below. Decide whether removal may merge now, what must happen first, and write the gate that enforces the decision in CI.
Example:
Input: lastRequestedAt: 2 days ago · requestingOperations: 3 (2 in mobile-app@4.x, 1 in nightly-report.py) · requestsPerDay: ~40
Output: Do not remove: contact owners of the two operations, migrate to firstSeenAt, re-measure; nightly-report.py is unregistered-looking traffic to chase directly
Recent activity in three documents means the check would fail — removal now converts a deprecated field into production validation errors for real callers.
Optimal Solution — O(1) time, O(1) space
Treat removal as a predicate over two usage sources rather than a calendar: any requesting operation or any recent raw-text mention blocks the merge; silence across both for a defined window allows it, provided the announcement already went out.
// gate.ts — runs inside the schema-check job
type Usage = {
lastRequestedAt: string | null; // from operation registry
requestingOperations: number;
rawSampleMentions30d: number; // gateway query-text sampling
announcedAt: string | null; // changelog / client notice
deprecatedSince: string;
};
function mayRemove(u: Usage): { ok: boolean; reason: string } {
if (!u.announcedAt) return { ok: false, reason: 'no removal notice published' };
if (u.requestingOperations > 0) return { ok: false, reason: 'registered operations still select it' };
if (u.rawSampleMentions30d > 0) return { ok: false, reason: 'unregistered traffic still mentions it' };
const quietDays = daysSince(u.lastRequestedAt ?? u.deprecatedSince);
if (quietDays < 90) return { ok: false, reason: 'usage window not silent: ' + quietDays + 'd' };
return { ok: true, reason: 'zero observed usage for ' + quietDays + 'd across both instruments' };
} Edge Cases:
- Never observed at all (no telemetry baseline): fall back to the deprecation window plus a public notice
- Registry says zero but raw sampling shows hits (third-party scripts)
- Announcement published after deprecation rather than before (order is not proof — require both)
Quiz
1. What makes cleanup of deprecated fields possible in a versionless API?
2. A schema has 400 fields; a client selects 15. Why does that matter for versioning?
3. When is a second GraphQL endpoint the defensible choice?
4. What population does field usage telemetry fail to see?
Flashcards
Question
Schema-as-version
Click to reveal answer
Answer
One endpoint whose schema evolves in place; clients depend on the fields they select, not on a URL generation.
Question
Five components of safe evolution
Click to reveal answer
Answer
Ownership, registered operations, field usage telemetry, written deprecation policy, CI enforcement.
Question
Telemetry blind spot
Click to reveal answer
Answer
Unregistered ad hoc queries are invisible — sample raw query text at the gateway and treat zero as zero-observed.
Question
Justify a second endpoint
Click to reveal answer
Answer
Semantics of an existing field must change, or an unmeasurable client population prevents a deprecation runway from completing.
Question
Why a fork is expensive
Click to reveal answer
Answer
Two schemas, resolvers, docs, codegen outputs, gateway routes — and no natural end date without sunset telemetry.
Question
Versionless does not mean unversioned
Click to reveal answer
Answer
Without telemetry and CI gates, schema changes are uncoordinated risk, not a strategy.
Revision Notes
Key Takeaways
- 1. Clients depend on selections, which is why additive evolution replaces URL versioning
- 2. Versionless requires machinery: ownership, registration, telemetry, policy, enforcement
- 3. Telemetry cannot see unregistered callers; pair dashboards with raw query sampling
- 4. A second endpoint is for semantic resets and unmeasurable populations — always with a sunset date
Interview Tips
- • Answer "how do you version GraphQL API?" in layers: field evolution, deprecation runway, CI checks, endpoint fork as last resort
- • Name the concrete artifacts (CODEOWNERS, operation registry, usage dashboard, check failure) rather than the abstract principle
- • Be ready to defend when you would *not* fork — interviewers probe whether the strategy has any discipline behind it
Cheat Sheet
Versionless API Cheat Sheet
Model: one endpoint, schema evolves in place; clients depend only on selected fields.
Required machinery
- CODEOWNERS + schema design review on every PR
- Registered operations (persisted queries / codegen manifests)
- Field usage telemetry: requestedBy, lastRequestedAt, clients[]
- Written deprecation window + announcement channel
- CI check blocking removal of fields with active operations
Blind spot: unregistered ad hoc callers — sample raw query text; call it "zero observed usage".
Fork only when: existing field semantics must change, or clients cannot be measured/coordinate.
Fork rules: freeze old version (additive only), register ops per version, sunset date announced up front, kill when traffic = 0 for a defined window.
Enforcement details: schema checks in CI · Removal mechanics: schema evolution rules