Learnixo

gRPC in .NET · Lesson 1 of 1

gRPC vs REST — When to Use Each

gRPC vs REST — When to Use Each

When two services need to talk to each other, REST is usually the default. But as systems grow and microservices multiply, REST starts showing cracks: verbose JSON payloads, no type contracts, HTTP/1.1 overhead, no built-in streaming.

gRPC solves all of these. It's a high-performance, type-safe RPC framework that runs over HTTP/2 and uses Protocol Buffers for serialization instead of JSON.

This lesson covers everything you need to make the REST-vs-gRPC decision and start building gRPC services in .NET.


What Is gRPC?

gRPC (Google Remote Procedure Call) is an open-source RPC framework that lets you call methods on a remote server as if they were local function calls.

Key characteristics:

  • HTTP/2 — multiplexed streams, binary framing, header compression
  • Protocol Buffers (protobuf) — binary serialization, ~10× smaller than JSON
  • Strongly typed contracts.proto files define the API
  • Code generation — client and server stubs generated from .proto
  • Streaming — client, server, and bidirectional streaming built in
PROTO
// orders.proto
syntax = "proto3";

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (OrderResponse);
  rpc StreamOrders (StreamOrdersRequest) returns (stream OrderResponse);
}

message GetOrderRequest {
  string order_id = 1;
}

message OrderResponse {
  string order_id = 1;
  string customer_id = 2;
  double total = 3;
  string status = 4;
}

gRPC vs REST — the Decision Matrix

| Factor | REST | gRPC | |--------|------|------| | Payload size | JSON (verbose) | Binary protobuf (~10× smaller) | | Speed | HTTP/1.1 | HTTP/2 multiplexed | | Type safety | None (JSON) | Strong — generated from .proto | | Streaming | Workarounds (SSE, WS) | Native (4 patterns) | | Browser support | Full | Requires gRPC-Web proxy | | Human readable | Yes | No (binary) | | Code generation | Optional (OpenAPI) | Mandatory (protobuf) | | Best for | Public APIs, browser clients | Internal microservices, high-throughput |

Choose gRPC when:

  • Services are internal (backend-to-backend)
  • You need low latency and small payloads
  • You need streaming (real-time data, large file transfers)
  • You want a strict contract between services

Choose REST when:

  • Building a public API consumed by browsers or third parties
  • You need human-readable requests for debugging
  • Your team is unfamiliar with protobuf tooling

The Four gRPC Communication Patterns

gRPC supports four call types — this is its biggest advantage over REST:

1. Unary (Request/Response)

Standard call — one request, one response. Same as REST.

PROTO
rpc GetOrder (GetOrderRequest) returns (OrderResponse);

2. Server Streaming

One request, stream of responses. Perfect for real-time feeds.

PROTO
rpc StreamOrderUpdates (OrderId) returns (stream OrderResponse);

3. Client Streaming

Stream of requests, one response. Good for uploading batches of data.

PROTO
rpc UploadOrders (stream CreateOrderRequest) returns (UploadResult);

4. Bidirectional Streaming

Both sides stream simultaneously. Chat applications, real-time collaboration.

PROTO
rpc SyncInventory (stream InventoryUpdate) returns (stream InventoryConfirmation);

Building a gRPC Service in ASP.NET Core

1. Create the project

Bash
dotnet new grpc -n OrderService
cd OrderService

This creates a .proto file in Protos/ and a service implementation.

2. Define your protobuf contract

PROTO
// Protos/orders.proto
syntax = "proto3";

option csharp_namespace = "OrderService";

package orders;

service Orders {
  rpc GetOrder (GetOrderRequest) returns (OrderReply);
  rpc CreateOrder (CreateOrderRequest) returns (OrderReply);
  rpc StreamOrders (StreamOrdersRequest) returns (stream OrderReply);
}

message GetOrderRequest {
  string id = 1;
}

message CreateOrderRequest {
  string customer_id = 1;
  repeated OrderItem items = 2;
}

message OrderItem {
  string product_id = 1;
  int32 quantity = 2;
  double price = 3;
}

message OrderReply {
  string id = 1;
  string customer_id = 2;
  double total = 3;
  string status = 4;
  string created_at = 5;
}

message StreamOrdersRequest {
  string customer_id = 1;
}

Register the proto file in your .csproj:

XML
<ItemGroup>
  <Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>

Build the project — gRPC tooling auto-generates C# classes.

3. Implement the service

C#
using Grpc.Core;
using OrderService;

public class OrdersService : Orders.OrdersBase
{
    private readonly IOrderRepository _orders;
    private readonly ILogger<OrdersService> _logger;

    public OrdersService(IOrderRepository orders, ILogger<OrdersService> logger)
    {
        _orders = orders;
        _logger = logger;
    }

    public override async Task<OrderReply> GetOrder(
        GetOrderRequest request, ServerCallContext context)
    {
        var order = await _orders.GetByIdAsync(request.Id);
        if (order is null)
            throw new RpcException(new Status(StatusCode.NotFound, $"Order {request.Id} not found"));

        return MapToReply(order);
    }

    public override async Task<OrderReply> CreateOrder(
        CreateOrderRequest request, ServerCallContext context)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                Price = (decimal)i.Price
            }).ToList()
        };

        await _orders.CreateAsync(order);
        _logger.LogInformation("Order {OrderId} created for customer {CustomerId}",
            order.Id, order.CustomerId);

        return MapToReply(order);
    }

    // Server streaming — push updates as orders change status
    public override async Task StreamOrders(
        StreamOrdersRequest request,
        IServerStreamWriter<OrderReply> responseStream,
        ServerCallContext context)
    {
        while (!context.CancellationToken.IsCancellationRequested)
        {
            var orders = await _orders.GetRecentByCustomerAsync(request.CustomerId);
            foreach (var order in orders)
                await responseStream.WriteAsync(MapToReply(order));

            await Task.Delay(TimeSpan.FromSeconds(2), context.CancellationToken);
        }
    }

    private static OrderReply MapToReply(Order order) => new()
    {
        Id = order.Id.ToString(),
        CustomerId = order.CustomerId,
        Total = (double)order.Total,
        Status = order.Status.ToString(),
        CreatedAt = order.CreatedAt.ToString("O")
    };
}

4. Register in Program.cs

C#
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;
});

builder.Services.AddScoped<IOrderRepository, OrderRepository>();

var app = builder.Build();

app.MapGrpcService<OrdersService>();

// Optional: gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();

app.Run();

Calling gRPC from Another .NET Service (Client)

C#
// Client project — add the proto file with GrpcServices="Client"
XML
<ItemGroup>
  <Protobuf Include="Protos\orders.proto" GrpcServices="Client" />
  <PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
  <PackageReference Include="Google.Protobuf" Version="3.28.0" />
  <PackageReference Include="Grpc.Tools" Version="2.65.0" PrivateAssets="All" />
</ItemGroup>
C#
// Register typed gRPC client
builder.Services.AddGrpcClient<Orders.OrdersClient>(options =>
{
    options.Address = new Uri("https://order-service:5001");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
    // For dev with self-signed certs
    return new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback =
            HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
    };
});

// Use it in a controller
public class CheckoutController : ControllerBase
{
    private readonly Orders.OrdersClient _ordersClient;

    public CheckoutController(Orders.OrdersClient ordersClient)
        => _ordersClient = ordersClient;

    [HttpPost("checkout")]
    public async Task<IActionResult> Checkout(CheckoutRequest request)
    {
        var reply = await _ordersClient.CreateOrderAsync(new CreateOrderRequest
        {
            CustomerId = request.CustomerId,
            Items = { request.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                Price = (double)i.Price
            })}
        });

        return Ok(new { OrderId = reply.Id, Total = reply.Total });
    }
}

Error Handling — gRPC Status Codes

gRPC uses its own status codes instead of HTTP status codes:

C#
// Not found
throw new RpcException(new Status(StatusCode.NotFound, "Order not found"));

// Validation error
throw new RpcException(new Status(StatusCode.InvalidArgument, "CustomerId is required"));

// Unauthorized
throw new RpcException(new Status(StatusCode.Unauthenticated, "Token invalid or expired"));

// Rate limited
throw new RpcException(new Status(StatusCode.ResourceExhausted, "Rate limit exceeded"));

// Catch on the client
try
{
    var reply = await client.GetOrderAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
    return NotFound(ex.Status.Detail);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unauthenticated)
{
    return Unauthorized();
}

Interceptors — Middleware for gRPC

Interceptors work like ASP.NET Core middleware but for gRPC calls:

C#
public class LoggingInterceptor : Interceptor
{
    private readonly ILogger<LoggingInterceptor> _logger;

    public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
        => _logger = logger;

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        var method = context.Method;
        _logger.LogInformation("gRPC call: {Method}", method);

        try
        {
            var response = await continuation(request, context);
            _logger.LogInformation("gRPC success: {Method}", method);
            return response;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "gRPC error: {Method}", method);
            throw;
        }
    }
}

// Register:
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<LoggingInterceptor>();
});

Authentication in gRPC

C#
// Add JWT bearer to gRPC
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options => { /* configure */ });

builder.Services.AddAuthorization();

// In service, use [Authorize] attribute
[Authorize]
public override async Task<OrderReply> GetOrder(...)

// Client — attach JWT token
var credentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{
    var token = await tokenService.GetTokenAsync();
    metadata.Add("Authorization", $"Bearer {token}");
});

var channel = GrpcChannel.ForAddress("https://order-service:5001", new GrpcChannelOptions
{
    Credentials = ChannelCredentials.Create(
        new SslCredentials(), credentials)
});

Testing gRPC Services

Bash
# Install grpcurl for manual testing
winget install fullstorydev.grpcurl

# List services
grpcurl -plaintext localhost:5001 list

# Call a method
grpcurl -plaintext -d '{"id": "order-123"}' localhost:5001 orders.Orders/GetOrder

For integration tests:

C#
public class OrdersServiceTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly Orders.OrdersClient _client;

    public OrdersServiceTests(WebApplicationFactory<Program> factory)
    {
        var channel = GrpcChannel.ForAddress("http://localhost",
            new GrpcChannelOptions { HttpHandler = factory.Server.CreateHandler() });
        _client = new Orders.OrdersClient(channel);
    }

    [Fact]
    public async Task GetOrder_ExistingId_ReturnsOrder()
    {
        var reply = await _client.GetOrderAsync(new GetOrderRequest { Id = "order-1" });
        Assert.Equal("order-1", reply.Id);
    }
}

Key Takeaways

  1. Use gRPC for internal microservice communication — the performance and type safety gains are significant at scale
  2. The .proto file is your contract — version it in source control, never break backwards compatibility
  3. Server streaming replaces polling — push data as it changes instead of clients polling every N seconds
  4. gRPC errors are typed — use status codes (NotFound, InvalidArgument) instead of exceptions
  5. Interceptors are your middleware — logging, auth, validation all go here

The next lesson covers protobuf design patterns — how to version your contracts, handle backwards compatibility, and design messages that don't break clients when you evolve them.