契约演进与向后兼容
为 proto 模式进行版本管理,并处理字段变更而不破坏现有使用方。
契约演进与向后兼容 是 CoddyKit 上的免费 NestJS Enterprise Backend APIs 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 NestJS Enterprise Backend APIs 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 NestJS Enterprise Backend APIs 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
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.
常见问题解答
「契约演进与向后兼容」课时是免费的吗?
是的 — 「契约演进与向后兼容」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 NestJS Enterprise Backend APIs 课程的其余内容,请升级到 CoddyKit PRO。 NestJS Enterprise Backend APIs 课程共包含 4 节课。
「契约演进与向后兼容」这节课中我会学到什么?
为 proto 模式进行版本管理,并处理字段变更而不破坏现有使用方。 你通过在浏览器中直接运行的动手代码来练习 NestJS Enterprise Backend APIs,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 NestJS Enterprise Backend APIs 需要有经验吗?
无需任何先前经验。CoddyKit 上的 NestJS Enterprise Backend APIs 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「契约演进与向后兼容」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 NestJS Enterprise Backend APIs 课中编写并运行代码吗?
能。每节 NestJS Enterprise Backend APIs 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。