Steven's Knowledge

gRPC

Contract-first services in .NET with Protocol Buffers -- the four call types, streaming, interceptors, and when gRPC beats REST for service-to-service traffic

gRPC

gRPC is a high-performance, contract-first RPC framework: Protocol Buffers over HTTP/2, with binary serialization and generated, strongly-typed clients and servers on both ends. You describe the service once in a .proto file, and the toolchain produces the server base class you implement and the client stub you call -- no hand-written DTOs, no hand-rolled HTTP plumbing, no ambiguity about the shape of a request. .NET has excellent first-party support through Grpc.AspNetCore, which plugs the gRPC server straight into the same WebApplicationBuilder and middleware pipeline every other .NET service uses.

The sweet spot is internal, service-to-service traffic where you control both ends and you want low latency, streaming, and a strict contract that the compiler enforces. It is a poor fit for public, browser-facing APIs: browsers cannot speak raw gRPC without the gRPC-Web shim, the binary wire format is not human-readable, and the ecosystem of caches, proxies, and curl-it-from-the-terminal debugging that surrounds REST simply is not there. Reach for gRPC inside your system; reach for REST at its edges.

Contract-First with .proto

The .proto file is the source of truth. It is not documentation that drifts from the code -- it is the code, the single artifact both the producer and every consumer compile against. The .NET build (via Grpc.Tools) reads it and generates the server base classes and client stubs as part of compilation, so a change to the contract surfaces as a compile error on both sides rather than a runtime surprise.

syntax = "proto3";
option csharp_namespace = "Orders.Grpc";
package orders;

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc StreamOrders (StreamOrdersRequest) returns (stream Order);
}

message GetOrderRequest { string id = 1; }
message Order {
  string id = 1;
  string customer_id = 2;
  double amount = 3;
}

The numbers after each field are the load-bearing part. Those are field numbers -- the wire identity of each field, the tag that actually goes onto the byte stream. The field name is for humans and code generation; the number is what serialization reads and writes. You never reuse a number for a different field and you never renumber an existing one, because an old client and a new server must agree on what 2 means even when one of them has never heard of customer_id. proto3 also has no concept of "field not set" for scalars -- an absent string deserializes to "", an absent double to 0, an absent message to null -- so you design around defaults rather than nullability, and the contract, not a parallel set of C# classes, is what both sides genuinely share.

Implementing the Service

On the server you override the generated base class -- OrderService.OrderServiceBase -- and fill in the methods. Each method receives the request message and a ServerCallContext, and returns the response message. Dependency injection works exactly as it does everywhere else in .NET: the service is registered in the container, and constructor injection hands it whatever it needs.

public class OrderServiceImpl(IOrderStore store) : OrderService.OrderServiceBase
{
    public override async Task<Order> GetOrder(GetOrderRequest request, ServerCallContext context)
    {
        var order = await store.GetAsync(request.Id, context.CancellationToken);
        if (order is null)
            throw new RpcException(new Status(StatusCode.NotFound, "Order not found"));
        return order.ToProto();
    }
}

// Program.cs
builder.Services.AddGrpc();
app.MapGrpcService<OrderServiceImpl>();

Two details carry the weight here. ServerCallContext.CancellationToken is the request's lifetime signal -- when the caller hangs up, deadlines elapse, or the host shuts down, that token cancels, and threading it into every downstream await is what lets work stop instead of running on for a client that has already given up. And errors travel as gRPC status codes, not HTTP codes: you throw an RpcException wrapping a Status with a StatusCode like NotFound, InvalidArgument, or PermissionDenied, and the framework maps that onto the wire and back into a typed exception on the client. There is no Results.NotFound() here -- the status code is the protocol's error channel.

The Four Call Types

gRPC is not just request/response. Because it runs over HTTP/2, a single call can stream messages in one or both directions, and the contract declares which shape you mean by where the stream keyword appears. There are four:

Unary is the familiar one request, one response -- the default, and the right choice for the overwhelming majority of calls. Server streaming sends one request and receives a stream of responses, ideal for a server pushing a result set, a feed, or progress updates as they become available. Client streaming sends a stream of requests and receives one response, which fits bulk upload or aggregating many events into a single summary. Bidirectional streaming opens an independent stream in each direction at once, the right tool for long-lived, chatty exchanges like a live sync or a back-and-forth protocol.

A server-streaming handler writes responses through an IServerStreamWriter<Order> instead of returning a value, calling WriteAsync once per message and honoring the cancellation token so the loop stops the moment the client disconnects.

public override async Task StreamOrders(
    StreamOrdersRequest request,
    IServerStreamWriter<Order> responseStream,
    ServerCallContext context)
{
    await foreach (var order in store.QueryAsync(request, context.CancellationToken))
    {
        await responseStream.WriteAsync(order);
    }
}

Bidirectional streaming hands you both an IAsyncStreamReader to consume incoming messages and an IServerStreamWriter to produce outgoing ones, and the two run concurrently -- you typically await foreach over the reader while writing replies from the same or a separate task, with no implied lockstep between the directions.

Calling from a Client

On the client side you do not construct an HttpClient and serialize anything by hand. You register the generated typed client with AddGrpcClient, point it at an address, and inject it. The stub exposes one method per RPC -- GetOrderAsync, StreamOrders, and so on -- already typed to the proto messages.

builder.Services.AddGrpcClient<OrderService.OrderServiceClient>(o =>
{
    o.Address = new Uri("https://orders.internal:5001");
});

// usage
var reply = await _client.GetOrderAsync(new GetOrderRequest { Id = "order-1" });

Two features are worth wiring in from the start. Deadlines are gRPC's first-class timeout: you pass CallOptions with a deadline (new CallOptions(deadline: DateTime.UtcNow.AddSeconds(5))), and unlike a fire-and-forget client-side timeout, the deadline is propagated across hops -- a service that calls another service forwards the remaining budget, so the whole call graph shares one clock and nothing runs past the point where the original caller has given up. Metadata is gRPC's headers: a key/value collection you attach to a call for auth tokens, correlation IDs, or tenant hints, readable on the server through ServerCallContext.RequestHeaders and the natural place for cross-cutting context to ride along with the request.

Interceptors

Interceptors are gRPC's cross-cutting hook -- the equivalent of ASP.NET Core middleware or MVC filters, but for RPCs. They sit around every call on either side, which makes them the right home for logging, authentication, retries, and metrics: the concerns you want applied uniformly without copying code into every method. A server interceptor derives from Interceptor and overrides the handler for the call type it cares about.

public class LoggingInterceptor(ILogger<LoggingInterceptor> logger) : Interceptor
{
    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        logger.LogInformation("Handling {Method}", context.Method);
        try
        {
            return await continuation(request, context);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed {Method}", context.Method);
            throw;
        }
    }
}

The continuation delegate is the rest of the pipeline -- call it to proceed, wrap it to add behavior before and after, and re-throw to let failures propagate. Interceptors compose: register several and they nest around the call in order, just as middleware does, and the same Interceptor base type works on the client side (AddInterceptor on the client builder) for client-side logging, header injection, or retry policies.

gRPC vs REST

These are not rivals so much as tools for different jobs, and the honest comparison is about where each one's strengths land. gRPC wins on raw performance -- binary Protocol Buffers are smaller and faster to serialize than JSON, and HTTP/2 multiplexes many calls over one connection without head-of-line blocking. It wins on streaming, which REST has no native answer for, and on contract enforcement: the .proto is compiled into both ends, and code generation across languages makes it a strong fit for polyglot back-ends where a Go service and a C# service must agree precisely.

REST/JSON wins on reach and friendliness. Browsers speak it natively, anyone can read a JSON payload or replay a request with curl, HTTP caching and CDNs understand it, and the entire web's tooling assumes it. gRPC closes some of that gap -- gRPC-Web lets browsers call gRPC services through a proxy, and JSON transcoding can expose the same service definition as a REST/JSON API when you genuinely need both surfaces from one implementation -- but neither makes gRPC the natural default at a public edge. The rule of thumb is directional: prefer gRPC for east-west traffic, service-to-service inside your system, and REST for north-south traffic, the public and browser-facing edge.

Sharp Edges

The contract rules are absolute, and violating them fails silently rather than loudly. Never reuse a field number for a new meaning and never renumber an existing field -- the wire format keys on the number, so a mismatch deserializes one field's bytes into another with no error. Breaking changes hide in field types and numbers, not names: rename freely, but changing a field's type or its number breaks every peer that has not been rebuilt in lockstep. Large messages are a trap -- gRPC has a default receive limit and big payloads pressure memory on both ends, so stream them or chunk them rather than stuffing a megabyte into one message.

The load-balancing gotcha catches almost everyone once. gRPC rides long-lived HTTP/2 connections, and a single connection carries every multiplexed call, so an L4 (connection-level) load balancer pins all of a client's traffic to one backend and your other replicas sit idle. You need an L7 proxy that balances individual HTTP/2 streams (Envoy, Linkerd, a service mesh) or client-side load balancing that the channel performs itself. Finally, deadlines must actually be set: a call with no deadline can hang indefinitely if the server stalls, taking a thread and a connection slot with it, so treat every cross-service call as deadline-bearing and propagate the budget down the chain.

On this page