Learnixo
Back to blog
Backend Systemsintermediate

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.

LearnixoJune 3, 20268 min read
.NETC#gRPCProtobufMicroservicesPerformanceASP.NET Core
Share:𝕏

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

Bash
dotnet new web -n GrpcDemo
dotnet add package Grpc.AspNetCore
XML
<!-- .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.

PROTOBUF
// 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

C#
// 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

C#
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 services

gRPC Client

Bash
# Client project
dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
XML
<!-- Client .csproj -->
<ItemGroup>
  <Protobuf Include="Protos\orders.proto" GrpcServices="Client" />
</ItemGroup>

Typed Client with IHttpClientFactory

C#
// 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:

C#
// 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

C#
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

C#
// 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

C#
var token = await _tokenService.GetTokenAsync();
var headers = new Metadata { { "Authorization", $"Bearer {token}" } };

var response = await _client.GetOrderAsync(request, headers, cancellationToken: ct);

Per-Call Credentials

C#
// 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:

C#
// 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

Bash
dotnet add package Grpc.HealthCheck
C#
builder.Services.AddGrpcHealthChecks()
    .AddCheck("orders-db", () =>
    {
        // Check DB connectivity
        return HealthCheckResult.Healthy();
    });

app.MapGrpcHealthChecksService();

Reflection (for tooling)

Bash
dotnet add package Grpc.AspNetCore.Server.Reflection
C#
app.MapGrpcReflectionService();

Now you can use grpcurl or tools like Postman to explore your services without sharing .proto files:

Bash
# 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/GetOrder

Interview 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.