Mert Özen Further With Every Line
Backend gRPC and GraphQL — #01

Designing Contracts with Protocol Buffers: .proto Files, Field Numbers and Backward Compatibility

Mert Özen Nov 14, 2026 16 min 4 views
Designing Contracts with Protocol Buffers: .proto Files, Field Numbers and Backward Compatibility

In gRPC the contract travels by field numbers, not field names, so adding a field is safe while changing a number or a type silently corrupts data. We write Bookstore Inventory's inventory.proto and show the enum zero rule, presence with optional, a DecimalValue for money, reserved, and C# generation with Grpc.Tools, all with real output.

Version note: The code in this post was built and run with .NET 10, Grpc.Tools 2.85 (bundling protoc 35.1) and Google.Protobuf 3.36.

Guess First

The Inventory service has shipped a new version and added a new field, warehouse_code, to its stock message. Ordering is still compiled against the old .proto file. Ordering receives this message and, without touching anything, sends it back to Inventory in another call. Does the new field get lost on the way?

Second question: a teammate changes int32 quantity = 2; to sint32 quantity = 2; "so negatives encode more efficiently". An old client sends 5. What does the new server read? An error, 5, or some other number?

Third: you rename a field from available to available_quantity and keep its number. Who does this change break?

All the answers are in this post, with output from real runs.

Why gRPC: Contract-First, Binary and HTTP/2

In the ASP.NET Core Web API series we wrote REST (Representational State Transfer) APIs: the URL points to a resource, the HTTP verb says what to do, and the body carries JSON (JavaScript Object Notation). The contract usually comes after the code; we generate the OpenAPI document from the controllers. gRPC (officially gRPC Remote Procedure Calls) flips the order. You first define the service's methods and messages in a .proto file, and both server and client code are generated from that file. The contract is not a by-product of the code; it is the source.

The second difference is the wire format. Messages are serialized in binary with Protocol Buffers (protobuf for short): field numbers travel, not field names. The third is transport: gRPC runs on HTTP/2, carries many calls concurrently over a single TCP connection, and supports streaming natively. We covered HTTP/2's framing and multiplexing in the HTTP/2 post of the Networking and How HTTP Works series; here we just use the result.

This post is about the first two points: the contract and how it changes over time. The question of when to pick REST, gRPC or GraphQL is closed at the end of the series by measuring the same Bookstore scenario in all three styles. For now, the one thing to keep in mind is this: gRPC's strength and its risk both come from the contract. Evolve the contract the wrong way and data can be corrupted without the compiler saying a word.

The First Contract: inventory.proto

In Bookstore, the order flow makes three calls from Ordering to Inventory: query stock, reserve it, release the reservation. The whole contract:

syntax = "proto3";

package bookstore.inventory.v1;

option csharp_namespace = "Bookstore.Inventory.V1";

import "google/protobuf/timestamp.proto";
import "bookstore/common/v1/money.proto";

service InventoryService {
  rpc GetStock (GetStockRequest) returns (GetStockResponse);
  rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse);
  rpc ReleaseStock (ReleaseStockRequest) returns (ReleaseStockResponse);
}

enum StockStatus {
  STOCK_STATUS_UNSPECIFIED = 0;
  STOCK_STATUS_IN_STOCK = 1;
  STOCK_STATUS_LOW = 2;
  STOCK_STATUS_OUT_OF_STOCK = 3;
}

message GetStockRequest {
  string book_id = 1;
}

message GetStockResponse {
  string book_id = 1;
  int32 available = 2;
  int32 reserved_quantity = 3;
  StockStatus status = 4;
  bookstore.common.v1.Money unit_price = 5;
  google.protobuf.Timestamp updated_at = 6;
  optional int32 reorder_level = 7;
  map<string, int32> warehouse_quantities = 8;
}

message ReservationLine {
  string book_id = 1;
  int32 quantity = 2;
}

message ReserveStockRequest {
  string order_id = 1;
  repeated ReservationLine lines = 2;
}

message ReserveStockResponse {
  string reservation_id = 1;
  google.protobuf.Timestamp expires_at = 2;
}

message ReleaseStockRequest {
  oneof target {
    string reservation_id = 1;
    string order_id = 2;
  }
}

message ReleaseStockResponse {
  int32 released_lines = 1;
}

A few decisions here are deliberate. package bookstore.inventory.v1 is the namespace on the protobuf side; it prevents two messages with the same name from colliding, and the trailing v1 keeps the door open to publishing a v2 side by side if a breaking version is ever needed. csharp_namespace sets the namespace of the generated C# code. Field names are snake_case as the protobuf style guide recommends; the generated C# properties still come out in PascalCase as BookId and ReservedQuantity.

Every RPC has its own request and response message; even ReleaseStock gets a ReleaseStockResponse, though it has a single field. The reason is evolution: when you want to add "how many books were released" to this response tomorrow, you won't have to touch a shared message.

The numbers on the right are field numbers. What identifies a field on the wire is this number, not its name; every compatibility rule we'll see shortly follows from it. The valid range is 1 to 536,870,911, and 19000–19999 is reserved for protobuf's own use. Numbers 1–15 are written with a one-byte tag on the wire and 16–2047 with two bytes, so it makes sense to give the small numbers to the fields you fill most often.

Enums, Presence and Default Values

In proto3, the first value of an enum must be zero. If you forget and start the enum with a meaningful value, the build stops with this error:

Protos/stock.proto(6,27): error : The first enum value must be zero for open enums.

The rule isn't arbitrary. In proto3 every field has a default value, and an enum's default is its first value, i.e. zero. Since zero means "not sent", it shouldn't carry business meaning; that's why the protobuf guide recommends a name prefixed with the enum's name, like STOCK_STATUS_UNSPECIFIED. Had you named zero STOCK_STATUS_IN_STOCK, a response that was never filled in would read as "in stock".

The phrase "open enums" in the error matters too. proto3 enums are open: an unrecognised number is not rejected, it is kept as is. Since a C# enum is just a number underneath, the value 4 added by a newer version looks like this on an old client:

status: 4, defined: False
{ "bookId": "b-42", "status": 4 }

So every switch in client code needs a default branch for values it doesn't know.

Default values come with a cost: presence. In proto3, for a plain scalar field, "zero was sent" and "nothing was sent" are the same thing on the wire: zero values aren't serialized. Sometimes that difference matters. A reorder_level of zero means "don't warn until stock runs out", while its absence means "nothing configured for this book". The optional keyword exists for exactly this; it adds HasReorderLevel and ClearReorderLevel() to the generated class:

var empty = new GetStockResponse();
var zero = new GetStockResponse { Available = 0, ReorderLevel = 0 };

Console.WriteLine($"empty: {empty.CalculateSize()} bytes, HasReorderLevel={empty.HasReorderLevel}");
Console.WriteLine($"zero:  {zero.CalculateSize()} bytes, HasReorderLevel={zero.HasReorderLevel}");
Console.WriteLine($"zero hex: {Convert.ToHexString(zero.ToByteArray())}");
empty: 0 bytes, HasReorderLevel=False
zero:  2 bytes, HasReorderLevel=True
zero hex: 3800

Available = 0 was never written to the wire. ReorderLevel = 0 took two bytes: 38 is the tag (field 7, varint) and 00 the value. Message-typed fields (unit_price, updated_at) already carry presence; in C# they come back null when unset. So you only need optional on scalars where zero has its own meaning.

Well-Known Types and a DecimalValue for Money

protobuf's scalar types are double, float, the integer families, bool, string and bytes. For dates, durations and nullable values Google ships ready-made messages, the well-known types: google.protobuf.Timestamp converts to DateTime/DateTimeOffset in C#, and Duration to TimeSpan. Wrappers such as Int32Value and StringValue appear directly as int? and string in generated code. Empty is for calls with no parameters or no return value. Timestamp always holds UTC (Coordinated Universal Time); pass Timestamp.FromDateTime a DateTime whose Kind isn't UTC and you get an exception, which makes FromDateTimeOffset the more comfortable choice.

The real gap is decimal. Carrying a price as double means rounding errors. Embedding it in a string leaves the format undefined: one client writes "249.90", another running with Turkish culture writes "249,90". Microsoft's gRPC documentation also suggests a message that keeps the whole part and the fractional part (in units of 10⁻⁹) separately. Bookstore's shared contract:

syntax = "proto3";

package bookstore.common.v1;

option csharp_namespace = "Bookstore.Common.V1";

// 249.90 -> units = 249, nanos = 900000000
// -1.50  -> units = -1,  nanos = -500000000
message DecimalValue {
  int64 units = 1;
  sfixed32 nanos = 2;
}

message Money {
  string currency_code = 1; // ISO 4217: "TRY", "EUR"
  DecimalValue amount = 2;
}

sfixed32 was chosen for nanos because the value is usually in the hundreds of millions and can be negative; a fixed four bytes is shorter than a varint here. The currency code is the three-letter code from ISO (International Organization for Standardization) 4217. Because the generated class is partial, we add the conversion next to it:

namespace Bookstore.Common.V1;

public partial class DecimalValue
{
    private const decimal NanoFactor = 1_000_000_000m;

    public static DecimalValue FromDecimal(decimal value)
    {
        long units = decimal.ToInt64(decimal.Truncate(value));
        decimal nanos = (value - units) * NanoFactor;

        // don't silently cut off more than 9 digits
        if (nanos != decimal.Truncate(nanos))
        {
            throw new ArgumentOutOfRangeException(nameof(value), value, "More than 9 fractional digits.");
        }

        return new DecimalValue { Units = units, Nanos = decimal.ToInt32(nanos) };
    }

    public decimal ToDecimal() => Units + Nanos / NanoFactor;
}

I used explicit methods instead of implicit operators: when a fraction longer than nine digits arrives, I don't want the conversion to round it silently somewhere you can't see while reading the code. The output:

249.90 -> units=249, nanos=900000000 -> 249.9
-1.5 -> units=-1, nanos=-500000000 -> -1.5
0.000000001 -> units=0, nanos=1 -> 0.000000001
More than 9 fractional digits. (Parameter 'value')
Actual value was 0.1234567891.

Look at the first line: 249.90 went out and came back as 249.9. The value is the same, but the scale carried by decimal (the trailing zero) is gone. If you print "249.90" on an invoice, format it on screen; don't rely on scale. Alternatively you can use google.type.Money from Google's common types (in the Google.Api.CommonProtos package; currency_code, units and nanos in one message). We stayed with our own message because we'll use DecimalValue outside money too, for a discount rate for example.

There are two tools for collections. repeated becomes RepeatedField<T> in C#, and map<string, int32> becomes MapField<string, int>. Neither has a setter; you fill them with Lines.Add(...) or inside an object initializer with { ["IST-1"] = 8 }. oneof means "at most one of these". ReleaseStock takes either a reservation id or an order id; set both and the last one wins:

var release = new ReleaseStockRequest { ReservationId = "r-1001" };
release.OrderId = "o-77";
Console.WriteLine($"{release.TargetCase}: {release}");
OrderId: { "orderId": "o-77" }

On the server, a switch on TargetCase is much cleaner than checking two separate nullable fields and wondering what to do when both are set.

Wire Format: Why Field Numbers Matter So Much

I serialized the same GetStockResponse (a GUID, two numbers, an enum, a price, a date and a map with two warehouses) both in binary and in protobuf's JSON format:

protobuf: 91 bytes
json:     305 bytes
{ "bookId": "3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13", "available": 12, "reservedQuantity": 3, "status": "STOCK_STATUS_IN_STOCK", "unitPrice": { "currencyCode": "TRY", "amount": { "units": "249", "nanos": 900000000 } }, "updatedAt": "2026-11-14T09:30:00Z", "warehouseQuantities": { "IST-1": 8, "ANK-1": 4 } }
0A2433663263396131652D376234642D

The first 16 bytes of the binary sum up the mechanism. 0A is the tag: field number 1 shifted left by three bits and combined with wire type 2 (length-delimited), i.e. (1 << 3) | 2. 24 is the length, 36 in hex; the character count of the GUID's string form. The 33 66 32 63 that follows is simply the ASCII codes of "3f2c". The field name appears nowhere. The JSON output has two interesting details as well: units, an int64, is written as a string ("249") because JavaScript numbers cannot safely carry integers above 2⁵³, and the enum travels by name. Both rules will come back in the post on browsers and JSON transcoding.

So the reader looks only at the number and the wire type. Change the name and a binary reader won't notice; change the number, or put a different type on the same number, and the reader reads the wrong thing, usually without an error.

Backward Compatibility Rules

To test the opening questions I put two versions of the Inventory message side by side:

// v1: the first version of Inventory
message StockV1 {
  string book_id = 1;
  int32 available = 2;
  int32 status = 4;
}

// v2: a field added, one renamed
message StockV2 {
  string book_id = 1;
  int32 available_quantity = 2;
  int32 status = 4;
  string warehouse_code = 9;
}

The new server writes v2, the old client reads it with v1 and sends it back unchanged:

var v2 = new StockV2 { BookId = "b-42", AvailableQuantity = 7, Status = 1, WarehouseCode = "IST-1" };
byte[] fromNewServer = v2.ToByteArray();

StockV1 oldClient = StockV1.Parser.ParseFrom(fromNewServer);
Console.WriteLine($"v1 reads v2: {oldClient}");

// the old client sends the message back untouched; is the unknown field lost?
byte[] roundTripped = oldClient.ToByteArray();
StockV2 backOnServer = StockV2.Parser.ParseFrom(roundTripped);
Console.WriteLine($"after v1 round-trip: {backOnServer}");
v1 reads v2: { "bookId": "b-42", "available": 7, "status": 1 }
after v1 round-trip: { "bookId": "b-42", "availableQuantity": 7, "status": 1, "warehouseCode": "IST-1" }

The answer to the first question: it isn't lost. The old client doesn't recognise warehouse_code, but it keeps it as an unknown field and writes it back when serializing again. That's why adding a field is safe. There are two conditions: if you copy the message field by field into a new object (map it to a DTO and rebuild it), unknown fields are dropped; and they're dropped when you convert to JSON.

We also saw that a rename is harmless in binary: v1 says available, v2 says available_quantity, and the value 7 made it through intact. But JSON writes names. When I tried to read the old client's JSON with the new schema:

InvalidProtocolBufferException: Unknown field: available
ignore unknown: { "bookId": "b-42", "status": 1 }

The default parser rejects it. Make it lenient with WithIgnoreUnknownFields(true) and it gets worse: available is silently dropped and stock shows as zero. That answers the third question. A rename doesn't break binary clients, but it breaks everything that speaks JSON: the REST endpoints you publish with JSON transcoding, tools that store the message as JSON and read it back later, tests that work with JSON fixtures.

A type change can break the binary itself. I put different types on the same number and had them read a quantity written as int32:

int32 5  as int64:  5
int32 -1 as int64:  -1
int32 5  as sint32: -3
int32 5  as string: ''
int32 -1 size: 17 bytes, int32 5 size: 8 bytes

The answer to the second question: -3, with no error. int32, int64, uint32, uint64 and bool share the same varint encoding, so they're compatible with each other (values that don't fit are truncated). sint32, however, uses ZigZag encoding to keep negative numbers compact; decode the same bytes with ZigZag and 5 becomes -3. When we switch to string, the wire type doesn't match, so the C# parser puts the value into unknown fields and Quantity stays empty. The last line shows why sint exists: a negative int32 becomes a ten-byte varint.

Deleting a field is only safe if you lock its number. With reserved you set the number and the name aside; if someone reuses them by accident, protoc stops the build:

message StockItem {
  reserved 3;
  reserved "warehouse";

  string book_id = 1;
  int32 available = 2;
  string location = 3;
  string warehouse = 4;
}
Protos/stock.proto(11,12): error : Field "location" uses reserved number 3.
Protos/stock.proto(11,12): error : Suggested field numbers for bookstore.inventory.v1.StockItem: 5
Protos/stock.proto(17,10): error : Field name "warehouse" is reserved.

Putting the rules in one place:

ChangeBinary clientJSON client
Add a field (new number)SafeSafe (a strict old parser may reject it)
Delete a field and mark its number reservedSafeSafe
Rename a fieldSafeBreaking
int32 → int64Safe (large values may be truncated)Careful: int64 is a string in JSON
int32 → sint32, string → numberSilently corruptsBreaking
Change or reuse a numberSilently corruptsDepends
Add a new enum valueSafe, old client sees the numberOld client doesn't know the name

You'll see this table on the event side as well; in the message contracts post of the Working with Message Queues series we discussed the same problem for messages sitting in a broker. The difference: in gRPC a mismatch surfaces immediately because both sides run at the same time, while in a queue old messages can wait for days.

Generating C# with Grpc.Tools

The Grpc.Tools package generates C# from .proto files; it bundles the protoc compiler and the gRPC plugin, and runs during the build. In Bookstore the contracts live in the protos/ folder at the solution root. Inventory generates the server side, Ordering the client side:

<!-- Bookstore.Inventory.csproj -->
<ItemGroup>
    <Protobuf Include="..\..\protos\bookstore\**\*.proto" ProtoRoot="..\..\protos" GrpcServices="Server" />
</ItemGroup>

<!-- Bookstore.Ordering.csproj -->
<ItemGroup>
    <Protobuf Include="..\..\protos\bookstore\**\*.proto" ProtoRoot="..\..\protos" GrpcServices="Client" />
</ItemGroup>

GrpcServices takes four values: Server, Client, Both (the default) and None, which generates messages only. ProtoRoot tells the compiler where to resolve import "bookstore/common/v1/money.proto" from; leave it out and imports aren't found. In Inventory, the Grpc.AspNetCore package already brings in Grpc.Tools and Google.Protobuf. In Ordering we add Grpc.Net.ClientFactory, Google.Protobuf and Grpc.Tools separately (Grpc.Tools with PrivateAssets="all", since it's needed only at build time).

The generated files land under obj/Debug/net10.0/bookstore/inventory/v1/: Inventory.cs holds the messages, InventoryGrpc.cs the service side. Each message is a sealed partial class implementing IMessage<T> and IBufferMessage; it carries static Parser and Descriptor properties, constants such as BookIdFieldNumber for each field, and Has/Clear pairs for optional fields. In InventoryGrpc.cs, the Inventory project gets only InventoryService.InventoryServiceBase (the abstract class you override) and the Ordering project only InventoryService.InventoryServiceClient. Don't edit these files by hand; they are rewritten on every build. Add extra behaviour with a partial class, as we did for DecimalValue.

Sharing the contract

Where Ordering gets Inventory's contract from is an organisational decision. There are three common routes. The first is copying: each consumer copies the .proto file into its own project; it's independent, but the copies drift over time and nobody knows which is right. The second is a shared folder like ours, or a separate contract repository; there is a single source, but every consumer is tied to the same repository or a submodule. The third is publishing the .proto files (or the generated code) as a NuGet package; it's versioned and each consumer upgrades at its own pace, but it's natural only for .NET consumers. In a polyglot team, sharing the .proto files themselves always beats sharing generated C#: a Go or TypeScript client can generate its own code from the same file.

A short note on Protobuf Editions

The protobuf team is moving from syntax = "proto3" to versioned "editions" such as edition = "2023"; the differences between proto2 and proto3 become feature settings at file and field level. I tried it with protoc 35.1 bundled in Grpc.Tools 2.85: edition = "2023" and edition = "2024" compile fine for C#, while "2025" fails with Unknown edition "2025". One difference jumps out immediately: in edition 2023, scalar fields carry explicit presence by default, so HasAvailable is generated even for int32 available = 2;. This series continues with proto3; don't switch before checking editions support in the tools around you (JSON transcoding, gRPC-Web client generators, other languages' plugins).

In Production

Catch breaking changes in CI. It's very easy to miss an int32 turning into sint32 in a human review. Tools such as the Buf CLI's buf breaking command compare two versions of the .proto files and report number changes, type changes and deleted-but-not-reserved fields at the pull request stage.

Deployment order matters. When you add a field, ship the server (the side that fills it) first, then the clients. When retiring a field, do the reverse: first clients stop reading it, then the server stops writing it, and finally the number becomes reserved. If a breaking change is unavoidable, publish a bookstore.inventory.v2 package side by side and serve both versions for a while.

Measure message sizes. The binary format is small, but not unlimited: ASP.NET Core's gRPC server rejects incoming messages larger than 4 MB by default. Putting thousands of rows into a repeated field without paging approaches that limit; for growing data like a stock list, consider streaming or paging. We cover limits and streaming in detail in later posts.

Keep the contract separate from the storage model. It's tempting to use GetStockResponse like an Entity Framework Core (EF Core) entity, but then the contract must change whenever the database schema does. Put a small mapping layer between the two.

Common Pitfalls

Embedding dates and money in strings with no defined format. Sending "14.11.2026" or "249,90" means assuming every client parses with the same culture. Use Timestamp for dates and DecimalValue/Money for money. GUIDs are a little different: carrying them as strings is practical and readable (38 bytes in our example), while bytes brings that down to 18 bytes but shows up as base64 in logs and JSON. Whichever you choose, define the id as an opaque value in the contract and do the Guid.Parse once at the boundary instead of scattering it across the code.

Trying to add a zero value to an enum later. If the first version said STOCK_STATUS_IN_STOCK = 0, you can't put UNSPECIFIED at zero later: that number is already live and old clients read zero as "in stock". The right layout from day one is _UNSPECIFIED = 0.

Reusing a number. Giving a deleted field's number to a new field means reading old clients' data into the new field. reserved on every deletion.

Losing unknown fields in a DTO. When a gateway maps a message to its own model and rebuilds it, fields it doesn't recognise vanish. An intermediate layer that doesn't forward messages as they are slows down the contract's evolution.

Making every field optional. Adding presence to everything "just in case" means a Has check on every read. Limit it to fields where zero really carries a separate meaning.

A Small Experiment

★☆☆ Add google.protobuf.Duration reservation_ttl = 9; to GetStockResponse, give it a value, measure how many bytes the binary grows by, and read it back with ToTimeSpan().

★★☆ Delete warehouse_code from StockV2 and, without reserving the number, add int32 warehouse_id = 9; in its place. Read bytes written by the old version with the new one and explain what happens; then add reserved 9; and look at protoc's error.

★★★ Write a validation for DecimalValue that throws when the signs of units and nanos don't match (units = 1, nanos = -5), and test the conversion with edge values such as decimal.MaxValue.

The contract is ready, but nobody calls these methods yet. In the next post we bring Inventory up as a real gRPC service in ASP.NET Core, call it from Ordering with GrpcChannel and the client factory, and see how deadlines, cancellation and RpcException carry errors.