Évolution des contrats et compatibilité ascendante
Faites évoluer les schémas proto et gérez les changements de champs sans interrompre les consommateurs existants.
Évolution des contrats et compatibilité ascendante est une leçon NestJS Enterprise Backend APIs gratuite sur CoddyKit. Ceci est la leçon 4 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage NestJS Enterprise Backend APIs, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours NestJS Enterprise Backend APIs comprend 4 leçons au total.
Certaines parties de cette leçon n'ont pas encore été traduites et s'affichent en anglais.
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.
Questions Fréquemment Posées
La leçon « Évolution des contrats et compatibilité ascendante » est-elle gratuite ?
Oui — le texte complet de « Évolution des contrats et compatibilité ascendante » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours NestJS Enterprise Backend APIs, passe à CoddyKit PRO. Le cours NestJS Enterprise Backend APIs comprend 4 leçons au total.
Qu'est-ce que j'apprendrai dans « Évolution des contrats et compatibilité ascendante » ?
Faites évoluer les schémas proto et gérez les changements de champs sans interrompre les consommateurs existants. Tu pratiques NestJS Enterprise Backend APIs avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.
Dois-je avoir de l'expérience pour commencer NestJS Enterprise Backend APIs ?
Aucune expérience préalable n'est requise. NestJS Enterprise Backend APIs sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 4 sur 4.
Combien de temps prend la leçon « Évolution des contrats et compatibilité ascendante » ?
La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.
Peux-tu écrire et exécuter du code dans cette leçon NestJS Enterprise Backend APIs ?
Oui. Chaque leçon NestJS Enterprise Backend APIs inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.
Toutes les leçons de ce cours
- Définition des services et des messages en Protobuf
- Implémentation et consommation de méthodes gRPC
- RPC en flux continu et contrôle de la contre-pression
- Évolution des contrats et compatibilité ascendante