gRPC vs REST — When to Use Each and How to Build gRPC Services in .NET
Understand what gRPC is, how Protocol Buffers work, when to choose gRPC over REST, and how to build your first gRPC service in ASP.NET Core.
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 —
.protofiles define the API - Code generation — client and server stubs generated from
.proto - Streaming — client, server, and bidirectional streaming built in
// 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.
rpc GetOrder (GetOrderRequest) returns (OrderResponse);2. Server Streaming
One request, stream of responses. Perfect for real-time feeds.
rpc StreamOrderUpdates (OrderId) returns (stream OrderResponse);3. Client Streaming
Stream of requests, one response. Good for uploading batches of data.
rpc UploadOrders (stream CreateOrderRequest) returns (UploadResult);4. Bidirectional Streaming
Both sides stream simultaneously. Chat applications, real-time collaboration.
rpc SyncInventory (stream InventoryUpdate) returns (stream InventoryConfirmation);Building a gRPC Service in ASP.NET Core
1. Create the project
dotnet new grpc -n OrderService
cd OrderServiceThis creates a .proto file in Protos/ and a service implementation.
2. Define your protobuf contract
// 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:
<ItemGroup>
<Protobuf Include="Protos\orders.proto" GrpcServices="Server" />
</ItemGroup>Build the project — gRPC tooling auto-generates C# classes.
3. Implement the service
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
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)
// Client project — add the proto file with GrpcServices="Client"<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>// 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:
// 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:
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
// 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
# 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/GetOrderFor integration tests:
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
- Use gRPC for internal microservice communication — the performance and type safety gains are significant at scale
- The
.protofile is your contract — version it in source control, never break backwards compatibility - Server streaming replaces polling — push data as it changes instead of clients polling every N seconds
- gRPC errors are typed — use status codes (
NotFound,InvalidArgument) instead of exceptions - 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.
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.