Protobuf Serialization in .NET with Google.Protobuf and gRPC
Protocol Buffers (Protobuf) is Google's language-neutral, schema-driven binary serialization format. In the .NET ecosystem, it's most commonly encountered as the wire format for gRPC, but it's equally useful for any scenario where you need compact, versioned, cross-platform serialization.
Schema-first design
Unlike JSON serialization where you start with C# classes, Protobuf starts with a .proto schema file:
syntax = "proto3";
option csharp_namespace = "MyApp.Contracts";
message Order {
int32 id = 1;
string customer_name = 2;
repeated LineItem items = 3;
google.protobuf.Timestamp created_at = 4;
}
message LineItem {
string product = 1;
int32 quantity = 2;
double unit_price = 3;
}
Each field has a numeric tag (the = 1, = 2, etc.) that identifies it in the binary format. These tags — not the field names — are what matter on the wire, which makes renaming fields a non-breaking change.
Setting up in .NET
Install the required packages:
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
Add your .proto files to the project:
<ItemGroup>
<Protobuf Include="Protos\order.proto" GrpcServices="None" />
</ItemGroup>
Set GrpcServices="None" if you only want the message classes. Use "Server", "Client", or "Both" if you're also defining gRPC services.
The Grpc.Tools package includes the Protobuf compiler (protoc), which runs at build time and generates C# classes from your .proto files.
Using generated classes
The generated classes are fully functional C# objects:
var order = new Order
{
Id = 1,
CustomerName = "Alice",
CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow),
Items =
{
new LineItem { Product = "Widget", Quantity = 3, UnitPrice = 9.99 },
new LineItem { Product = "Gadget", Quantity = 1, UnitPrice = 24.50 }
}
};
Serialize to bytes and back:
// Serialize
byte[] bytes = order.ToByteArray();
// Deserialize
var restored = Order.Parser.ParseFrom(bytes);
You can also serialize to and from streams, which is more efficient for large messages:
using var stream = new MemoryStream();
order.WriteTo(stream);
stream.Position = 0;
var restored = Order.Parser.ParseFrom(stream);
Using with gRPC
Define a service in your .proto file:
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc ListOrders (ListOrdersRequest) returns (stream Order);
}
message GetOrderRequest {
int32 id = 1;
}
message ListOrdersRequest {
int32 page_size = 1;
string page_token = 2;
}
Implement the server:
public class OrderServiceImpl : OrderService.OrderServiceBase
{
public override async Task<Order> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
var order = await _repository.FindAsync(request.Id);
return MapToProto(order);
}
}
The gRPC tooling generates both the server base class and a typed client, giving you end-to-end type safety from a single schema definition.
Schema evolution rules
Protobuf is designed for forward and backward compatibility, provided you follow these rules:
- Never reuse a field number. Once assigned, a tag number is permanent.
- Adding new fields is safe. Old consumers ignore unknown fields.
- Removing fields is safe if you reserve the removed tag number:
message Order {
reserved 5; // was 'discount'
reserved "discount";
int32 id = 1;
// ...
}
- Renaming fields is safe. Only the numeric tag matters on the wire.
- Changing field types is dangerous.
int32toint64works;int32tostringdoes not.
Protobuf vs MessagePack vs JSON
| Aspect | Protobuf | MessagePack | JSON |
|---|---|---|---|
| Schema | Required (.proto) | Optional (attributes) | None |
| Human-readable | No | No | Yes |
| Cross-language | Excellent | Good | Excellent |
| Payload size | Smallest | Small | Largest |
| .NET integration | Good (gRPC) | Good (SignalR) | Native |
| Versioning | Built-in rules | Manual | Manual |
Choose Protobuf when you need strict schema contracts across teams or languages, particularly for gRPC services. Choose MessagePack for .NET-to-.NET scenarios where you want binary performance without a schema file. Choose JSON for public APIs and simplicity.
Wrapping up
Protobuf brings rigour to serialization. The schema-first approach forces you to think about versioning, field types, and contracts before writing application code. In a microservices architecture where multiple teams consume your data, that discipline pays dividends. Start with gRPC — it's the most natural entry point — and consider Protobuf for caching, messaging, and event storage once you're comfortable with the tooling.