Defining a Service

A .proto file holds messages and services; field numbers identify fields on the wire and must never be reused.

Syntaxmessage HelloRequest { string name = 1; }

A .proto file is the contract that both sides of a gRPC call compile against. It is not C#. It holds messages, which are the data, and services, which are the callable methods. Both sides generate code from the same file, so the file is the single description of the call.

Fields and numbers

Each field has a type, a name and a number: string name = 1;. The number is not a default value. It is the field's identifier on the wire, and it is the only part a receiver uses to decide which field it is reading. Numbers run from 1 to 536,870,911, except 19,000 to 19,999, which Protocol Buffers keeps for itself. Numbers 1 to 15 cost one byte to encode, so spend them on fields that appear in every message.

Renaming a field is safe for protobuf traffic, because names are not sent. Changing a number is not, and giving a deleted field's number to a new field is worse: old senders and new receivers then disagree about what the bytes mean, silently. Mark removed numbers with reserved so the compiler refuses to hand them out again.

What the tooling generates

The Grpc.Tools package compiles every file listed in a <Protobuf> item on each build, into the obj folder. A message becomes a C# class, and snake_case field names become PascalCase properties: first_name becomes FirstName. A service becomes a base class to override plus a client class to call. package sets the generated namespace unless option csharp_namespace overrides it.

Example

Example · csharp
// Protos/greet.proto, compiled into C# by the Grpc.Tools package:
//     syntax = "proto3";
//     package greet.v1;
//     option csharp_namespace = "Greet.V1";
//     message HelloRequest { string name = 1; }
//
// Project file: <Protobuf Include="Protos\greet.proto" GrpcServices="Client" />

using Google.Protobuf;
using Greet.V1;

// The generated class has one property per field: name became Name.
var request = new HelloRequest { Name = "Ada" };

// Longhand for the same thing, in case you meet it in older code:
var other = new HelloRequest();
other.Name = "Ada";

byte[] bytes = request.ToByteArray();
Console.WriteLine($"byte count: {bytes.Length}");

// The first byte carries field number 1 and how the value is encoded.
// The field name "name" is nowhere in these five bytes.
Console.WriteLine($"first byte: 0x{bytes[0]:X2}");

// Parsing matches on the field number, not on the name.
HelloRequest copy = HelloRequest.Parser.ParseFrom(bytes);
Console.WriteLine($"name: {copy.Name}");
Console.WriteLine($"same: {other.Name == copy.Name}");

// Output:
// byte count: 5
// first byte: 0x0A
// name: Ada
// same: True

When to use it

  • Write the first contract for a new internal service
  • Add a field to a message that live clients already use
  • Delete a field safely when the data behind it is retired
  • Work out which C# type a repeated, map or timestamp field will generate

More examples

A service definition with every method shape

This is the whole vocabulary you need most days: a service with two method shapes, an enum, a repeated field for a list, a map for a dictionary, and a well-known type for a point in time. Protocol Buffers has no date type of its own, so google.protobuf.Timestamp is imported; it generates a Timestamp property with ToDateTimeOffset and FromDateTimeOffset methods rather than a DateTime. Note that an enum's zero value must exist and means "unset".

Example · protobuf
syntax = "proto3";

package orders.v1;

option csharp_namespace = "Orders.V1";

import "google/protobuf/timestamp.proto";

service Orders {
  // One request, one reply.
  rpc GetOrder (GetOrderRequest) returns (Order);

  // stream on the reply: many replies to one request.
  rpc ListOrders (ListOrdersRequest) returns (stream Order);
}

enum State {
  // proto3 requires the first enum value to be zero.
  STATE_UNSPECIFIED = 0;
  STATE_PAID = 1;
  STATE_SHIPPED = 2;
}

message GetOrderRequest {
  string order_id = 1;
}

message ListOrdersRequest {
  string customer_id = 1;
  int32 page_size = 2;
}

message Order {
  string order_id = 1;
  State state = 2;
  repeated string sku = 3;              // becomes RepeatedField<string> Sku
  map<string, string> labels = 4;       // becomes MapField<string, string> Labels
  google.protobuf.Timestamp placed_at = 5;
}

Deleting a field without breaking old clients

Removing a field is not a protocol break: an old sender's value simply lands in the receiver's unknown fields. Reusing its number is a break, and a quiet one, because the old bytes parse cleanly into the new field and produce wrong data. reserved makes the protobuf compiler reject that mistake at build time. Use it the moment you delete a field, and note that it reserves names as well as numbers, which matters when the service also serves JSON.

Example · protobuf
message Customer {
  // Fields 2 and 5 held email and fax_number. Both are gone.
  // reserved stops anyone handing those numbers to a new field.
  reserved 2, 5;
  reserved "email", "fax_number";

  string customer_id = 1;
  string display_name = 3;
  string country = 4;
  string contact_email = 6;
}

message Invoice {
  // Shorthand for a run of numbers.
  reserved 4 to 7;
  // The same thing written out one number at a time.
  // reserved 4, 5, 6, 7;

  string invoice_id = 1;
}

Working with the generated collections

The generated C# does not look exactly like the proto. Enum value STATE_PAID becomes State.Paid, a repeated field becomes a RepeatedField<T> with no setter, and a map becomes a MapField<TKey, TValue>. Assigning to Sku or Labels will not compile, which surprises people once each; add to the existing collection instead. Timestamp always works in UTC, so convert with a DateTimeOffset and keep offsets out of the message.

Example · csharp
using Google.Protobuf.WellKnownTypes;
using Orders.V1;

var placedAt = new DateTimeOffset(2026, 1, 5, 9, 30, 0, TimeSpan.Zero);

var order = new Order
{
    OrderId = "A-1",
    State = State.Paid,
    PlacedAt = Timestamp.FromDateTimeOffset(placedAt)
};

// repeated and map properties are read-only: add to them, never assign.
order.Sku.Add("PEN-01");
order.Sku.Add(new[] { "PAD-02", "INK-03" });
order.Labels["channel"] = "web";

Console.WriteLine($"{order.OrderId} {order.State} {order.Sku.Count}");
Console.WriteLine(order.PlacedAt.ToDateTimeOffset().ToString("u"));

// Output:
// A-1 Paid 3
// 2026-01-05 09:30:00Z

Discussion

  • Be the first to comment on this lesson.