Versioning & Evolution
How to change an API without breaking everyone — the four ways to version REST, what counts as a breaking change in protobuf, GraphQL's evolve-don't-version stance, and the principles common to all three.
The question every team fights about: how do we change this without breaking everyone? The answer differs sharply by protocol — but the principles underneath rhyme, so we end with those.
Four ways to version a REST API
Ordered coarse to fine:
- URI path (
/v2/orders) — visible and trivial to route, but couples the version to the URL and arguably breaks REST’s idea that a URL identifies a resource, not a representation. Common precisely because it’s obvious. - Query parameter (
?version=2) — easy to default, but muddies caches (the same URL now returns different shapes). - Custom header (
X-API-Version: 2) — keeps URLs clean, but is invisible and easy to forget. - Media type (
Accept: application/vnd.acme.order.v2+json) — keeps the URL stable and versions the representation, which is the most RESTful choice.
# Media-type versioning: the URL stays the same; the version lives in Accept
GET /orders/A-1001
Accept: application/vnd.acme.order.v2+json
HTTP/1.1 200 OK
Content-Type: application/vnd.acme.order.v2+json
Which REST scheme to start with — and what it costs HATEOAS and security
For a new public or partner API, start with URI-path versioning (/v1/orders).
It is the least elegant of the four but the most operable: trivially routable,
visible in logs and dashboards, cacheable without surprises, and impossible for a
client to get wrong. Reach for media-type versioning when you specifically need
the URL to stay stable — which, as it happens, is exactly what HATEOAS needs.
Impact on HATEOAS. Hypermedia only works if a client can follow the links the server hands it without guessing a version, so the scheme decides how the version travels with those links:
- Media-type keeps every link clean (
/orders/A-1001/items) and lets the version ride inAccepton each follow — the representation is versioned, the resource identity is not. This is the HATEOAS-friendly choice. - URI-path works too, but every emitted link must already carry the version
(
/v2/orders/A-1001/items); a client that follows links stays pinned to one version, which is fine until you want it to move. - Query-param and custom-header versioning quietly break link-following: the version lives outside the URL, so a naively followed link drops it.
Impact on security. Each live version is attack surface you must keep patched —
the real cost of versioning is sprawl, not the scheme. Two scheme-specific notes:
URI and query versions sit in URLs, so they surface in proxy logs, browser history,
and Referer headers (more fingerprintable, but also routable by a WAF), while header
and media-type versions hide there instead. And never let an older version skip a
check the newer one added — an auth scope, an input bound, a rate limit — or /v1
becomes the soft way in. Deprecate and retire on the lifecycle every protocol shares
(Figure B.4): a version you forgot to turn off is a version you forgot to patch.
gRPC: field numbers are the contract
protobuf’s rules are precise because the wire format is positional — the field number is the contract, not the name or the order. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters), mark fields deprecated, and reserve removed tags. Breaking: changing a field’s type, moving a field to a different number, or reusing a retired tag.
syntax = "proto3";
message Order {
string id = 1;
string sku = 2;
int32 qty = 3;
reserved 4; // tag 4 once held 'notes' — never reuse it
reserved "notes";
string currency = 5; // SAFE: brand-new field, new tag number
PaymentState state = 6; // SAFE: new field; old clients ignore it
}
enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // tag 0 must exist
// BREAKING (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4
The reserved keyword is the whole discipline in one line: it tombstones a retired
tag so no future change can accidentally reuse it and silently corrupt old readers.
GraphQL evolves, it doesn’t version
GraphQL takes a different stance entirely: there is no /v2. One schema evolves
continuously. Add new fields and types (additive is always safe — existing queries
don’t ask for the new field, so they’re unaffected), mark superseded fields
@deprecated with a reason, watch usage drop, then retire.
// spring-boot-starter-graphql — SDL schema; deprecated shows in introspection
// schema.graphqls:
// type Order {
// id: ID!
// total: Float @deprecated(reason: "Use totalMinor (integer cents) instead.")
// totalMinor: Int!
// }
@Controller
public class OrderController {
@SchemaMapping(typeName = "Order", field = "totalMinor")
public int totalMinor(Order order) {
return (int) (order.getTotal() * 100); // the replacement, added additively
}
}
// SmallRye GraphQL — @Deprecated maps to the SDL directive
@GraphQLApi
public class OrderApi {
@Query
public Order order(String id) { return orders.get(id); }
}
@Type
public class Order {
public String id;
@Deprecated // still served; shows in introspection
public double total;
@Name("totalMinor")
public int getTotalMinor() { return (int) (total * 100); } // added additively
}
// HotChocolate — [GraphQLDeprecated] maps to the SDL @deprecated directive
public class Order
{
public string Id { get; init; }
[GraphQLDeprecated("Use totalMinor (integer cents) instead.")]
public double Total { get; init; } // still served; shows in introspection
public int TotalMinor => (int)(Total * 100); // the replacement, added additively
}
import strawberry
@strawberry.type
class Order:
id: str
total: float = strawberry.field(
deprecation_reason="Use totalMinor (integer cents) instead.",
) # still served; shows in introspection
total_minor: int # the replacement, added additively
// cppgraphqlgen — the SDL directive drives introspection; code implements both
// schema.graphql:
// type Order {
// id: ID!
// total: Float @deprecated(reason: "Use totalMinor (integer cents) instead.")
// totalMinor: Int!
// }
class OrderResolver : public object::Order {
public:
service::AwaitableScalar<double> getTotal() const { co_return total_; } // deprecated
service::AwaitableScalar<int> getTotalMinor() const { // replacement
co_return static_cast<int>(total_ * 100);
}
};
// gqlgen — the SDL @deprecated directive shows in introspection automatically
// schema.graphqls:
// type Order {
// id: ID!
// total: Float @deprecated(reason: "Use totalMinor (integer cents) instead.")
// totalMinor: Int!
// }
func (r *orderResolver) TotalMinor(ctx context.Context, obj *Order) (int, error) {
return int(obj.Total * 100), nil // the replacement, added additively
}
Clients see the deprecation in introspection and in tooling (the Playground), which is what lets you measure who’s still on the old field before removing it.
Versioning a Kafka API
An event stream is an API too — its schema is the contract, exactly like a
.proto or an OpenAPI document — and it evolves under the same pressure. The first
thing to separate is the two roles a Kafka message plays, which are easy to conflate:
- The key is identity: it picks the partition and so fixes ordering for a given
entity. It is not a version slot — never encode
v2into the key, or you scatter one entity’s events across partitions and lose ordering. - The version is schema: it lives in the schema registry (Apicurio, from the
API-management chapter) as a numbered version with a compatibility rule, and
travels on the wire as either the registry’s schema id or an explicit
schema.versionheader.
The default that keeps consumers working is one topic with a BACKWARD-compatible, evolving schema: add fields, never renumber or repurpose them, and old consumers ignore what they don’t know while new consumers read the additions. Only a genuine breaking change earns a new major — and then you run two topics (or two schema majors) side by side, dual-write through the migration, and retire the old once consumers have moved.
On the wire, the cleanest portable signal is a schema.version header the producer
sets alongside the key; a consumer reads it and upcasts an old payload into the
current shape before handling it. The producer side looks like this — the key stays
identity, the version is metadata:
// key is identity (partition + ordering); version is metadata, not the key
ProducerRecord<String, Order> rec =
new ProducerRecord<>("order.placed", order.id(), order); // key = order id
rec.headers().add("schema.version", "2".getBytes()); // version header
kafka.send(rec); // schema-aware serde
@Inject @Channel("order.placed") Emitter<Order> emitter; // Reactive Messaging
void publish(Order order) {
emitter.send(KafkaRecord.of(order.id(), order) // key = order id
.withHeader("schema.version", "2".getBytes())); // version header
}
// Confluent.Kafka — key is the message identity; version travels as a header
var headers = new Headers { { "schema.version", "2"u8.ToArray() } };
await producer.ProduceAsync("order.placed",
new Message<string, string>
{
Key = order.Id, // key = identity
Value = JsonSerializer.Serialize(order),
Headers = headers, // version as metadata
});
await producer.send_and_wait(
"order.placed",
key=order.id.encode(), # key = identity / partitioning
value=serialize(order), # schema-aware serde
headers=[("schema.version", b"2")]) # version as metadata
// librdkafka C++ API — key is identity, version is a header
RdKafka::Headers* headers = RdKafka::Headers::create();
headers->add("schema.version", "2"); // version as metadata
auto value = serialize(order);
producer->produce("order.placed", RdKafka::Topic::PARTITION_UA, RdKafka::Producer::RK_MSG_COPY,
value.data(), value.size(), // value
order.id.data(), order.id.size(), // key = identity
0, headers, nullptr); // headers are owned by librdkafka on success
// franz-go — key is identity; append the version as a header
rec := &kgo.Record{Topic: "order.placed", Key: []byte(order.ID), Value: serialize(order)}
rec.Headers = append(rec.Headers,
kgo.RecordHeader{Key: "schema.version", Value: []byte("2")}) // version header
cl.Produce(ctx, rec, nil)
A consumer completes the picture: read the schema.version header, and for an older
version run a small upcaster (v1 → v2: default the new fields) before dispatching,
so the rest of the handler only ever sees the current shape. That is the same
tolerant-reader discipline the cross-cutting principles call for — strict in what you emit,
liberal in what you accept.
Principles that cut across them all
The protocol-specific rules are all faces of four ideas:
- Additive change is safe; removal and mutation break people — so design to add.
- Make compatibility mechanical — the registry’s
BACKWARD/FORWARD/FULLrules checked in CI, never a reviewer remembering. - Never break silently — deprecate with a reason, a replacement, and a sunset date, and measure who’s still on the old version.
- Run old and new side by side until consumers have migrated, then retire.
Cross-check it yourself
Prove the additive/breaking line with the registry from API Registry. Add an
optional field to a schema and confirm the BACKWARD check passes; change a field’s
type or reuse a protobuf tag and confirm it fails with 400. The mechanical check
catching the breaking change — instead of a reviewer’s memory — is the principle
made real.
Verification status: unverified — examples transcribed from the source decks; the
REST, gRPC, and GraphQL compatibility behaviour and the Kafka schema.version header
are exercised against the registry in the examples/09-api-registry/ runner.