Web API  

Using C# 14 with gRPC Instead of REST: Build a Customer Microservice (with Benchmarks)

Introduction

 In .NET, REST APIs have been the default for years because they are simple, widely supported, and ideal for public APIs.

The way systems are built in 2025, however, is very different from the era when REST became popular.

The following are common components of enterprise platforms today:

  • Microservices in the dozens (or hundreds)

  • Dashboards and notifications in real-time

  • Monitoring and telemetry pipelines (IoT)

  • Workflows driven by AI (agent pipelines, inference, streaming outputs)

Services communicate constantly in these environments, sometimes thousands of times per second.

Not because REST is "bad", but because HTTP/1.1 + JSON introduce overhead that becomes expensive at scale.

gRPC excels in this area.

It is designed for machine-to-machine communication. It uses:

  • Two-way HTTP

  • Buffers for Protocols (Protobuf)

  • Strongly typed clients were generated

  • Streaming support built-in

I will walk you through a practical gRPC customer microservice demo using C# 14 + .NET 10, and compare JSON vs Protobuf payload performance.

Why REST Starts to Hurt Inside Distributed Systems

At the "edge" of a system, REST is brilliant:

  • APIs available to the public

  • Clients for browsers

  • Integrations with partners

  • Endpoints for CRUD

However, internal service-to-service communication has a different priority.

JSON payloads are expensive under load

Microservices don't need human-readable formats, even though JSON is human-readable.

JSON becomes expensive at scale because:

  • Property names repeated (larger payloads)

  • During serialization, allocations are made

  • Cost of parsing during deserialization

  • Pressure increase in the GC

This becomes a permanent performance tax on a platform with constant inter-service calls.

REST contracts are soft contracts

It helps to use Swagger/OpenAPI, but it does not guarantee compatibility.

Real-world teams frequently experience contract drift:

  • Field renaming

  • Fields that are required have been added

  • The type has changed (string to number)

  • Nullability assumptions break consumers

These problems usually arise at runtime, and runtime failures are expensive in distributed systems.

Streaming is not first-class

Streaming is essential for modern workloads:

  • Ingestion of telemetry

  • Dashboards that are updated in real time

  • notifications

  • Streams of AI inference

The REST API can do it, but typically through workarounds:

  • Security Sector Expansion (SSE)

  • The WebSocket protocol

  • Taking a poll

As a result, complexity and operational burden increase.

What gRPC Solves (In Practical Enterprise Terms)

Multiplexing HTTP/2

HTTP/2 is properly used by gRPC:

  • Relationships that last a lifetime

  • Streams that are multiplexed

  • Compression of headers

  • With fewer open sockets, throughput is improved

Fast and compact protobuf

The protobuf format is binary:

  • JSON payloads are smaller

  • Serialization and deserialization are faster

  • Language-independent behavior

APIs based on contracts

The contract for gRPC is defined in the .proto file.

  • Code is generated for the server

  • Code is generated for the client

  • During the build process, breaking changes are caught

By itself, that reduces integration drift dramatically.

There is built-in streaming

Natively supported are unary calls and streaming modes.

The software does not support bolt-ons, custom message envelopes, or handwritten state management.

The Honest Part: When REST is Still the Better Choice

 As a Microsoft MVP, let me be clear:

REST cannot be replaced everywhere by gRPC.

It is often better to rest when:

  • Browsers are consumers

  • CDN caching is necessary

  • Public APIs are available to the public

  • Integration with external partners must be easy

The best model for real-world architecture is typically:

  • At the edge, REST

  • Platform-based gRPC

Code Example of Customer Microservice with gRPC (C# 14 + .NET 10)

Customer-Microservice=Demo-By-Ziggy-Rafiq

Solution Structure

Solution_Structure_by_Ziggy_Rafiq

Step 1 — Define the API Contract (customer.proto)

File: CustomerGrpc.Server/Protos/customer.proto

syntax = "proto3";

option csharp_namespace = "CustomerGrpc";

package customer.v1;

service CustomerService {
  rpc CreateCustomer (CreateCustomerRequest) returns (CustomerResponse);
  rpc GetCustomerById (GetCustomerByIdRequest) returns (CustomerResponse);
  rpc StreamCustomers (StreamCustomersRequest) returns (stream CustomerResponse);
}

message CreateCustomerRequest {
  string full_name = 1;
  string email = 2;
}

message GetCustomerByIdRequest {
  string customer_id = 1;
}

message StreamCustomersRequest {
  int32 delay_ms = 1;
}

message CustomerResponse {
  string customer_id = 1;
  string full_name = 2;
  string email = 3;
  string created_utc = 4;
}

The API contract generates strongly typed C# classes from this file.

Step 2 — Implement the Server (CustomerRepository)

File: CustomerGrpc.Server/Data/CustomerRepository.cs

namespace CustomerGrpc.Server.Data;

public sealed record Customer(
    Guid Id,
    string FullName,
    string Email,
    DateTime CreatedUtc);

public interface ICustomerRepository
{
    Customer Add(string fullName, string email);
    Customer? Get(Guid id);
    IReadOnlyList<Customer> GetAll();
}

public sealed class CustomerRepository : ICustomerRepository
{
    private readonly List<Customer> _customers = [];

    public Customer Add(string fullName, string email)
    {
        var customer = new Customer(Guid.NewGuid(), fullName, email, DateTime.UtcNow);
        _customers.Add(customer);
        return customer;
    }

    public Customer? Get(Guid id) => _customers.FirstOrDefault(x => x.Id == id);

    public IReadOnlyList<Customer> GetAll() => _customers.AsReadOnly();
}

Step 3 — CustomerService Implementation (Unary + Streaming)

File: CustomerGrpc.Server/Services/CustomerService.cs

using CustomerGrpc.Server.Data;
using Grpc.Core;

namespace CustomerGrpc.Server.Services;

public sealed class CustomerGrpcService(
    ILogger<CustomerGrpcService> logger,
    ICustomerRepository repository)
    : CustomerGrpc.CustomerService.CustomerServiceBase
{
    public override Task<CustomerResponse> CreateCustomer(
        CreateCustomerRequest request,
        ServerCallContext context)
    {
        if (string.IsNullOrWhiteSpace(request.FullName))
            throw new RpcException(new Status(StatusCode.InvalidArgument, "Full name is required."));

        if (string.IsNullOrWhiteSpace(request.Email) || !request.Email.Contains('@'))
            throw new RpcException(new Status(StatusCode.InvalidArgument, "A valid email is required."));

        var customer = repository.Add(request.FullName.Trim(), request.Email.Trim());

        logger.LogInformation("Customer created: {CustomerId} ({Email})", customer.Id, customer.Email);

        return Task.FromResult(Map(customer));
    }

    public override Task<CustomerResponse> GetCustomerById(
        GetCustomerByIdRequest request,
        ServerCallContext context)
    {
        if (!Guid.TryParse(request.CustomerId, out var id))
            throw new RpcException(new Status(StatusCode.InvalidArgument, "CustomerId must be a valid GUID."));

        var customer = repository.Get(id);

        if (customer is null)
            throw new RpcException(new Status(StatusCode.NotFound, $"Customer '{id}' not found."));

        return Task.FromResult(Map(customer));
    }

    public override async Task StreamCustomers(
        StreamCustomersRequest request,
        IServerStreamWriter<CustomerResponse> responseStream,
        ServerCallContext context)
    {
        var delayMs = request.DelayMs <= 0 ? 250 : request.DelayMs;

        foreach (var customer in repository.GetAll())
        {
            if (context.CancellationToken.IsCancellationRequested)
                break;

            await responseStream.WriteAsync(Map(customer));
            await Task.Delay(delayMs, context.CancellationToken);
        }
    }

    private static CustomerResponse Map(Customer customer) => new()
    {
        CustomerId = customer.Id.ToString(),
        FullName = customer.FullName,
        Email = customer.Email,
        CreatedUtc = customer.CreatedUtc.ToString("O")
    };
}

Step 4 — Hosting in Program.cs

File: CustomerGrpc.Server/Program.cs

using CustomerGrpc.Server.Data;
using CustomerGrpc.Server.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddGrpc();
builder.Services.AddSingleton<ICustomerRepository, CustomerRepository>();
builder.Services.AddLogging(x => x.AddConsole());

var app = builder.Build();

app.MapGrpcService<CustomerGrpcService>();
app.MapGet("/", () => "Customer gRPC service running.");

app.Run();

Step 5 — Client (Unary + Streaming)

File: CustomerGrpc.Client/Program.cs


using CustomerGrpc;
using Grpc.Core;
using Grpc.Net.Client;

Console.WriteLine("Hello, from Ziggy Rafiq!");


Console.WriteLine("Customer gRPC demo client starting...");

using var channel = GrpcChannel.ForAddress("https://localhost:7118");
var client = new CustomerService.CustomerServiceClient(channel);

var created = await client.CreateCustomerAsync(new CreateCustomerRequest
{
    FullName = "Ziggy Rafiq",
    Email = "[email protected]"
});

Console.WriteLine($"Created: {created.CustomerId} - {created.FullName}");

var fetched = await client.GetCustomerByIdAsync(new GetCustomerByIdRequest
{
    CustomerId = created.CustomerId
});

Console.WriteLine($"Fetched: {fetched.CustomerId} - {fetched.Email}");

Console.WriteLine("\nStreaming customers...");

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var stream = client.StreamCustomers(new StreamCustomersRequest { DelayMs = 300 }, cancellationToken: cts.Token);

await foreach (var item in stream.ResponseStream.ReadAllAsync(cts.Token))
{
    Console.WriteLine($"→ {item.CustomerId} | {item.FullName} | {item.Email}");
}

Console.ReadLine();

BenchmarkDotNet: JSON vs Protobuf

Serialization costs are a significant component of latency and CPU usage in internal services.

We get real numbers from BenchmarkDotNet rather than opinions.

To measure the real differences between REST-style JSON and gRPC-style Protobuf, we use BenchmarkDotNet instead of guessing.

Run the benchmarks

dotnet run -c Release --project .\CustomerGrpc.Benchmarks\CustomerGrpc.Benchmarks.csproj

Benchmark files included in this demo

Protobuf message contract

File: CustomerGrpc.Benchmarks/Protos/customer_benchmark.proto
The benchmark uses the following Protobuf payload:

 syntax = "proto3";

option csharp_namespace = "CustomerGrpc.Benchmarks.Protos";
package customer.bench.v1;

message CustomerProto {
  string customer_id = 1;
  string full_name = 2;
  string email = 3;
  string created_utc = 4;
}

JSON DTO model (REST-style)

File: CustomerGrpc.Benchmarks/Models/CustomerJsonDto.cs
A typical REST payload looks like this:

namespace CustomerGrpc.Benchmarks.Models;

public sealed record CustomerJsonDto(
    Guid CustomerId,
    string FullName,
    string Email,
    DateTime CreatedUtc
);

Benchmark suite (JSON vs Protobuf)

File: CustomerGrpc.Benchmarks/Benchmarks/JsonVsProtobufBenchmarks.cs
Payload size, serialization and deserialization time, and allocations are measured:

using BenchmarkDotNet.Attributes;
using CustomerGrpc.Benchmarks.Models;
using CustomerGrpc.Benchmarks.Protos;
using Google.Protobuf;
using System.Text.Json;

namespace CustomerGrpc.Benchmarks.Benchmarks;

[MemoryDiagnoser]
public class JsonVsProtobufBenchmarks
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);

    private CustomerJsonDto _jsonDto = default!;
    private CustomerProto _proto = default!;

    private byte[] _jsonBytes = default!;
    private byte[] _protoBytes = default!;

    [GlobalSetup]
    public void Setup()
    {
        _jsonDto = new CustomerJsonDto(
            CustomerId: Guid.NewGuid(),
            FullName: "Ziggy Rafiq",
            Email: "[email protected]",
            CreatedUtc: DateTime.UtcNow);

        _proto = new CustomerProto
        {
            CustomerId = _jsonDto.CustomerId.ToString(),
            FullName = _jsonDto.FullName,
            Email = _jsonDto.Email,
            CreatedUtc = _jsonDto.CreatedUtc.ToString("O")
        };

        // Pre-serialized payloads for deserialize benchmarks
        _jsonBytes = JsonSerializer.SerializeToUtf8Bytes(_jsonDto, JsonOptions);
        _protoBytes = _proto.ToByteArray();
    }

    // -----------------------------
    // Size comparisons (no timing)
    // -----------------------------

    [Benchmark(Description = "JSON Size (bytes)")]
    public int Json_Size() => _jsonBytes.Length;

    [Benchmark(Description = "Protobuf Size (bytes)")]
    public int Protobuf_Size() => _protoBytes.Length;

    // -----------------------------
    // Serialization
    // -----------------------------

    [Benchmark(Baseline = true, Description = "JSON Serialize")]
    public byte[] Json_Serialize()
        => JsonSerializer.SerializeToUtf8Bytes(_jsonDto, JsonOptions);

    [Benchmark(Description = "Protobuf Serialize")]
    public byte[] Protobuf_Serialize()
        => _proto.ToByteArray();

    // -----------------------------
    // Deserialization
    // -----------------------------

    [Benchmark(Description = "JSON Deserialize")]
    public CustomerJsonDto? Json_Deserialize()
        => JsonSerializer.Deserialize<CustomerJsonDto>(_jsonBytes, JsonOptions);

    [Benchmark(Description = "Protobuf Deserialize")]
    public CustomerProto Protobuf_Deserialize()
        => CustomerProto.Parser.ParseFrom(_protoBytes);
}

Benchmark entry point

File: CustomerGrpc.Benchmarks/Program.cs


using BenchmarkDotNet.Running;
using CustomerGrpc.Benchmarks.Benchmarks;

BenchmarkRunner.Run<JsonVsProtobufBenchmarks>();

What to look for in results

Most developers are surprised by how much JSON allocates under load. Lower allocations and smaller payloads usually translate into better throughput and lower hosting costs.

Conclusion

Public APIs can still benefit from REST, but internal distributed systems need more:

  • Contracts that are strong

  • Payloads that are compact

  • Services that behave consistently

  • Streaming natively

  • Under load, scalability

In many modern enterprise platforms, gRPC has become the default choice.

One of the most practical upgrades you can make to your microservices in C# 14 + .NET 10 is to adopt gRPC for internal communication. You can find the code examples of this article on my GitHub Repository URL https://github.com/ziggyrafiq/csharp14-grpc-customer-microservice-demo