gRPC Services

Define a contract in .proto, implement the generated service base with Grpc.AspNetCore, return the right status codes and honour deadlines.

Syntaxbuilder.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:

SituationStatusCode
A request field is invalidInvalidArgument
Order does not existNotFound
Order already shipped, cannot cancelFailedPrecondition
No or bad credentials / not allowedUnauthenticated / PermissionDenied
Dependency down, safe to retryUnavailable
Unhandled exceptionUnknown

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 int64 reads as 0, so use optional where absence matters, and make every enum start with ..._UNSPECIFIED = 0.
  • There is no decimal: send money as int64 minor units plus a currency code.
  • Timestamp.FromDateTime throws unless the DateTime is 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

Example · csharp
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.

Example · protobuf
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.

Example · csharp
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.

Example · xml
<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

  • Be the first to comment on this lesson.