Contract Evolution and Backward Compatibility
Version proto schemas and manage field changes without breaking existing consumers.
Contract Evolution and Backward Compatibility is a free NestJS Enterprise Backend APIs lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the NestJS Enterprise Backend APIs learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Contract Evolution Matters
In a microservice mesh, a gRPC service contract is a shared API used by many independent consumers that deploy on their own schedule. You can almost never coordinate a simultaneous upgrade of every client and server.
The goal of contract evolution is to change a .proto schema so that:
- Old clients keep working against a new server (backward compatibility).
- New clients keep working against an old server (forward compatibility).
Protobuf was designed precisely for this. The wire format encodes fields by tag number, not by name, and unknown fields are tolerated rather than rejected. Master a few rules and you can evolve contracts for years without a breaking flag day.
The Golden Rule: Never Reuse Field Numbers
On the wire, every field is identified by its tag number plus a wire type. The field name is irrelevant once compiled. This single fact drives almost every evolution rule.
- You may freely rename a field — the number is what travels.
- You may add a new field with a fresh, never-before-used number.
- You must never reuse a number that was previously assigned to a different field.
Reusing a number means an old client that still sends data under that tag will be silently misinterpreted by the new schema as a completely different field — corrupting data with no error.
// user.proto (v1)
syntax = "proto3";
package user.v1;
message User {
string id = 1;
string email = 2;
string display_name = 3;
}
// Tag numbers 1,2,3 are now a permanent part of the contract.
// They may never be re-assigned to a different field.Adding Fields Is Safe
The most common evolution is adding a field. Because proto3 has no required fields and every scalar has a default zero value, a new field is automatically safe in both directions:
- An old client talking to a new server simply does not send the field — the server reads its default (empty string, 0, false).
- A new client talking to an old server sends the field — the old server does not recognize the tag and stores it as an unknown field, preserving it on round-trips.
Always pick the next free tag number and document it. Treat display_name readers as if the field might be absent.
// user.proto (v2) — added phone_number, backward compatible
syntax = "proto3";
package user.v1;
message User {
string id = 1;
string email = 2;
string display_name = 3;
string phone_number = 4; // NEW: old clients omit it, default ""
}Removing Fields: Reserve, Don't Delete
To remove a field, do not just delete its line. If a future developer adds a new field and accidentally picks the old number, you get silent corruption. Protobuf gives you the reserved keyword to permanently fence off retired tag numbers and names.
reserved 3;blocks the number from ever being reused.reserved "display_name";blocks the name so no new field can claim it.
The compiler will reject any future message that tries to reuse a reserved tag or name — turning a silent runtime bug into a build-time error.
// user.proto (v3) — display_name retired safely
syntax = "proto3";
package user.v1;
message User {
string id = 1;
string email = 2;
reserved 3; // tag 3 can never be reused
reserved "display_name"; // name can never be reused
string phone_number = 4;
string full_name = 5; // replacement lives at a NEW number
}proto3 Defaults and the Presence Problem
In proto3, scalar fields have no built-in concept of presence. A field equal to its default (0, "", false) is indistinguishable from a field that was never set. This matters during evolution: you cannot tell "client deliberately sent 0" from "old client that does not know this field."
When you genuinely need to distinguish absent from zero — for example a nullable balance or an optional toggle — use the optional keyword (restored in proto3.15) or a wrapper type. optional generates explicit has* presence semantics on the wire.
// Explicit presence so 'absent' != 'false'
syntax = "proto3";
package account.v1;
message AccountSettings {
string account_id = 1;
optional bool marketing_opt_in = 2; // hasMarketingOptIn() now exists
optional int64 credit_limit = 3; // distinguish unset from 0
}Defensive Reads in the NestJS Service
Backward compatibility is not only a schema concern — your NestJS gRPC handlers must read defensively. Never assume a newly added field is populated by every caller. Use presence checks and explicit fallbacks rather than trusting non-empty values.
Below, the handler tolerates clients compiled against any schema version: old clients omit fullName, so we fall back; marketingOptIn uses presence semantics from the generated type.
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
interface UpdateUserRequest {
id: string;
fullName?: string;
marketingOptIn?: boolean; // optional => may be undefined
}
@Controller()
export class UserController {
@GrpcMethod('UserService', 'UpdateUser')
updateUser(req: UpdateUserRequest) {
// Old clients won't send fullName at all
const name = req.fullName?.trim() || 'Anonymous';
// Presence check: undefined means 'unset', not 'false'
const optIn = req.marketingOptIn ?? false;
return { id: req.id, fullName: name, marketingOptIn: optIn };
}
}Wire-Compatible Type Changes
Some scalar type changes are safe because they share the same wire encoding; others corrupt data. Knowing which is which lets you evolve a field's type without bumping its number.
- Compatible:
int32,uint32,int64,uint64, andboolare all varint-encoded and interchangeable (watch for truncation when narrowing 64-bit to 32-bit). - Compatible:
stringandbytesare interchangeable if the bytes are valid UTF-8. - NOT compatible:
int32↔fixed32, or any varint ↔ fixed-width change — different wire types, silent garbage.
When a change is not wire-compatible, the correct move is to add a new field at a new number and deprecate the old one.
// Safe: widening int32 -> int64 (both varint)
// BEFORE
message Order { int32 quantity = 1; }
// AFTER — old & new clients still interoperate
message Order { int64 quantity = 1; }
// UNSAFE: int32 -> fixed32 (varint vs 4-byte fixed)
// Don't do this; add a new field instead:
message Order { int64 quantity = 1; sfixed32 legacy_qty = 2; }Enums: Always Keep a Zero Default
Enums evolve too. The cardinal rules:
- The zero value must be an UNSPECIFIED / default member. Unknown enum values from newer clients are decoded by older proto3 code as their numeric value but conceptually fall back to handling-as-unknown.
- Add new members with new numbers; never renumber existing ones.
- Reserve removed enum numbers and names, exactly like message fields.
Because a new server may return an enum constant an old client has never heard of, clients must treat unrecognized enum values gracefully — typically by mapping them to the UNSPECIFIED branch rather than crashing.
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0; // mandatory safe default
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_SHIPPED = 2;
ORDER_STATUS_DELIVERED = 3;
// v2 adds a value old clients won't recognize:
ORDER_STATUS_RETURNED = 4;
reserved 5; // a removed status, fenced off
reserved "ORDER_STATUS_LEGACY";
}Evolving RPCs and Versioned Packages
You can freely add new RPC methods to a service — old clients ignore methods they do not call. But you must never change a method's request or response message type or its name; that breaks the generated stubs.
For breaking changes that cannot be done additively, version the package, not the field. Run user.v1 and user.v2 side by side so consumers migrate on their own timeline, then retire v1 once telemetry shows it is unused.
- Additive change → same package, new field/method.
- Breaking change → new versioned package, dual-run, deprecate.
// Additive: new method, old clients unaffected
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
rpc SearchUsers(SearchUsersRequest) returns (stream User); // NEW
}
// Breaking reshape? Don't mutate v1 — introduce a new package:
// package user.v2; (deployed alongside user.v1)Demonstrating Tag-Based Decoding
To build intuition for why tag numbers (not names) define the contract, here is a tiny standalone simulation of protobuf-style decoding. The encoder stores values keyed by tag number; a renamed schema reading the same tags still resolves correctly, while a reused tag silently mismatches.
This is plain TypeScript modelling the principle — no gRPC runtime required.
// Wire = list of (tag, value). Names live only in the schema.
type Wire = Array<{ tag: number; value: string }>;
const encodeV1 = (email: string, displayName: string): Wire => [
{ tag: 2, value: email },
{ tag: 3, value: displayName },
];
// v2 schema RENAMED tag 3 to full_name (safe: same number)
const v2Schema: Record<number, string> = { 2: 'email', 3: 'full_name' };
function decode(wire: Wire, schema: Record<number, string>) {
const out: Record<string, string> = {};
for (const { tag, value } of wire) {
const name = schema[tag] ?? `unknown_${tag}`;
out[name] = value; // unknown tags preserved, not dropped
}
return out;
}
const wire = encodeV1('a@x.com', 'Ada Lovelace');
console.log(decode(wire, v2Schema));
// { email: 'a@x.com', full_name: 'Ada Lovelace' } — rename is transparentA Workflow for Safe Evolution
Enterprise teams encode these rules into automated guardrails so a human never has to remember them all. A practical checklist:
- Store
.protofiles in a central schema registry or shared repo as the single source of truth. - Run a breaking-change linter (e.g.
buf breaking) in CI against the previous published schema — it rejects reused numbers, removed fields withoutreserved, and incompatible type changes. - Generate TypeScript stubs from the registry, never by hand.
- Roll out server first, then clients — a backward-compatible server can serve old and new clients during the transition.
With these in place, additive evolution becomes routine and breaking changes become a deliberate, versioned event.
# CI gate: fail the build on any breaking proto change
buf lint
buf breaking --against '.git#branch=main'
# then regenerate typed stubs for NestJS
buf generateQuick Check: Removing a Field
A teammate needs to remove the display_name field (tag 3) from a widely consumed User message, while many old clients still send it. What is the correct, safe approach?
Recap: Evolving Contracts Without Breaking Consumers
You now have the core toolkit for backward- and forward-compatible gRPC contracts:
- Tag numbers are the contract — rename freely, never reuse a number.
- Add fields at fresh numbers; proto3 defaults make them safe in both directions.
- Remove via
reservedon both the number and the name — never bare-delete. - Use
optionalwhen you must distinguish unset from default. - Read defensively in NestJS handlers: presence checks and fallbacks, never assume a field is populated.
- Only some type changes are wire-compatible (varint family, string↔bytes); otherwise add a new field.
- Keep a zero UNSPECIFIED enum and reserve removed members.
- Add RPCs freely; for truly breaking changes, version the package and dual-run.
- Enforce it all with a schema registry + breaking-change linter in CI.
Follow these and your services can evolve continuously, with consumers upgrading on their own schedule.
Frequently asked questions
Is the “Contract Evolution and Backward Compatibility” lesson free?
Yes — the full text of “Contract Evolution and Backward Compatibility” is free to read here on the web, and the NestJS Enterprise Backend APIs course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the NestJS Enterprise Backend APIs course, upgrade to CoddyKit PRO.
What will I learn in “Contract Evolution and Backward Compatibility”?
Version proto schemas and manage field changes without breaking existing consumers. You practise NestJS Enterprise Backend APIs with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start NestJS Enterprise Backend APIs?
No prior experience is required. NestJS Enterprise Backend APIs on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Contract Evolution and Backward Compatibility” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this NestJS Enterprise Backend APIs lesson?
Yes. Every NestJS Enterprise Backend APIs lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Defining Services and Messages in Protobuf
- Implementing and Consuming gRPC Methods
- Streaming RPCs and Backpressure
- Contract Evolution and Backward Compatibility