Stéphane De Todaro — tech lead

@super-dev.app · Tech-lead
Active since 2017

Technical lead and full-stack architect, freelance since 2019. I design, industrialize and operate business platforms on Azure, and I publish open-source software engines.

Back to articles
.NET
.NET

.NET microservices with gRPC

Article 4 of 5 — Modern .NET
Minimal APIs, CQRS, gRPC, source generators: clean, testable .NET 8 without the ceremony.

Entre deux microservices, exchanging JSON over HTTP/1.1 costs more than it seems: verbose serialization, no contract the compiler can check, a connection reopened on every request. gRPC targets this exact case with binary Protobuf over HTTP/2, a shared contract and generated code on both sides. In .NET, the Grpc.AspNetCore stack makes it a first-class integration, with no third-party dependency to bolt on.

The .proto contract, single source of truth

Everything starts from a .proto file that describes the messages and the service independently of the language. This is the contract, and neither the client nor the server hand-copy its types. The proto3 syntax fits in a few words: a service , some rpc , message s whose every field carries a number.

TypeScript
1syntax = "proto3";
2
3option csharp_namespace = "Catalog.Pricing";
4
5service Pricing {
6 // Unary: one request, one reply
7 rpc GetQuote (QuoteRequest) returns (QuoteReply);
8
9 // Server streaming: one request, a stream of replies
10 rpc WatchPrices (WatchRequest) returns (stream PriceTick);
11
12 // Client streaming: a stream of requests, one reply
13 rpc BulkImport (stream PriceUpdate) returns (ImportSummary);
14
15 // Bidirectional: two independent streams over one call
16 rpc Negotiate (stream Offer) returns (stream Counter);
17}
18
19message QuoteRequest {
20 string sku = 1;
21 int32 quantity = 2;
22}
23
24message QuoteReply {
25 int64 unit_price_cents = 1;
26}

These numbers are the fields' identity on the wire: one is never reused, even after removing the field that carried it. This is what makes an old binary readable by a newer schema, backward compatibility by construction. The stream keyword, placed on the input, the output, or both, describes the four call shapes.

In the .csproj , one line is enough: <Protobuf Include="Protos/pricing.proto" GrpcServices="Both" /> . At compile time, Grpc.Tools runs protoc and generates the message classes, the server base class Pricing.PricingBase and the client Pricing.PricingClient . The proto's snake_case becomes PascalCase in C#, unit_price_cents reads as UnitPriceCents . No DTO to write, none to keep in sync twice over.

Typed server and client

The server derives from the generated class and overrides the method. No routing to wire up, no manual deserialization: a strongly-typed message comes in, a typed message goes back out.

C#
1// Server: derive from the generated base, override the method
2public sealed class PricingService(IPriceBook book) : Pricing.PricingBase
3{
4 public override async Task<QuoteReply> GetQuote(
5 QuoteRequest request, ServerCallContext context)
6 {
7 var unit = await book.LookupAsync(request.Sku, context.CancellationToken);
8
9 return new QuoteReply { UnitPriceCents = unit * request.Quantity };
10 }
11}
12
13// Client: injected, called like a local method, with a 2s deadline
14var reply = await client.GetQuoteAsync(
15 new QuoteRequest { Sku = "A-17", Quantity = 4 },
16 deadline: DateTime.UtcNow.AddSeconds(2),
17 cancellationToken: ct);

The server wiring fits in two lines in Program.cs : builder.Services.AddGrpc(); then app.MapGrpcService<PricingService>(); . On the calling side, Grpc.Net.Client provides the channel, and registration goes through dependency injection, AddGrpcClient<Pricing.PricingClient>(o => o.Address = new Uri("https://pricing:443")) . The injected client is called like a local method; underneath, a single HTTP/2 connection multiplexes concurrent calls rather than reopening one per request.

The four call shapes

The unary call (one request, one reply) covers most of the traffic and closely resembles a REST call. The other three shapes exploit HTTP/2's ability to keep a stream open, something REST doesn't do natively.

Server streaming pushes a sequence of messages over a single request: the server writes to an IServerStreamWriter<T> , the client iterates with await foreach .

C#
1// Server pushes ticks until the caller stops listening
2public override async Task WatchPrices(
3 WatchRequest request,
4 IServerStreamWriter<PriceTick> responses,
5 ServerCallContext context)
6{
7 await foreach (var tick in book.Ticks(request.Sku, context.CancellationToken))
8 {
9 await responses.WriteAsync(tick);
10 }
11}
12
13// Client consumes the stream with await foreach
14using var call = client.WatchPrices(new WatchRequest { Sku = "A-17" });
15await foreach (var tick in call.ResponseStream.ReadAllAsync(ct))
16{
17 Render(tick);
18}

Client streaming reverses the direction: the client sends a stream, the server reads it via an IAsyncStreamReader<T> and returns a single summary, handy for a bulk import. The bidirectional shape opens two independent streams over the same call, for telemetry or a negotiation loop, with no polling or WebSocket to hack together.

Deadlines, cancellation and statuses

A network call with no time bound is an incident waiting to happen. gRPC carries the notion of a deadline in the protocol itself: an absolute instant, not a duration, sent along with the call. On the client, it's passed to the call, deadline: DateTime.UtcNow.AddSeconds(2) .

When it expires, the server's context.CancellationToken fires and the client receives an RpcException with status DeadlineExceeded . This is nothing like a purely client-side timeout, which gives up waiting but leaves the server working into the void. Passing this token down to downstream calls, a database or the next service, propagates the abandonment through the whole chain.

gRPC also defines a closed set of status codes : NotFound , InvalidArgument , PermissionDenied , Unavailable , Unauthenticated . The server signals a business failure by throwing throw new RpcException(new Status(StatusCode.NotFound, "unknown sku")) , and the client branches on it with catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable) . Where a REST 500 is a catch-all whose meaning has to be guessed, the gRPC status is typed and testable.

Interceptors for cross-cutting concerns

Logging, metrics, authentication, retries: these concerns come up on every call. An interceptor factors them out by wrapping the handlers, the equivalent of a middleware for the gRPC pipeline. You derive from Interceptor and override the desired hook, here the unary call on the server side.

C#
1public sealed class LoggingInterceptor(ILogger<LoggingInterceptor> log) : Interceptor
2{
3 public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
4 TRequest request,
5 ServerCallContext context,
6 UnaryServerMethod<TRequest, TResponse> continuation)
7 {
8 var sw = Stopwatch.StartNew();
9 try
10 {
11 return await continuation(request, context);
12 }
13 catch (RpcException ex)
14 {
15 log.LogWarning("{Method} failed with {Status}", context.Method, ex.StatusCode);
16 throw;
17 }
18 finally
19 {
20 log.LogInformation("{Method} took {Elapsed}ms", context.Method, sw.ElapsedMilliseconds);
21 }
22 }
23}

It's registered once, AddGrpc(o => o.Interceptors.Add<LoggingInterceptor>()) , and it covers all services. The same mechanism exists on the client side via .AddInterceptor<T>() , to attach an authentication token or a retry policy without polluting every call.

gRPC or REST: choosing knowingly

gRPC isn't universal. A contract the compiler checks and a compact binary over HTTP/2 make it suited first to internal , service-to-service traffic, where you control both ends of the channel. Full-duplex streaming reinforces this positioning.

Its limits are concrete. A browser doesn't speak gRPC natively: it can't access the HTTP/2 trailers the protocol depends on, and you have to go through gRPC-Web with a proxy. The binary isn't human-readable in logs, and debugging calls for dedicated tools.

For a public API exposed to third parties, REST/JSON often remains the better choice: readable in a browser and cacheable at the HTTP level. The gRPC for .NET guide details this division of roles.

The healthy split: REST as the public front, gRPC on the inside. The .proto file then becomes the formal boundary between your services, a versioned and shared boundary that the compiler checks on both ends.
Stéphane De Todaro — super-dev.app
// More in .NET
Externalizing SQL in EF Core migrations: procedures and views as versioned files
Aug 14, 2026 • 8 min
Orchestrating without a database: a ProcessAPI / SystemAPI split in .NET
Aug 14, 2026 • 10 min
{{ }}
Interpret, don't compile: a template engine for .NET
Jul 30, 2026 • 5 min