gRPC in .NET: Build Fast, Typed Service-to-Service APIs
Build gRPC services in ASP.NET Core. Covers Protocol Buffers, service definitions, unary and streaming RPCs, authentication, client generation, error handling, and when to use gRPC vs REST.
gRPC vs REST: When to Choose Which
gRPC is not a replacement for REST — it solves a different problem.
| | REST (JSON/HTTP) | gRPC (Protobuf/HTTP2) | |---|---|---| | Human-readable | Yes | No (binary) | | Browser support | Native | Requires proxy (gRPC-Web) | | Payload size | ~3–5x larger | Compact binary | | Speed | Baseline | ~5–10x faster serialization | | Typing | Optional (OpenAPI) | Strongly enforced by schema | | Streaming | Polling or WebSockets | Native (unary + 3 streaming modes) | | Use case | Public APIs, browser clients | Internal service-to-service |
Use gRPC when: internal microservice communication, high throughput, tight latency budget, real-time streaming, polyglot environments (proto schema is language-neutral).
Use REST when: public APIs, browser-facing, third-party integrations, simple CRUD.
Setup
dotnet new web -n GrpcDemo
dotnet add package Grpc.AspNetCore<!-- .csproj — required for proto compilation -->
<ItemGroup>
<Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>Protocol Buffers (Protobuf)
The IDL (interface definition language) for gRPC. Defines services and messages. Language-neutral — generates C#, Go, Python, Java from the same .proto file.
// Protos/orders.proto
syntax = "proto3";
option csharp_namespace = "GrpcDemo";
package orders;
// Service definition
service OrderService {
rpc GetOrder (GetOrderRequest) returns (OrderResponse); // Unary
rpc ListOrders (ListOrdersRequest) returns (stream OrderResponse); // Server streaming
rpc CreateOrders (stream CreateOrderRequest) returns (OrderSummary); // Client streaming
rpc ProcessOrders (stream CreateOrderRequest) returns (stream OrderResponse); // Bidirectional
}
// Messages
message GetOrderRequest {
string order_id = 1;
}
message OrderResponse {
string id = 1;
string customer_id = 2;
double amount = 3;
string status = 4;
string created_at = 5;
}
message ListOrdersRequest {
string customer_id = 1;
int32 page_size = 2;
}
message CreateOrderRequest {
string customer_id = 1;
repeated OrderItem items = 2;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double unit_price = 3;
}
message OrderSummary {
int32 created_count = 1;
double total_amount = 2;
}Field numbers (1, 2, 3…) are the wire format identifiers — never change them in production. Adding new fields is backwards compatible; removing or renumbering breaks existing clients.
Implementing the Server
// Services/OrderServiceImpl.cs
using Grpc.Core;
public class OrderServiceImpl : OrderService.OrderServiceBase
{
private readonly IOrderRepository _repo;
private readonly ILogger<OrderServiceImpl> _logger;
public OrderServiceImpl(IOrderRepository repo, ILogger<OrderServiceImpl> logger)
{
_repo = repo;
_logger = logger;
}
// Unary RPC
public override async Task<OrderResponse> GetOrder(
GetOrderRequest request,
ServerCallContext context)
{
var order = await _repo.GetByIdAsync(request.OrderId, context.CancellationToken);
if (order is null)
throw new RpcException(new Status(StatusCode.NotFound,
$"Order {request.OrderId} not found"));
return MapToResponse(order);
}
// Server streaming — send multiple responses to one request
public override async Task ListOrders(
ListOrdersRequest request,
IServerStreamWriter<OrderResponse> responseStream,
ServerCallContext context)
{
var orders = _repo.StreamByCustomerAsync(request.CustomerId, context.CancellationToken);
await foreach (var order in orders.WithCancellation(context.CancellationToken))
{
await responseStream.WriteAsync(MapToResponse(order));
}
}
// Client streaming — receive multiple requests, return one response
public override async Task<OrderSummary> CreateOrders(
IAsyncStreamReader<CreateOrderRequest> requestStream,
ServerCallContext context)
{
int count = 0;
double total = 0;
await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
{
var order = await _repo.CreateAsync(request, context.CancellationToken);
count++;
total += order.Amount;
}
return new OrderSummary { CreatedCount = count, TotalAmount = total };
}
private static OrderResponse MapToResponse(Order o) => new()
{
Id = o.Id,
CustomerId = o.CustomerId,
Amount = o.Amount,
Status = o.Status,
CreatedAt = o.CreatedAt.ToString("O")
};
}Register in Program.cs
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16 MB
options.MaxSendMessageSize = 16 * 1024 * 1024;
});
app.MapGrpcService<OrderServiceImpl>();
app.MapGrpcReflectionService(); // enables tools like grpcurl to inspect servicesgRPC Client
# Client project
dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools<!-- Client .csproj -->
<ItemGroup>
<Protobuf Include="Protos\orders.proto" GrpcServices="Client" />
</ItemGroup>Typed Client with IHttpClientFactory
// Register
builder.Services.AddGrpcClient<OrderService.OrderServiceClient>(options =>
{
options.Address = new Uri(builder.Configuration["Services:OrderService"]!);
})
.ConfigureChannel(o =>
{
o.HttpHandler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
EnableMultipleHttp2Connections = true
};
});
// Use in a service
public class OrderApiClient
{
private readonly OrderService.OrderServiceClient _client;
public OrderApiClient(OrderService.OrderServiceClient client) => _client = client;
public async Task<OrderResponse> GetOrderAsync(string id, CancellationToken ct)
{
try
{
return await _client.GetOrderAsync(
new GetOrderRequest { OrderId = id },
cancellationToken: ct);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return null!;
}
}
public async IAsyncEnumerable<OrderResponse> ListOrdersAsync(
string customerId,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var call = _client.ListOrders(
new ListOrdersRequest { CustomerId = customerId },
cancellationToken: ct);
await foreach (var order in call.ResponseStream.ReadAllAsync(ct))
yield return order;
}
}Error Handling
gRPC has its own status codes. Map domain errors to appropriate status codes:
// Server — throw RpcException with appropriate status
throw new RpcException(new Status(StatusCode.NotFound, "Order not found"));
throw new RpcException(new Status(StatusCode.InvalidArgument, "OrderId is required"));
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid token"));
throw new RpcException(new Status(StatusCode.PermissionDenied,"Not authorised"));
throw new RpcException(new Status(StatusCode.AlreadyExists, "Order already exists"));
throw new RpcException(new Status(StatusCode.Internal, "Unexpected error"));Global 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; // already a gRPC exception, let it through
}
catch (NotFoundException ex)
{
throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
}
catch (ValidationException ex)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled error in gRPC call");
throw new RpcException(new Status(StatusCode.Internal, "An error occurred"));
}
}
}
// Register
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<ExceptionInterceptor>();
});Authentication
JWT Bearer on gRPC
// Server
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* same JWT config as REST */ });
app.UseAuthentication();
app.UseAuthorization();
// Protect a service
[Authorize]
public class OrderServiceImpl : OrderService.OrderServiceBase { }
// Or per method
public override async Task<OrderResponse> GetOrder(
GetOrderRequest request, ServerCallContext context)
{
context.GetHttpContext().User.Identity?.IsAuthenticated; // check auth
}Client — Add Auth Header
var token = await _tokenService.GetTokenAsync();
var headers = new Metadata { { "Authorization", $"Bearer {token}" } };
var response = await _client.GetOrderAsync(request, headers, cancellationToken: ct);Per-Call Credentials
// Automatic token injection via CallCredentials
var callCredentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{
var token = await _tokenService.GetTokenAsync();
metadata.Add("Authorization", $"Bearer {token}");
});
var channel = GrpcChannel.ForAddress("https://orders.internal", new GrpcChannelOptions
{
Credentials = ChannelCredentials.Create(new SslCredentials(), callCredentials)
});Deadlines and Cancellation
Always set deadlines on client calls:
// Set a 5-second deadline
var deadline = DateTime.UtcNow.AddSeconds(5);
var response = await _client.GetOrderAsync(request,
deadline: deadline,
cancellationToken: ct);On the server, context.CancellationToken is cancelled when the deadline is reached or the client disconnects. Pass it to all downstream calls.
gRPC Health Checks
dotnet add package Grpc.HealthCheckbuilder.Services.AddGrpcHealthChecks()
.AddCheck("orders-db", () =>
{
// Check DB connectivity
return HealthCheckResult.Healthy();
});
app.MapGrpcHealthChecksService();Reflection (for tooling)
dotnet add package Grpc.AspNetCore.Server.Reflectionapp.MapGrpcReflectionService();Now you can use grpcurl or tools like Postman to explore your services without sharing .proto files:
# List services
grpcurl -plaintext localhost:5000 list
# Describe a method
grpcurl -plaintext localhost:5000 describe orders.OrderService.GetOrder
# Call a method
grpcurl -plaintext -d '{"order_id":"abc-123"}' localhost:5000 orders.OrderService/GetOrderInterview Questions
Q: When would you choose gRPC over REST for an internal API? When serialization performance matters (binary Protobuf is ~5x smaller and faster than JSON), when you need streaming (server push, bidirectional), when strict typing across services is important, or when you're building a polyglot environment (generate clients in Go, Python, Java from the same .proto).
Q: What is Protocol Buffers and why is field number order important? A binary serialization format. Fields are identified by their number, not their name — this is what makes the format compact. Field numbers must never change once published because clients decode using the number. You can add new fields safely; removing or renumbering breaks existing clients.
Q: What are the four gRPC call types? Unary (one request, one response), server streaming (one request, many responses), client streaming (many requests, one response), bidirectional streaming (many requests, many responses). Streaming uses a single HTTP/2 connection.
Q: How do you handle errors in gRPC?
Throw RpcException with a StatusCode and message. Map domain exceptions in a server-side Interceptor. Common codes: NotFound, InvalidArgument, Unauthenticated, PermissionDenied, AlreadyExists, Internal.
Q: What is a deadline in gRPC and why is it better than a timeout? A deadline is an absolute time by which the call must complete. It propagates automatically to downstream services — if service A calls B and A's deadline is 500ms away, B receives a deadline 500ms in the future. A timeout is relative and doesn't propagate. Deadlines prevent cascading slow calls from piling up.
gRPC Knowledge Check
5 questions · Test what you just learned · Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.