0Pricing
NestJS Enterprise Backend APIs · Lesson

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, and bool are all varint-encoded and interchangeable (watch for truncation when narrowing 64-bit to 32-bit).
  • Compatible: string and bytes are interchangeable if the bytes are valid UTF-8.
  • NOT compatible: int32fixed32, 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 transparent

A 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 .proto files 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 without reserved, 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 generate

Quick 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 reserved on both the number and the name — never bare-delete.
  • Use optional when 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

  1. Defining Services and Messages in Protobuf
  2. Implementing and Consuming gRPC Methods
  3. Streaming RPCs and Backpressure
  4. Contract Evolution and Backward Compatibility
← Back to NestJS Enterprise Backend APIs