Learn how to build gRPC services in .NET with Protocol Buffers. Step-by-step C# tutorial with code, streaming, best practices, and pitfalls. Start now.
If you have ever profiled a busy ASP.NET Core REST API, you know where the time goes: JSON serialization, HTTP/1.1 connection churn, and hand-written client code that drifts out of sync with the server. gRPC .NET fixes all three. It uses Protocol Buffers for compact binary serialization, HTTP/2 for multiplexed connections, and code generation so your client and server share a single contract. In this tutorial you will build a complete gRPC service in C#, add streaming, call it from a .NET client, and learn the best practices and pitfalls that separate a demo from a production system.
What Is gRPC and Why Use It in .NET?
gRPC is a high-performance Remote Procedure Call framework originally built at Google and now maintained by the Cloud Native Computing Foundation. Instead of exposing resources over URLs like REST, you define services with strongly typed methods in a .proto file. The tooling generates the server base class and the client for you.
Microsoft ships first-class support through the grpc-dotnet project, and it is the recommended way to build gRPC services on .NET 8, .NET 9, and .NET 10. Here is why it matters:
- Speed. Protocol Buffers messages are typically 3 to 10 times smaller than equivalent JSON and parse far faster. Benchmarks from the ASP.NET team regularly show gRPC handling several times the requests per second of JSON APIs at lower CPU.
- HTTP/2 by default. One TCP connection carries many concurrent calls, with header compression and full-duplex streaming.
- Contract-first design. The
.protofile is the single source of truth. Changing a field regenerates both sides, and the compiler catches mismatches at build time rather than in production. - Polyglot. The same contract generates clients for Go, Java, Python, TypeScript, and more, which is why gRPC dominates microservice-to-microservice traffic.
gRPC vs REST: When to Choose Which
gRPC is not a REST replacement for every scenario. Use gRPC for internal service-to-service communication, real-time streaming, mobile backends that care about bandwidth, and any hot path where serialization cost shows up in your profiler. Stick with REST or minimal APIs for public browser-facing endpoints, third-party integrations that expect JSON, and simple CRUD where human readability and curl-ability matter more than throughput. Many teams run both: gRPC inside the cluster, REST at the edge.
Step 1: Create an ASP.NET Core gRPC Service
The .NET SDK includes a ready-made template. Open a terminal and run:
dotnet new grpc -n OrderService
cd OrderService
dotnet run
The template gives you a working Greeter service. We will replace it with something more realistic: an order lookup service. Open the project file and confirm these packages are referenced. The template adds them automatically, but it helps to know what each one does:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- Grpc.AspNetCore bundles Grpc.Tools (the protoc code generator),
Google.Protobuf, and the ASP.NET Core server integration -->
<PackageReference Include="Grpc.AspNetCore" Version="2.*" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>
</Project>
The Protobuf item is the important line. GrpcServices="Server" tells the build to generate the abstract service base class. On the client project you set it to Client, and for a shared contracts library you use Both.
Step 2: Define the Contract with Protocol Buffers
Delete Protos/greet.proto and create Protos/orders.proto. This is the heart of any gRPC C# project, so read the comments carefully:
syntax = "proto3";
// Sets the C# namespace of the generated classes.
option csharp_namespace = "OrderService.Grpc";
package orders;
import "google/protobuf/timestamp.proto";
service Orders {
// Unary: one request, one response.
rpc GetOrder (GetOrderRequest) returns (OrderReply);
// Server streaming: one request, a stream of responses.
rpc WatchOrderStatus (GetOrderRequest) returns (stream OrderStatusUpdate);
// Client streaming: a stream of requests, one response.
rpc CreateOrders (stream CreateOrderRequest) returns (CreateOrdersSummary);
}
message GetOrderRequest {
int32 order_id = 1;
}
message OrderReply {
int32 order_id = 1;
string customer_email = 2;
double total = 3;
OrderStatus status = 4;
google.protobuf.Timestamp created_at = 5;
repeated OrderLine lines = 6;
}
message OrderLine {
string sku = 1;
int32 quantity = 2;
double unit_price = 3;
}
message OrderStatusUpdate {
int32 order_id = 1;
OrderStatus status = 2;
google.protobuf.Timestamp changed_at = 3;
}
message CreateOrderRequest {
string customer_email = 1;
repeated OrderLine lines = 2;
}
message CreateOrdersSummary {
int32 created_count = 1;
repeated int32 order_ids = 2;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
PENDING = 1;
PAID = 2;
SHIPPED = 3;
DELIVERED = 4;
}
A few things worth understanding about why the file looks this way:
- Field numbers, not names, go on the wire. The
= 1,= 2tags are what Protocol Buffers serializes. You can rename a field freely, but you must never reuse or renumber a tag once it ships. - Enums must start at zero. Proto3 treats zero as the default, so an explicit
UNSPECIFIEDvalue protects you from silently misreading a missing field asPENDING. - Well-known types.
google.protobuf.Timestampmaps cleanly toDateTimeandDateTimeOffsetin C# through extension methods, so you avoid ambiguity about time zones and formats. - snake_case in proto, PascalCase in C#. The generator converts
customer_emailtoCustomerEmailautomatically.
Step 3: Implement the gRPC Service in C#
Build the project once so the generated Orders.OrdersBase class exists, then create Services/OrdersService.cs:
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using OrderService.Grpc;
namespace OrderService.Services;
public sealed class OrdersService : Orders.OrdersBase
{
private readonly IOrderRepository _repository;
private readonly ILogger<OrdersService> _logger;
public OrdersService(IOrderRepository repository, ILogger<OrdersService> logger)
{
_repository = repository;
_logger = logger;
}
public override async Task<OrderReply> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
// Always honor the cancellation token; the client may have hung up.
var order = await _repository.FindAsync(request.OrderId, context.CancellationToken);
if (order is null)
{
// gRPC has its own status codes. Do not throw generic exceptions.
throw new RpcException(new Status(
StatusCode.NotFound, $"Order {request.OrderId} was not found."));
}
var reply = new OrderReply
{
OrderId = order.Id,
CustomerEmail = order.CustomerEmail,
Total = (double)order.Total,
Status = order.Status,
CreatedAt = Timestamp.FromDateTimeOffset(order.CreatedAt)
};
// 'repeated' fields are read-only collections you Add to, not assign.
reply.Lines.AddRange(order.Lines.Select(l => new OrderLine
{
Sku = l.Sku,
Quantity = l.Quantity,
UnitPrice = (double)l.UnitPrice
}));
return reply;
}
public override async Task WatchOrderStatus(
GetOrderRequest request,
IServerStreamWriter<OrderStatusUpdate> responseStream,
ServerCallContext context)
{
// Server streaming: push updates until the client disconnects.
await foreach (var change in _repository
.StatusChangesAsync(request.OrderId, context.CancellationToken))
{
await responseStream.WriteAsync(new OrderStatusUpdate
{
OrderId = request.OrderId,
Status = change.Status,
ChangedAt = Timestamp.FromDateTimeOffset(change.At)
});
}
}
public override async Task<CreateOrdersSummary> CreateOrders(
IAsyncStreamReader<CreateOrderRequest> requestStream,
ServerCallContext context)
{
// Client streaming: read many requests, reply once at the end.
var summary = new CreateOrdersSummary();
await foreach (var req in requestStream.ReadAllAsync(context.CancellationToken))
{
var id = await _repository.CreateAsync(req, context.CancellationToken);
summary.OrderIds.Add(id);
summary.CreatedCount++;
}
_logger.LogInformation("Created {Count} orders via stream", summary.CreatedCount);
return summary;
}
}
Now register the service in Program.cs. The template already does most of this:
using OrderService.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(options =>
{
// Surface exception messages to clients in Development only.
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB, the default
});
builder.Services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
var app = builder.Build();
app.MapGrpcService<OrdersService>();
app.MapGet("/", () => "This server hosts gRPC endpoints. Use a gRPC client to connect.");
app.Run();
Run dotnet run and Kestrel will listen on HTTPS with HTTP/2 enabled. gRPC requires HTTP/2, which is why the template configures Kestrel for it out of the box.
Step 4: Call the Service from a .NET gRPC Client
Create a console project and add the client-side packages:
dotnet new console -n OrderClient
cd OrderClient
dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
Copy the same orders.proto into a Protos folder and reference it with GrpcServices="Client" in the project file. Then write the client:
using Grpc.Core;
using Grpc.Net.Client;
using OrderService.Grpc;
// A GrpcChannel is expensive to create and cheap to reuse.
// Create ONE per server address and share it for the app's lifetime.
using var channel = GrpcChannel.ForAddress("https://localhost:7042");
var client = new Orders.OrdersClient(channel);
// --- Unary call with a deadline ---
try
{
var order = await client.GetOrderAsync(
new GetOrderRequest { OrderId = 42 },
deadline: DateTime.UtcNow.AddSeconds(5));
Console.WriteLine($"Order {order.OrderId} for {order.CustomerEmail}: " +
$"{order.Status}, total {order.Total:C}, " +
$"placed {order.CreatedAt.ToDateTimeOffset():g}");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
Console.WriteLine($"Not found: {ex.Status.Detail}");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded)
{
Console.WriteLine("The server took too long to respond.");
}
// --- Server streaming ---
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var watch = client.WatchOrderStatus(
new GetOrderRequest { OrderId = 42 },
cancellationToken: cts.Token);
await foreach (var update in watch.ResponseStream.ReadAllAsync(cts.Token))
{
Console.WriteLine($"Order {update.OrderId} is now {update.Status} " +
$"at {update.ChangedAt.ToDateTimeOffset():T}");
}
// --- Client streaming ---
using var create = client.CreateOrders();
foreach (var email in new[] { "a@example.com", "b@example.com" })
{
var req = new CreateOrderRequest { CustomerEmail = email };
req.Lines.Add(new OrderLine { Sku = "SKU-100", Quantity = 2, UnitPrice = 9.99 });
await create.RequestStream.WriteAsync(req);
}
await create.RequestStream.CompleteAsync(); // Signal "no more requests"
var summary = await create; // Await the single response
Console.WriteLine($"Created {summary.CreatedCount} orders: {string.Join(", ", summary.OrderIds)}");
In a real application, prefer the gRPC client factory instead of new-ing channels. Install Grpc.Net.ClientFactory and register the client with dependency injection:
builder.Services
.AddGrpcClient<Orders.OrdersClient>(o =>
{
o.Address = new Uri("https://orders.internal:443");
})
.ConfigureChannel(c =>
{
// Retry transient failures automatically.
c.ServiceConfig = new ServiceConfig
{
MethodConfigs =
{
new MethodConfig
{
Names = { MethodName.Default },
RetryPolicy = new RetryPolicy
{
MaxAttempts = 4,
InitialBackoff = TimeSpan.FromMilliseconds(200),
MaxBackoff = TimeSpan.FromSeconds(2),
BackoffMultiplier = 2,
RetryableStatusCodes = { StatusCode.Unavailable }
}
}
}
};
});
The factory manages channel lifetime, integrates with HttpClientFactory handlers and Polly, and lets you inject Orders.OrdersClient anywhere.
gRPC .NET Best Practices for Production
- Reuse channels. Every
GrpcChannelopens its own HTTP/2 connection. Creating one per call is the single most common performance mistake in gRPC C# code. - Always set deadlines. Without a deadline, a hung downstream service ties up your threads forever. Deadlines propagate across services automatically when you forward the
ServerCallContexttoken. - Use gRPC status codes. Throw
RpcExceptionwithNotFound,InvalidArgument,PermissionDenied, and so on. Unhandled exceptions become an opaqueUnknownstatus that clients cannot act on. - Add interceptors for cross-cutting concerns. Logging, authentication, and exception mapping belong in a server
Interceptor, not copied into each method. - Secure it. Use TLS everywhere and plug in ASP.NET Core authentication with
[Authorize]on the service class. JWT bearer tokens work exactly as they do for controllers. - Enable health checks and reflection.
Grpc.AspNetCore.HealthCheckslets Kubernetes probe your service, andGrpc.AspNetCore.Server.Reflectionlets tools like grpcurl and Postman discover your contract. - Consider gRPC-Web or JSON transcoding for browsers. Browsers cannot speak native gRPC.
Microsoft.AspNetCore.Grpc.JsonTranscodingexposes your gRPC methods as RESTful JSON endpoints from the same code, with OpenAPI support.
Common Pitfalls and How to Avoid Them
- Calling an HTTP/1.1 endpoint. If you see
Status(StatusCode="Internal", Detail="Bad gRPC response. HTTP status code: 404")or protocol errors, the server is not serving HTTP/2. Check the KestrelProtocolssetting and any reverse proxy in front of it. - Using
doublefor money. Protocol Buffers has no decimal type. For financial data, send an integer number of cents or define a customDecimalValuemessage with units and nanos, as Microsoft documents. - Forgetting the zero-value rule. Proto3 does not serialize default values. A missing
int32arrives as0and a missingstringas empty. Useoptionalfields or wrapper types when you must distinguish "not set" from zero. - Large messages. gRPC caps messages at 4 MB by default. Do not raise it to 500 MB. Stream large payloads in chunks instead, which also keeps memory flat.
- Breaking the contract. Removing a field or changing its type breaks older clients. Mark old fields
reserved, add new ones with fresh tags, and never change a tag's type. - Blocking on async calls. Calling
.Resulton a gRPC call can deadlock and hides cancellation. Useawaitend to end.
Conclusion: Key Takeaways on gRPC .NET
gRPC .NET gives C# developers a fast, strongly typed, contract-first way to build APIs, and the tooling in ASP.NET Core makes it nearly as easy to set up as a minimal API. Here is what to remember:
- Define your contract in a
.protofile and let the compiler generate both server and client code. - Protocol Buffers and HTTP/2 deliver smaller payloads, fewer connections, and higher throughput than JSON over HTTP/1.1.
- Choose the right call type: unary for request-reply, server streaming for live updates, client streaming for bulk uploads, and bidirectional streaming for chat-style workloads.
- Reuse channels, set deadlines, and return proper gRPC status codes to keep your services resilient.
- Respect Protocol Buffers evolution rules so you can ship new versions without breaking existing clients.
Start by converting one internal service-to-service call in your system to gRPC .NET and measure the difference. Most teams see the latency and CPU savings immediately, and the generated client code pays for itself on the very first contract change.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment