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 — unary plus server, client, and bidi streaming. 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;
}// Models/Payment.cs
using System.Text.Json.Serialization;
namespace Example.Payments.Models;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("customerId")]
public required string CustomerId { get; init; }
[JsonPropertyName("amount")]
public required long Amount { get; init; }
[JsonPropertyName("currency")]
public required Currency Currency { get; init; }
[JsonPropertyName("status")]
public required PaymentStatus Status { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("kind")]
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 covers every non-optional field; System.Text.Json attributes pin the JSON property names so the wire shape stays stable as the C# names evolve.
// Models/Payment.cs
using System.Text.Json.Serialization;
namespace Example.Payments.Models;
public record Payment
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("customerId")]
public required string CustomerId { get; init; }
[JsonPropertyName("amount")]
public required long Amount { get; init; }
[JsonPropertyName("currency")]
public required Currency Currency { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("kind")]
public KindUnion? Kind { get; init; }
}
// Models/Currency.cs
using System.Text.Json.Serialization;
using System.Runtime.Serialization;
namespace Example.Payments.Models;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Currency
{
[EnumMember(Value = "CURRENCY_UNSPECIFIED")]
CurrencyUnspecified = 0,
[EnumMember(Value = "CURRENCY_EUR")]
CurrencyEur = 1,
[EnumMember(Value = "CURRENCY_USD")]
CurrencyUsd = 2
}
// 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("customerId")]
public required string CustomerId { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
// ...
}
// - record, not partial class
// - init accessors, not setters
// - required on every non-optional field, so the
// compiler rejects construction without them
// - JSON property names are emitted 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 unary RPC is an async method that POSTs to /package.Service/Method with a Connect-Protocol-Version: 1 header and a JSON body. Server-stream RPCs return IAsyncEnumerable<T> and read NDJSON line-by-line from the response. Opt into Microsoft.Extensions.DependencyInjection wiring (HttpClientFactory + an AddPaymentsService extension) by toggling UseDependencyInjection. System.Text.Json handles both sides.
// Client/PaymentsService.cs
using System.Net.Http;
using System.Text.Json;
using Example.Payments.Models;
namespace Example.Payments.Client;
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);
}
}
// 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)!;
}
// For server-stream RPCs (`stream` return type),
// a sibling helper returns IAsyncEnumerable<T> and
// reads NDJSON line-by-line from the response body.
protected async IAsyncEnumerable<TResponse> ExecuteServerStreamAsync<TResponse>(
string path, object? body,
[EnumeratorCancellation] CancellationToken cancellationToken)
{ /* NDJSON loop over the response stream */ }
}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."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. For HTTP/2 + binary framing, protoc --csharp_out with Grpc.Tools is the established path. This generator's angle is a different one: a modern 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.