Dotnet-skills dotnet-grpc
Build or review gRPC services and clients in .NET with correct contract-first design, streaming behavior, transport assumptions, and backend service integration.
install
source · Clone the upstream repo
git clone https://github.com/managedcode/dotnet-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/managedcode/dotnet-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/catalog/Frameworks/gRPC/skills/dotnet-grpc" ~/.claude/skills/managedcode-dotnet-skills-dotnet-grpc && rm -rf "$T"
manifest:
catalog/Frameworks/gRPC/skills/dotnet-grpc/SKILL.mdsource content
gRPC for .NET
Trigger On
- building backend-to-backend RPC services or clients
- adding protobuf contracts, streaming calls, or interceptors
- deciding between gRPC, HTTP APIs, and SignalR
- optimizing gRPC performance and connection management
- implementing service-to-service communication in microservices
Documentation
- gRPC on .NET Overview
- Performance Best Practices with gRPC
- gRPC Client Factory
- gRPC Interceptors
- Call gRPC Services with .NET Client
References
- patterns.md - Detailed proto patterns, streaming implementations, interceptors, health checks, and load balancing
- anti-patterns.md - Common gRPC mistakes with explanations and corrections
Workflow
- Use gRPC where low-latency backend communication, strong contracts, or streaming are the real drivers.
- Treat
files as source of truth and keep generated code ownership clear..proto - Choose unary, server streaming, client streaming, or bidirectional streaming based on the interaction model, not by default.
- Do not use gRPC for broad browser-facing APIs unless the limitations and gRPC-Web tradeoffs are explicitly acceptable.
- Handle deadlines, cancellation, auth, and retry behavior explicitly on both server and client paths.
- Validate contract changes carefully because gRPC drift breaks callers fast.
Service Patterns
Basic Unary Service
// greeter.proto syntax = "proto3"; option csharp_namespace = "GrpcService"; package greet; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply); } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }
// GreeterService.cs public class GreeterService : Greeter.GreeterBase { private readonly ILogger<GreeterService> _logger; public GreeterService(ILogger<GreeterService> logger) { _logger = logger; } public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context) { _logger.LogInformation("Greeting {Name}", request.Name); return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" }); } }
Server Streaming
// In .proto file service DataStream { rpc StreamData (DataRequest) returns (stream DataChunk); } // Service implementation public override async Task StreamData( DataRequest request, IServerStreamWriter<DataChunk> responseStream, ServerCallContext context) { for (int i = 0; i < request.Count; i++) { // Check for cancellation to avoid wasted work if (context.CancellationToken.IsCancellationRequested) { _logger.LogInformation("Stream cancelled by client"); break; } await responseStream.WriteAsync(new DataChunk { Index = i, Data = await GetDataAsync(i) }); // Respect backpressure await Task.Delay(10, context.CancellationToken); } }
Bidirectional Streaming
// In .proto file service Chat { rpc ChatStream (stream ChatMessage) returns (stream ChatMessage); } // Service implementation public override async Task ChatStream( IAsyncStreamReader<ChatMessage> requestStream, IServerStreamWriter<ChatMessage> responseStream, ServerCallContext context) { await foreach (var message in requestStream.ReadAllAsync(context.CancellationToken)) { _logger.LogInformation("Received: {Message}", message.Text); // Echo back with transformation await responseStream.WriteAsync(new ChatMessage { Text = $"Echo: {message.Text}", Timestamp = Timestamp.FromDateTime(DateTime.UtcNow) }); } }
Client Patterns
Channel Reuse with Client Factory (Recommended)
// Program.cs - Register gRPC client with factory builder.Services.AddGrpcClient<Greeter.GreeterClient>(options => { options.Address = new Uri("https://localhost:5001"); }) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new SocketsHttpHandler { PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, KeepAlivePingDelay = TimeSpan.FromSeconds(60), KeepAlivePingTimeout = TimeSpan.FromSeconds(30), EnableMultipleHttp2Connections = true }; return handler; }) .AddInterceptor<LoggingInterceptor>(); // Usage in service public class MyService { private readonly Greeter.GreeterClient _client; public MyService(Greeter.GreeterClient client) { _client = client; } public async Task<string> GreetAsync(string name, CancellationToken ct) { // Always set deadlines var deadline = DateTime.UtcNow.AddSeconds(5); var response = await _client.SayHelloAsync( new HelloRequest { Name = name }, deadline: deadline, cancellationToken: ct); return response.Message; } }
Manual Channel Creation with Connection Options
// Reuse channels - expensive to create var channel = GrpcChannel.ForAddress("https://localhost:5001", new GrpcChannelOptions { HttpHandler = new SocketsHttpHandler { EnableMultipleHttp2Connections = true, PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, KeepAlivePingDelay = TimeSpan.FromSeconds(60), KeepAlivePingTimeout = TimeSpan.FromSeconds(30) }, MaxRetryAttempts = 3, ServiceConfig = new ServiceConfig { MethodConfigs = { new MethodConfig { Names = { MethodName.Default }, RetryPolicy = new RetryPolicy { MaxAttempts = 3, InitialBackoff = TimeSpan.FromSeconds(1), MaxBackoff = TimeSpan.FromSeconds(5), BackoffMultiplier = 1.5, RetryableStatusCodes = { StatusCode.Unavailable } } } } } }); // Create multiple clients from same channel var greeterClient = new Greeter.GreeterClient(channel); var orderClient = new Orders.OrdersClient(channel);
Consuming Server Streaming
public async Task ProcessStreamAsync(CancellationToken ct) { using var call = _client.StreamData(new DataRequest { Count = 100 }); try { await foreach (var chunk in call.ResponseStream.ReadAllAsync(ct)) { await ProcessChunkAsync(chunk); } } catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) { _logger.LogInformation("Stream cancelled"); } }
Bidirectional Streaming Client
public async Task ChatAsync(CancellationToken ct) { using var call = _client.ChatStream(); // Read responses in background var readTask = Task.Run(async () => { await foreach (var response in call.ResponseStream.ReadAllAsync(ct)) { Console.WriteLine($"Received: {response.Text}"); } }, ct); // Send messages foreach (var message in GetMessages()) { if (ct.IsCancellationRequested) break; await call.RequestStream.WriteAsync(new ChatMessage { Text = message }); } // Signal completion and wait for responses await call.RequestStream.CompleteAsync(); await readTask; }
Interceptor Patterns
Logging Interceptor
public class LoggingInterceptor : Interceptor { private readonly ILogger<LoggingInterceptor> _logger; public LoggingInterceptor(ILogger<LoggingInterceptor> logger) { _logger = logger; } public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation) { var sw = Stopwatch.StartNew(); var call = continuation(request, context); return new AsyncUnaryCall<TResponse>( HandleResponse(call.ResponseAsync, context.Method.FullName, sw), call.ResponseHeadersAsync, call.GetStatus, call.GetTrailers, call.Dispose); } private async Task<TResponse> HandleResponse<TResponse>( Task<TResponse> responseTask, string method, Stopwatch sw) { try { var response = await responseTask; _logger.LogInformation("{Method} completed in {Elapsed}ms", method, sw.ElapsedMilliseconds); return response; } catch (RpcException ex) { _logger.LogError(ex, "{Method} failed with {Status} in {Elapsed}ms", method, ex.StatusCode, sw.ElapsedMilliseconds); throw; } } }
Server Exception Interceptor
public class ExceptionInterceptor : Interceptor { private readonly ILogger<ExceptionInterceptor> _logger; public ExceptionInterceptor(ILogger<ExceptionInterceptor> logger) { _logger = logger; } public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>( TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation) { try { return await continuation(request, context); } catch (RpcException) { throw; // Let gRPC exceptions pass through } catch (Exception ex) { _logger.LogError(ex, "Unhandled exception in {Method}", context.Method); throw new RpcException(new Status(StatusCode.Internal, "An error occurred")); } } }
Server Configuration
var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(options => { options.EnableDetailedErrors = builder.Environment.IsDevelopment(); options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB options.MaxSendMessageSize = 4 * 1024 * 1024; options.Interceptors.Add<ExceptionInterceptor>(); }); // Configure Kestrel for HTTP/2 builder.WebHost.ConfigureKestrel(options => { options.Limits.Http2.MaxStreamsPerConnection = 100; options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024; // 1 MB options.Limits.Http2.InitialStreamWindowSize = 512 * 1024; // 512 KB }); var app = builder.Build(); app.MapGrpcService<GreeterService>(); app.MapGet("/", () => "gRPC endpoint"); app.Run();
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Creating new channel per call | Connection overhead kills performance | Reuse channels, use client factory |
| Missing deadlines | Calls can hang indefinitely | Always set deadline on client calls |
| Ignoring cancellation in streams | Wastes server resources | Check periodically |
| Using gRPC for browser clients | Limited browser support | Use gRPC-Web with Envoy or REST |
| Large messages (>1MB) | Memory pressure, LOH allocations | Stream in chunks or use HTTP for files |
Sync blocking () | Thread pool starvation | Use async/await consistently |
| Swallowing exceptions in interceptors | Hides failures from clients | Rethrow or convert to |
| Not aligning client/server deadlines | Mismatched timeout behavior | Coordinate deadline budgets |
Blocking with interceptor | Interceptors are method-specific | Implement both interceptor methods |
| Missing retry configuration | Single failures cause request failure | Configure retry policy on channel |
Best Practices
Channel and Connection Management
- Reuse channels across the application lifetime
- Enable multiple HTTP/2 connections with
EnableMultipleHttp2Connections = true - Configure keep-alive pings to maintain connections through idle periods
- Use client factory (
) for centralized channel managementAddGrpcClient - Set
to prevent premature connection closurePooledConnectionIdleTimeout
Deadlines and Cancellation
- Always set deadlines on client calls to prevent indefinite hangs
- Propagate cancellation through the call chain
- Check cancellation in long-running streaming handlers
- Coordinate deadline budgets between client and server
Performance
- Avoid large messages (>85KB to stay off Large Object Heap)
- Use streaming for large data transfers instead of single messages
- Enable server GC for high-throughput client applications
- Complete streams gracefully to allow connection reuse
- Dispose streaming calls when done to release resources
Error Handling
- Use appropriate status codes (not just
for everything)Internal - Let
propagate through interceptorsRpcException - Convert domain exceptions to gRPC status codes at service boundaries
- Include meaningful error details in development mode only
Contract Design
- Use custom objects in proto to enable backward-compatible evolution
- Reserve field numbers you remove instead of reusing
- Version service names for breaking changes (
)GreeterV2 - Keep proto files as the single source of truth
Observability
- Add logging interceptors for request/response timing
- Track error rates by status code
- Monitor connection pool health and reuse rates
- Integrate with distributed tracing (OpenTelemetry)
Deliver
- stable protobuf contracts and generated code flow
- service and client code that match the RPC shape
- tests or smoke checks for serialization and call behavior
- proper deadline and cancellation handling
Validate
- gRPC is chosen for the right problem
- streaming semantics and deadlines are explicit
- browser constraints are acknowledged when relevant
- channels are reused appropriately
- error handling converts exceptions to proper status codes
- interceptors are ordered correctly (logging before auth before validation)