Protobuf to
C#.
Turn a .proto file into a modern C# client. Records with required and init, nullable reference types on every optional, discriminated union records for each oneof. Ships with an HttpClient that speaks Connect RPC over JSON. No signup, no install.
syntax = "proto3";
package payments.v1;
service PaymentsService {
rpc GetPayment(GetPaymentRequest) returns (Payment);
rpc CreatePayment(CreatePaymentRequest) returns (Payment);
}
message Payment {
string id = 1;
string customer_id = 2;
int64 amount = 3;
Currency currency = 4;
PaymentStatus status = 5;
optional string description = 6;
oneof kind {
CardPayment card = 10;
BankPayment bank = 11;
}
}
enum Currency {
CURRENCY_UNSPECIFIED = 0;
CURRENCY_EUR = 1;
CURRENCY_USD = 2;
}// Payments/V1/Models/Payment.cs
namespace Payments.V1.Models;
using System.Text.Json.Serialization;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("customer_id")]
public required string CustomerId { get; init; }
[JsonPropertyName("amount")]
public long Amount { get; init; }
[JsonPropertyName("currency")]
public Currency Currency { get; init; }
[JsonPropertyName("status")]
public PaymentStatus Status { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
public KindUnion? Kind { get; init; }
}Idiomatic C#. Not a generated-sources folder.
Below: types emitted from a small payments .proto with a oneof, an optional, and an enum. Every message is a record with init properties; required is used for non-optional strings; System.Text.Json attributes preserve the proto field names on the wire.
// Payments/V1/Models/Payment.cs
namespace Payments.V1.Models;
using System.Text.Json.Serialization;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("customer_id")]
public required string CustomerId { get; init; }
[JsonPropertyName("amount")]
public long Amount { get; init; }
[JsonPropertyName("currency")]
public Currency Currency { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
public KindUnion? Kind { get; init; }
}
// Payments/V1/Models/Currency.cs
namespace Payments.V1.Models;
public enum Currency
{
CurrencyUnspecified,
CurrencyEur,
CurrencyUsd
}
// Exhaustive pattern match — no cast, no HasX / ClearX dance:
string Label(Payment p) => p.Kind switch
{
KindUnionCard c => $"card {c.Value.Last4}",
KindUnionBank b => $"bank {b.Value.Iban}",
null => "no method",
_ => throw new InvalidOperationException(),
};Grounded in how the proto reads.
Each claim is something you can verify by running the converter on your own .proto. Click a card to see the proto fragment that triggered it and the C# that came out.
message Payment {
string id = 1;
string customer_id = 2;
optional string description = 6;
// ...
}public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("customer_id")]
public required string CustomerId { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
// ...
}
// - record, not partial class
// - init accessors, not setters
// - required on non-optional strings, so the
// compiler rejects construction without them
// - snake_case preserved on the wire via
// [JsonPropertyName], camelCase in codeA typed service, not a stub.
The service extends a thin BaseHttpClient that owns the HttpClient and the base URL. Each RPC is an async method that POSTs to /package.Service/Method with a Connect-Protocol-Version: 1 header and a JSON body. System.Text.Json handles both sides — no Google.Protobuf runtime, no Grpc.Tools MSBuild step.
// Payments/V1/Client/PaymentsService.cs
namespace Payments.V1.Client;
using System.Net.Http;
using System.Text.Json;
using Payments.V1.Models;
public class PaymentsService : BaseHttpClient
{
public PaymentsService(
HttpClient httpClient,
string baseUrl,
JsonSerializerOptions? jsonOptions)
: base(httpClient, baseUrl, jsonOptions) { }
public async Task<Payment> GetPaymentAsync(
GetPaymentRequest requestBody,
CancellationToken cancellationToken = default)
{
return await ExecuteConnectAsync<Payment>(
"/payments.v1.PaymentsService/GetPayment",
requestBody,
cancellationToken).ConfigureAwait(false);
}
public async Task<Payment> CreatePaymentAsync(
CreatePaymentRequest requestBody,
CancellationToken cancellationToken = default)
{
return await ExecuteConnectAsync<Payment>(
"/payments.v1.PaymentsService/CreatePayment",
requestBody,
cancellationToken).ConfigureAwait(false);
}
}
// Payments/V1/Client/BaseHttpClient.cs (shared)
public abstract class BaseHttpClient
{
protected readonly HttpClient _httpClient;
protected readonly string _baseUrl;
protected readonly JsonSerializerOptions? _jsonOptions;
public BaseHttpClient(
HttpClient httpClient,
string baseUrl,
JsonSerializerOptions? jsonOptions = null)
{
_httpClient = httpClient;
_baseUrl = baseUrl.TrimEnd('/');
_jsonOptions = jsonOptions;
}
protected async Task<TResponse> ExecuteConnectAsync<TResponse>(
string path, object? body, CancellationToken cancellationToken)
{
var requestUrl = $"{_baseUrl}{path}";
using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
request.Content = new StringContent(
body is null ? "{}" : JsonSerializer.Serialize(body, _jsonOptions),
Encoding.UTF8,
"application/json");
request.Headers.TryAddWithoutValidation(
"Connect-Protocol-Version", "1");
using var response = await _httpClient
.SendAsync(request, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false);
return JsonSerializer.Deserialize<TResponse>(content, _jsonOptions)!;
}
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a .proto and it produces the C# client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load proto/payments.proto and generate a C#
// HttpClient in namespace Acme.Payments.V1."Things people ask.
The questions we keep getting about Protobuf → C#. If yours is not here, run the converter — the answer is usually in the output.
/package.Service/Method with a Connect-Protocol-Version: 1 header and a JSON body. It is wire-compatible with Connect RPC servers (Buf's stack, Connect-Go, connect-es) and with JSON-transcoded gRPC gateways, but it will not talk to a pure gRPC server expecting HTTP/2 + binary framing. If you need binary gRPC, use protoc --csharp_out with Grpc.Tools — that is what they are built for. This generator's angle is a different one: a modern idiomatic C# client generated from .proto as an IDL, with JSON on the wire.Try it on your own .proto.
The converter runs in the browser. Your proto never leaves the page.