Getting Started with gRPC in .NET

REST has been the dominant API paradigm for over a decade, but gRPC offers a compelling alternative when performance, strong typing, and streaming matter. Built on HTTP/2 and Protocol Buffers, gRPC is a natural fit for .NET microservices — and ASP.NET Core has first-class support for it.

Why gRPC?

gRPC uses Protocol Buffers (protobuf) as its interface definition language and serialisation format. Compared to JSON over HTTP/1.1, you get:

The trade-off is human readability. You cannot casually curl a gRPC endpoint. For service-to-service communication, that rarely matters.

Defining a Service

Everything starts with a .proto file. This is your contract.

Protos/weather.proto
syntax = "proto3";

option csharp_namespace = "WeatherService.Grpc";

package weather;

service WeatherForecaster {
  rpc GetForecast (ForecastRequest) returns (ForecastReply);
  rpc StreamForecasts (ForecastRequest) returns (stream ForecastReply);
}

message ForecastRequest {
  string city = 1;
  int32 days = 2;
}

message ForecastReply {
  string city = 1;
  string date = 2;
  int32 temperature_c = 3;
  string summary = 4;
}

The stream keyword on StreamForecasts makes the server push multiple ForecastReply messages over a single connection — ideal for real-time data.

Setting Up the Server

Create a new gRPC project or add the package to an existing ASP.NET Core application:

Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();

var app = builder.Build();
app.MapGrpcService<WeatherForecasterService>();
app.Run();

Then implement the service by inheriting from the generated base class:

Services/WeatherForecasterService.cs
public class WeatherForecasterService : WeatherForecaster.WeatherForecasterBase
{
    public override Task<ForecastReply> GetForecast(
        ForecastRequest request, ServerCallContext context)
    {
        var reply = new ForecastReply
        {
            City = request.City,
            Date = DateTime.UtcNow.ToString("yyyy-MM-dd"),
            TemperatureC = Random.Shared.Next(-5, 35),
            Summary = "Partly cloudy"
        };

        return Task.FromResult(reply);
    }

    public override async Task StreamForecasts(
        ForecastRequest request,
        IServerStreamWriter<ForecastReply> responseStream,
        ServerCallContext context)
    {
        for (var i = 0; i < request.Days; i++)
        {
            var reply = new ForecastReply
            {
                City = request.City,
                Date = DateTime.UtcNow.AddDays(i).ToString("yyyy-MM-dd"),
                TemperatureC = Random.Shared.Next(-5, 35),
                Summary = "Generated forecast"
            };

            await responseStream.WriteAsync(reply);
            await Task.Delay(500, context.CancellationToken);
        }
    }
}

The streaming method writes each forecast individually. The client receives them as they arrive rather than waiting for the entire batch.

Building the Client

On the client side, reference the same .proto file with GrpcServices="Client" and create a channel:

Example.cs
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new WeatherForecaster.WeatherForecasterClient(channel);

// Unary call
var reply = await client.GetForecastAsync(
    new ForecastRequest { City = "London", Days = 5 });
Console.WriteLine($"{reply.City}: {reply.TemperatureC}C - {reply.Summary}");

// Server streaming call
using var streamingCall = client.StreamForecasts(
    new ForecastRequest { City = "London", Days = 5 });

await foreach (var forecast in streamingCall.ResponseStream.ReadAllAsync())
{
    Console.WriteLine($"{forecast.Date}: {forecast.TemperatureC}C");
}

In production, register the client with dependency injection using AddGrpcClient<T>, which handles channel lifecycle and integrates with HttpClientFactory:

Example.cs
builder.Services
    .AddGrpcClient<WeatherForecaster.WeatherForecasterClient>(options =>
    {
        options.Address = new Uri("https://weather-service:5001");
    });

Error Handling

gRPC uses status codes rather than HTTP status codes. Throw an RpcException with an appropriate StatusCode:

Example.cs
if (string.IsNullOrEmpty(request.City))
{
    throw new RpcException(new Status(
        StatusCode.InvalidArgument, "City is required"));
}

Common status codes include NotFound, InvalidArgument, PermissionDenied, and Unavailable. These map cleanly to domain errors without the ambiguity of overloaded HTTP status codes.

When to Reach for gRPC

gRPC excels in service-to-service communication where both ends are under your control. It is less suited for browser clients (though gRPC-Web helps) or public APIs where discoverability and tooling around REST/OpenAPI are expected. If you are building internal microservices in .NET, gRPC deserves serious consideration — the performance gains and type safety pay for themselves quickly.