gRPC Services
Define a contract in .proto, implement the generated service base with Grpc.AspNetCore, return the right status codes and honour deadlines.
builder.Services.AddGrpc();
app.MapGrpcService<OrdersService>();
class OrdersService : Orders.OrdersBase {
public override Task<OrderReply> GetOrder(GetOrderRequest r, ServerCallContext ctx) }gRPC is contract-first RPC over HTTP/2. You describe services and messages in a .proto file, and Grpc.Tools runs protoc during the build to generate message classes plus, per service, an abstract base class (Orders.OrdersBase) and a client (Orders.OrdersClient). Messages travel as Protocol Buffers binary, and each call is one HTTP/2 stream on a shared connection.
Wiring a service
Reference Grpc.AspNetCore 2.84.0 (it brings Grpc.Tools and Google.Protobuf), add <Protobuf Include="Protos\orders.proto" GrpcServices="Server" /> to the project, then call builder.Services.AddGrpc() and app.MapGrpcService<OrdersService>(). Each method is served at /{package}.{Service}/{Method}, for example /shop.orders.v1.Orders/GetOrder. Methods you do not override answer Unimplemented. The service is created per call, like a controller, so injecting a scoped DbContext is fine and fields never hold state between calls. gRPC methods are ordinary endpoints and are anonymous until you call .RequireAuthorization() on MapGrpcService or put [Authorize] on the service; the auth middleware then answers 401 or 403, which the client reports as Unauthenticated or PermissionDenied.
HTTP/2 is not optional
Kestrel's default protocols are Http1AndHttp2, but HTTP/2 is only chosen through TLS ALPN. On a plaintext port, which is what a container behind an ingress usually exposes, the connection is HTTP/1.1 and every gRPC call fails with "Response protocol downgraded to HTTP/1.1". Give gRPC its own cleartext HTTP/2 (h2c) endpoint with Protocols = Http2, next to the HTTP/1.1 port that serves REST and health probes.
Status codes, not HTTP codes
The HTTP status of a gRPC response is 200; the outcome travels in the grpc-status and grpc-message trailers. Throw RpcException to choose it:
| Situation | StatusCode |
|---|---|
| A request field is invalid | InvalidArgument |
| Order does not exist | NotFound |
| Order already shipped, cannot cancel | FailedPrecondition |
| No or bad credentials / not allowed | Unauthenticated / PermissionDenied |
| Dependency down, safe to retry | Unavailable |
| Unhandled exception | Unknown |
An unhandled exception reaches the client as Unknown with "Exception was thrown by handler." EnableDetailedErrors adds the exception message; keep it to Development. Client retry policies normally retry only Unavailable, so returning it for a validation error makes every client retry a request that can never succeed.
Deadlines
A client's deadline arrives in the grpc-timeout header. context.Deadline exposes it (DateTime.MaxValue when none was sent) and context.CancellationToken fires when it passes or the client cancels. Pass that token into every query and outgoing call, or the server keeps working on answers nobody will read; the client sees DeadlineExceeded either way.
Evolving the contract
- Field numbers are the wire format: never renumber or reuse one; mark removed fields
reserved. - proto3 has no null. An unset
int64reads as 0, so useoptionalwhere absence matters, and make every enum start with..._UNSPECIFIED = 0. - There is no decimal: send money as
int64minor units plus a currency code. Timestamp.FromDateTimethrows unless theDateTimeis UTC.
Incoming messages are limited to 4 MB (MaxReceiveMessageSize); larger ones fail with ResourceExhausted. Page or stream big results instead of raising the limit.
Example
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Shop.Orders.V1;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ListenAnyIP(8080, listen => listen.Protocols = HttpProtocols.Http1); // REST and health probes
kestrel.ListenAnyIP(8081, listen => listen.Protocols = HttpProtocols.Http2); // gRPC over cleartext HTTP/2
});
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 1024 * 1024;
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = "orders-grpc";
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Without this, anyone who can reach port 8081 can cancel any order.
app.MapGrpcService<OrdersService>().RequireAuthorization();
app.MapGet("/healthz", () => "ok");
app.Run();
public sealed class OrdersService(IOrderRepository orders, ILogger<OrdersService> logger) : Orders.OrdersBase
{
public override async Task<OrderReply> GetOrder(GetOrderRequest request, ServerCallContext context)
{
if (request.OrderId <= 0)
throw new RpcException(new Status(StatusCode.InvalidArgument, "order_id must be greater than zero"));
var order = await orders.FindAsync(request.OrderId, context.CancellationToken)
?? throw new RpcException(new Status(StatusCode.NotFound, $"Order {request.OrderId} was not found"));
return ToReply(order);
}
public override async Task<OrderReply> CancelOrder(CancelOrderRequest request, ServerCallContext context)
{
// With under 100 ms left the caller will give up before the write commits; refuse the work.
if (context.Deadline - DateTime.UtcNow < TimeSpan.FromMilliseconds(100))
throw new RpcException(new Status(StatusCode.DeadlineExceeded, "Not enough time left to cancel the order"));
var order = await orders.FindAsync(request.OrderId, context.CancellationToken)
?? throw new RpcException(new Status(StatusCode.NotFound, $"Order {request.OrderId} was not found"));
if (order.State == OrderState.Shipped)
throw new RpcException(new Status(StatusCode.FailedPrecondition, $"Order {order.Id} has already shipped"));
var cancelled = order with { State = OrderState.Cancelled };
await orders.SaveAsync(cancelled, context.CancellationToken);
logger.LogInformation("Order {OrderId} cancelled by {Peer}: {Reason}", order.Id, context.Peer, request.Reason);
return ToReply(cancelled);
}
private static OrderReply ToReply(Order order) => new()
{
OrderId = order.Id,
CustomerId = order.CustomerId,
Status = order.State switch
{
OrderState.Pending => OrderStatus.Pending,
OrderState.Paid => OrderStatus.Paid,
OrderState.Shipped => OrderStatus.Shipped,
OrderState.Cancelled => OrderStatus.Cancelled,
_ => OrderStatus.Unspecified
},
TotalCents = order.TotalCents,
Currency = order.Currency,
PlacedAt = Timestamp.FromDateTime(order.PlacedAtUtc)
};
}
public enum OrderState { Pending, Paid, Shipped, Cancelled }
public sealed record Order(long Id, long CustomerId, OrderState State, long TotalCents, string Currency, DateTime PlacedAtUtc);
public interface IOrderRepository
{
Task<Order?> FindAsync(long id, CancellationToken cancellationToken);
Task SaveAsync(Order order, CancellationToken cancellationToken);
}
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, Order> orders = new()
{
[2042] = new Order(2042, 17, OrderState.Paid, 4_998, "EUR", new DateTime(2026, 10, 20, 9, 30, 0, DateTimeKind.Utc)),
[2043] = new Order(2043, 17, OrderState.Shipped, 12_500, "EUR", new DateTime(2026, 10, 21, 14, 5, 0, DateTimeKind.Utc))
};
public Task<Order?> FindAsync(long id, CancellationToken cancellationToken) =>
Task.FromResult(orders.TryGetValue(id, out var order) ? order : null);
public Task SaveAsync(Order order, CancellationToken cancellationToken)
{
orders[order.Id] = order;
return Task.CompletedTask;
}
}When to use it
- Expose GetOrder and CancelOrder to the warehouse service over HTTP/2 from a contract both teams generate code from
- Return FailedPrecondition when the payments service tries to cancel an order that has already shipped
- Stop a slow stock query the moment the caller's 500 ms deadline passes by passing context.CancellationToken into it
More examples
The contract: Protos/orders.proto
The <code>package</code> becomes part of the route (<code>/shop.orders.v1.Orders/GetOrder</code>) and <code>csharp_namespace</code> the C# namespace, so put a version in both: a breaking change ships as <code>v2</code> beside <code>v1</code>. The C# enum members lose the prefix (<code>OrderStatus.Shipped</code>). <code>reserved</code> stops anyone reusing field 4, which would make old clients decode new data as the wrong field. <code>optional</code> generates <code>HasCouponCode</code> and <code>ClearCouponCode()</code>, so an empty string and "not sent" are different things.
syntax = "proto3";
package shop.orders.v1;
option csharp_namespace = "Shop.Orders.V1";
import "google/protobuf/timestamp.proto";
service Orders {
rpc GetOrder (GetOrderRequest) returns (OrderReply);
rpc CancelOrder (CancelOrderRequest) returns (OrderReply);
}
message GetOrderRequest {
int64 order_id = 1;
}
message CancelOrderRequest {
int64 order_id = 1;
string reason = 2;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_CANCELLED = 4;
}
message OrderReply {
// Field 4 was "string status_text"; old clients still decode it, so it can never be reused.
reserved 4;
reserved "status_text";
int64 order_id = 1;
int64 customer_id = 2;
OrderStatus status = 3;
int64 total_cents = 5;
string currency = 6;
google.protobuf.Timestamp placed_at = 7;
optional string coupon_code = 8;
}Mapping domain exceptions in one interceptor
Services throw domain exceptions and stay unaware of gRPC, and one interceptor turns them into status codes, the gRPC counterpart of <code>IExceptionHandler</code>. Registering it in <code>AddGrpc</code> applies it to every service. This override covers unary calls only: streaming methods go through <code>ServerStreamingServerHandler</code>, <code>ClientStreamingServerHandler</code> and <code>DuplexStreamingServerHandler</code>, which you override the same way. Logging the exception here and returning a fixed <code>Internal</code> message keeps stack traces off the wire.
using Grpc.Core;
using Grpc.Core.Interceptors;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc(options => options.Interceptors.Add<DomainExceptionInterceptor>());
var app = builder.Build();
app.Run();
public sealed class OrderNotFoundException(long orderId) : Exception($"Order {orderId} was not found")
{
public long OrderId { get; } = orderId;
}
public sealed class OrderStateException(string message) : Exception(message);
public sealed class DomainExceptionInterceptor(ILogger<DomainExceptionInterceptor> logger) : Interceptor
{
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
return await continuation(request, context);
}
catch (OrderNotFoundException ex)
{
throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
}
catch (OrderStateException ex)
{
throw new RpcException(new Status(StatusCode.FailedPrecondition, ex.Message));
}
catch (OperationCanceledException) when (context.CancellationToken.IsCancellationRequested)
{
throw new RpcException(new Status(StatusCode.Cancelled, "The call was cancelled or its deadline passed"));
}
catch (Exception ex) when (ex is not RpcException)
{
logger.LogError(ex, "Unhandled exception in {Method}", context.Method);
throw new RpcException(new Status(StatusCode.Internal, "Internal error"));
}
}
}The server project file
<code>GrpcServices</code> takes <code>Server</code>, <code>Client</code>, <code>Both</code> or <code>None</code> (messages only). The generated C# lands in <code>obj/</code> on every build, so it is never committed and never edited. Share the <code>.proto</code> files themselves between teams, from a contracts repository or a package of proto files, and let each project generate its own side; sharing a compiled assembly of generated code couples everyone to one Grpc.Tools version.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.84.0" />
</ItemGroup>
<ItemGroup>
<!-- Server generates OrdersBase; a calling project uses GrpcServices="Client". -->
<Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>
</Project>
Discussion