Protobuf’s C# code generation is a surprisingly effective way to bypass the entire .NET serialization ecosystem.
Let’s see it in action. Imagine we have a simple message definition in a .proto file:
syntax = "proto3";
package myapp.messages;
message User {
string name = 1;
int32 age = 2;
repeated string emails = 3;
}
We want to generate C# classes from this. The protoc compiler, with the csharp_out plugin, is our tool.
First, ensure you have protoc installed. Then, you’ll need the C# plugin. You can get this via a NuGet package:
dotnet add package Google.Protobuf.Tools
This installs protoc and the necessary C# compiler plugin. Now, run the generation command:
protoc --csharp_out=. --proto_path=. myapp/messages/user.proto
This command tells protoc:
--csharp_out=.: Generate C# code and place it in the current directory (.).--proto_path=.: Look for.protofiles in the current directory (.).myapp/messages/user.proto: The input.protofile.
The output will be a User.cs file in your current directory. It will look something like this:
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: myapp/messages/user.proto
#pragma warning disable 0414, 0618, 0169, 218
#region Designer generated code
using grpc = global::Grpc.Core;
namespace myapp.messages {
public static partial class UserReflection {
// ... reflection descriptors ...
}
public sealed partial class User : global::Google.Protobuf.IMessage<User>
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY
, global::Google.Protobuf.IBufferMessage
#endif
{
private static readonly global::Google.Protobuf.Reflection.MessageDescriptor _descriptor;
static User() {
// ... descriptor initialization ...
_descriptor = global::Google.Protobuf.Reflection.FileDescriptor.FromGeneratedCode(descriptorData, new pbc::FileDescriptor[] { }, new pbc::GeneratedClrTypeInfo(null, new pbc::GeneratedClrTypeInfo[] {
new pbc::GeneratedClrTypeInfo(typeof(global::myapp.messages.User), global::myapp.messages.User.Parser, new[]{ "Name", "Age", "Emails" }, null, null, null)
}));
global::Google.Protobuf.Reflection.MessageExtensions.SetMessageDescriptor(ref _shared.Descriptor, _descriptor);
}
// ... constructors, properties, Parse methods ...
public string Name {
get { return Name_; }
set {
Name_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
private string name_ = "";
public int Age {
get { return Age_; }
set {
Age_ = value;
}
}
private int age_;
public global::System.Collections.Generic.List<string> Emails {
get { return emails_; }
set {
emails_ = value;
}
}
private readonly global::System.Collections.Generic.List<string> emails_ = new global::System.Collections.Generic.List<string>();
// ... other methods like CalculateSize, MergeFrom, WriteTo ...
public override string ToString() {
return global::Google.Protobuf.MessageExtensions.ToString(this);
}
public override bool Equals(object other) {
return MessageExtensions.Equals(this, other);
}
public override int GetHashCode() {
return MessageExtensions.GetHashCode(this);
}
public void WriteTo(global::Google.Protobuf.CodedOutputStream output) {
// ... serialization logic ...
}
public int CalculateSize() {
// ... size calculation logic ...
}
public void MergeFrom(User other) {
// ... merge logic ...
}
}
}
#endregion
This generated code provides a User class with properties for Name, Age, and Emails. Crucially, it implements Google.Protobuf.IMessage<T> and IBufferMessage (if the compatibility flag is off), giving it built-in serialization and deserialization capabilities.
You can then use this class in your C# application:
using myapp.messages;
// Create a user
var user = new User {
Name = "Alice",
Age = 30,
Emails = { "alice@example.com", "alice.work@example.com" }
};
// Serialize to bytes
var bytes = user.ToByteArray();
// Deserialize from bytes
var newUser = User.Parser.ParseFrom(bytes);
Console.WriteLine($"Name: {newUser.Name}, Age: {newUser.Age}, Emails: {string.Join(", ", newUser.Emails)}");
The ToByteArray() method handles the Protobuf encoding, and User.Parser.ParseFrom(bytes) handles the decoding. This is significantly more performant and schema-aware than generic JSON or XML serializers.
The core problem Protobuf code generation solves is the impedance mismatch between your application’s data structures and the wire format. Instead of writing manual serialization/deserialization code or relying on brittle reflection-based serializers, you define your schema once and generate type-safe, performant C# classes. This leads to fewer bugs, easier maintenance, and faster data transfer.
The generated code includes not just the data structures but also the logic for serializing to and deserializing from the compact binary Protobuf format. It also includes reflection information, which is used by gRPC and other advanced features. The IMessage<T> interface is the key contract that enables this.
One detail often overlooked is the [global::System.Diagnostics.DebuggerNonUserCodeAttribute] and [global::System.CodeDom.Compiler.GeneratedCodeAttribute] attributes applied to the generated classes. These indicate that the code was automatically generated and should not be edited directly, and also help tools distinguish between your application code and the generated boilerplate.
The next step you’ll likely encounter is integrating this with gRPC for efficient inter-service communication.