How to Create a gRPC Microservice in C# : A Complete Practical Guide
If you have spent any time working with distributed systems, you already know that the way services talk to each other matters just as much as the code inside them. Two services with perfect business logic can still fail an entire system if the communication between them is slow, chatty or fragile. That is exactly the problem gRPC was built to solve and .NET has become one of the most comfortable ecosystems to build gRPC services in.
This article walks through microservices from first principles, when they make sense and when they do not, why gRPC is a strong choice for service-to-service communication and then a complete, hands-on walkthrough of building a real gRPC microservice in C# including folder structure, a working .proto contract, service implementation, client code and the commands you will actually type.
What Is a Microservice?
A microservice is an independently deployable service that owns a single, well-defined piece of business capability and communicates with other services over the network, typically through lightweight protocols such as HTTP/REST, gRPC or asynchronous messaging.
The word "micro" is a bit misleading. Size is not the defining trait autonomy is. A microservice should be able to:
- Be built, tested and deployed on its own schedule, without waiting for other teams.
- Own its data. Other services do not reach directly into its database. They ask through an API.
- Scale independently. If your
OrderServicegets ten times more traffic thanNotificationService, you scale only what needs scaling. - Fail without taking the whole system down, assuming you have designed proper fault isolation (timeouts, retries, circuit breakers).
This is usually contrasted with a monolith, where all business capabilities live in one codebase, one deployment unit and often one database.
Common Use Cases for Microservices
Microservices tend to earn their complexity in situations like these:
- Large engineering organizations : When multiple teams need to ship independently without stepping on each other's deployments, service boundaries reduce coordination overhead.
- Different scaling profiles : A payment processing engine and a recommendation engine rarely need the same amount of compute. Splitting them lets you scale each based on its own load.
- Polyglot requirements : One team might need Python for a machine learning component while another prefers C# for a transactional service. Microservices allow each part to use the best tool for its job.
- Independent release cycles : A checkout service that changes weekly should not be bundled with a rarely-changing billing-reconciliation job.
- Fault isolation : Isolating a flaky third-party integration (say, an SMS gateway) into its own service prevents its failures from cascading into unrelated features.
The Trade-offs You Should Know Before Committing
Microservices are not free. They trade one kind of complexity (a large codebase) for another (distributed systems complexity). Be honest with yourself about these costs before adopting the pattern:
| Benefit | Corresponding Cost |
|---|---|
| Independent deployment | You now need CI/CD pipelines, versioning strategy and API contracts per service |
| Independent scaling | You need infrastructure like container orchestration (Kubernetes, ECS) and load balancing |
| Fault isolation | You must design for network failures retries, timeouts, circuit breakers, idempotency |
| Technology freedom | Operational overhead multiplies: more logging pipelines, more monitoring dashboards, more deployment targets |
| Smaller, focused codebases | Debugging a single user request often means tracing it across five services (distributed tracing becomes mandatory, not optional) |
| Team autonomy | Data consistency becomes harder. you trade ACID transactions for eventual consistency and patterns like the Saga pattern |
A good rule of thumb: if a small team can still hold the entire domain model of your application in their heads and deployments are not blocking each other, you probably don't need microservices yet. Reach for them when organizational or scaling pain, not architectural fashion, forces your hand.
Why gRPC for Microservice Communication?
Once you decide services need to talk to each other, you have to pick a protocol. REST over JSON is the most common default, but gRPC has become the preferred choice for internal, service-to-service communication for a few concrete reasons:
- Contract-first design : Every gRPC service is defined in a
.protofile. This file is the single source of truth for the request/response shapes and the available operations and it can generate client and server code in dozens of languages. - Performance : gRPC runs over HTTP/2 and serializes messages with Protocol Buffers, a compact binary format. This is typically faster and smaller on the wire than JSON over HTTP/1.1.
- Streaming support : gRPC natively supports server streaming, client streaming and bidirectional streaming, which REST does not handle cleanly without extra plumbing like WebSockets.
- Strong typing across languages : Because the contract is compiled into strongly typed classes, you catch mismatches at compile time instead of discovering them at runtime through a failed JSON deserialization.
- Built-in code generation : You don't hand-write DTOs and client wrappers, the tooling generates them from the
.protofile, keeping client and server in sync.
The trade-off is that gRPC is less friendly to browsers directly (it needs a proxy layer like grpc-web) and less human-readable on the wire than JSON, which can make ad-hoc debugging slightly less convenient. For internal service-to-service calls, though, these downsides rarely matter and gRPC is usually the stronger choice.
Building a gRPC Microservice in C#, Step by Step
We'll build a small but complete ProductService a microservice responsible for looking up product information. It will expose one RPC, GetProduct, that a client can call to retrieve a product by its ID. The same pattern extends cleanly to as many RPCs as your service needs.
Prerequisites
Make sure you have the following installed:
- .NET 8 SDK (or .NET 9, if you're on the latest release)
- A code editor Visual Studio, Rider or VS Code with the C# extension
grpcurl(optional, but very useful for manual testing without writing a client)
Check your SDK version with:
dotnet --version
Step 1: Create the Solution and Project
Start by creating a solution to hold both the service and, later, a client.
mkdir ProductPlatform
cd ProductPlatform
dotnet new sln -n ProductPlatform
dotnet new grpc -o ProductService
dotnet sln add ProductService/ProductService.csproj
dotnet new grpc scaffolds a ready-to-run ASP.NET Core gRPC project, complete with a sample Greeter service. We'll replace the sample with our own ProductService contract and implementation.
Step 2: Recommended Folder Structure
A clean, scalable structure keeps your .proto contracts, generated code and hand-written logic clearly separated. Here is a layout that works well in production codebases:
ProductPlatform/
├── ProductPlatform.sln
├── ProductService/
│ ├── Protos/
│ │ └── product.proto
│ ├── Services/
│ │ └── ProductGrpcService.cs
│ ├── Models/
│ │ └── Product.cs
│ ├── Data/
│ │ └── ProductRepository.cs
│ ├── Interceptors/
│ │ └── ExceptionInterceptor.cs
│ ├── Program.cs
│ ├── appsettings.json
│ └── ProductService.csproj
└── ProductService.Client/
├── Program.cs
└── ProductService.Client.csproj
A few notes on why this layout works well:
Protos/holds the contract. Keeping it separate makes it obvious this file is shared across teams and should be treated like a versioned API.Services/holds the classes that implement the generated gRPC base classes this is where request handling logic lives.Data/(orRepositories/) isolates persistence so your gRPC service class stays thin and testable.Interceptors/is where you put cross-cutting concerns like logging, exception translation and authentication checks, keeping them out of your business logic.
Step 3: Define the Contract (product.proto)
Replace the sample greet.proto with your own contract. Create Protos/product.proto:
syntax = "proto3";
option csharp_namespace = "ProductService";
package product;
service ProductLookup {
rpc GetProduct (ProductRequest) returns (ProductReply);
}
message ProductRequest {
int32 id = 1;
}
message ProductReply {
int32 id = 1;
string name = 2;
double price = 3;
bool in_stock = 4;
}
A quick breakdown of what's happening here:
syntax = "proto3"selects the current version of the Protocol Buffers language.csharp_namespacecontrols the namespace of the generated C# classes.service ProductLookupdeclares the RPC(s) your service exposes in our case, justGetProduct.messageblocks define the request and response shapes. The numbers (= 1,= 2) are field tags used for binary encoding they must be unique within a message and should never be reused once a contract ships to production, since changing them breaks backward compatibility.
Now update the .csproj to make sure this proto file is registered for code generation (the scaffolded template usually already references Protos/*.proto, but double check):
<ItemGroup>
<Protobuf Include="Protos\product.proto" GrpcServices="Server" />
</ItemGroup>
Run a build to generate the strongly typed classes:
dotnet build
Behind the scenes, this generates ProductLookup.ProductLookupBase (the class your service implements), along with ProductRequest and ProductReply C# classes all inside obj/Debug/.../product.cs, though you never need to look at that generated file directly.
Step 4: Implement the Service
Create Services/ProductGrpcService.cs:
using Grpc.Core;
namespace ProductService.Services;
public class ProductGrpcService : ProductLookup.ProductLookupBase
{
private readonly ILogger<ProductGrpcService> _logger;
public ProductGrpcService(ILogger<ProductGrpcService> logger)
{
_logger = logger;
}
public override Task<ProductReply> GetProduct(ProductRequest request, ServerCallContext context)
{
_logger.LogInformation("Fetching product with id {ProductId}", request.Id);
if (request.Id <= 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Product id must be positive."));
}
// In a real service this would come from a repository or database call.
var product = new ProductReply
{
Id = request.Id,
Name = $"Sample Product {request.Id}",
Price = 19.99,
InStock = true
};
return Task.FromResult(product);
}
}
A couple of things worth calling out:
- The class inherits from
ProductLookup.ProductLookupBase, which was generated from the.protofile. Every RPC you defined becomes anoverride-able method. - gRPC errors should be raised using
RpcExceptionwith an appropriateStatusCode, not generic exceptions. This ensures the client receives a proper gRPC status code (likeInvalidArgumentorNotFound) instead of an opaqueInternalerror.
Step 5: Wire It Up in Program.cs
The scaffolded Program.cs already contains most of what you need. Update it to register your service:
using ProductService.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGrpcService<ProductGrpcService>();
app.MapGet("/", () => "This server only accepts gRPC requests. Use a gRPC client to communicate.");
app.Run();
AddGrpc() registers the gRPC middleware and MapGrpcService<T>() binds your implementation to an HTTP/2 endpoint. Run the service with:
dotnet run --project ProductService
By default, this listens on https://localhost:5001 (or whatever port is configured in launchSettings.json / appsettings.json).
Step 6: Build a Client to Call the Service
Add a console client project so you can test end-to-end:
dotnet new console -o ProductService.Client
dotnet sln add ProductService.Client/ProductService.Client.csproj
cd ProductService.Client
dotnet add package Grpc.Net.Client
dotnet add package Google.Protobuf
dotnet add package Grpc.Tools
cd ..
The client needs its own copy of the .proto file (or a shared package/reference) so it can generate matching client stubs. Copy product.proto into a Protos/ folder inside the client project, then add to ProductService.Client.csproj:
<ItemGroup>
<Protobuf Include="Protos\product.proto" GrpcServices="Client" />
</ItemGroup>
Now write the client in Program.cs:
using Grpc.Net.Client;
using ProductService;
using var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new ProductLookup.ProductLookupClient(channel);
var reply = await client.GetProductAsync(new ProductRequest { Id = 42 });
Console.WriteLine($"Product #{reply.Id}: {reply.Name}, ${reply.Price}, In stock: {reply.InStock}");
Run the service in one terminal, then the client in another:
# Terminal 1
dotnet run --project ProductService
# Terminal 2
dotnet run --project ProductService.Client
You should see the product details printed to the console confirming the full round trip from client, over HTTP/2, through your gRPC contract, into your service logic and back.
Step 7 (Optional but Recommended): Testing Without Writing a Client
While you're developing, grpcurl lets you call the service directly from the terminal, similar to how curl works for REST:
grpcurl -plaintext -d '{"id": 42}' localhost:5001 product.ProductLookup/GetProduct
This is especially useful in CI pipelines or quick manual verification without spinning up a full client project.
Production-Readiness Checklist
A working RPC is a good start, but a handful of additions separate a demo from something you'd trust in production:
- Health checks : Add the
Grpc.HealthCheckpackage and implement the standard gRPC health-checking protocol so orchestrators like Kubernetes can probe service liveness/readiness. - Interceptors for cross-cutting concerns : Centralize logging, authentication and exception-to-status translation in server interceptors instead of repeating logic in every method.
- Structured error handling : Always map domain errors to appropriate
StatusCodevalues (NotFound,InvalidArgument,PermissionDenied, etc.) rather than letting unhandled exceptions leak as genericInternalerrors. - TLS everywhere : In production, gRPC should run over TLS, not plaintext.
dotnet new grpcsets this up by default; just make sure certificates are properly provisioned in your deployment environment. - Versioning strategy : Treat your
.protofile like a public API contract. Add new fields with new field numbers rather than reusing old ones and avoid changing the meaning of existing fields. - Containerization : Package the service with a
Dockerfilebased on the officialmcr.microsoft.com/dotnet/aspnetruntime image and expose the HTTP/2 port explicitly. - Observability : Integrate distributed tracing (OpenTelemetry works well with gRPC) so you can follow a single request across multiple services.
- Deadlines and retries on the client side : Configure sensible call deadlines and retry policies in
GrpcChanneloptions so one slow dependency doesn't cascade into a full outage.
Advanced Pattern Hosting gRPC with Raw Grpc.Core (A Production Alternative)
Everything above uses ASP.NET Core's built-in gRPC hosting (AddGrpc() / MapGrpcService), which is the recommended starting point for new services. But it's worth knowing that a lot of production gRPC services especially older or high-throughput systems host gRPC directly through the Grpc.Core.Server class instead of going through the ASP.NET Core pipeline at all. You'll see this pattern in codebases that predate Microsoft's mature ASP.NET Core gRPC support or in teams that want a thinner, non-HTTP-middleware hosting layer.
Here's what that looks like and why each piece exists.
Bootstrapping DI Manually
Instead of WebApplication.CreateBuilder, the composition root is a plain ServiceCollection built up in a Startup class and resolved once at process start:
public class Startup
{
public IConfiguration Configuration { get; }
public Startup()
{
Configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables()
.Build();
}
public ServiceProvider InitialiseServices()
{
var services = new ServiceCollection();
services.Configure<GrpcServerOptions>(Configuration.GetSection("GrpcServer"));
services.AddSingleton<IProductRepository, ProductRepository>();
services.AddSingleton<ProductGrpcServiceImpl>();
return services.BuildServiceProvider();
}
}
This is the same DI container ASP.NET Core would use under the hood you're just building and resolving it yourself instead of letting the framework do it for you.
Constructing the Server by Hand
Program.cs becomes responsible for binding services, attaching interceptors, opening ports and managing the process lifetime explicitly:
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
#if DEBUG || DEVELOPMENT
using Grpc.Reflection;
#endif
class Program
{
private static readonly ManualResetEvent Shutdown = new ManualResetEvent(false);
static void Main(string[] args)
{
var startup = new Startup();
var serviceProvider = startup.InitialiseServices();
var options = serviceProvider.GetRequiredService<IOptions<GrpcServerOptions>>().Value;
var loggingInterceptor = new LoggingInterceptor(); // implements Grpc.Core.Interceptors.Interceptor
var boundService = ProductLookup
.BindService(serviceProvider.GetRequiredService<ProductGrpcServiceImpl>())
.Intercept(loggingInterceptor);
Server server = null;
// Graceful shutdown: give in-flight requests time to finish before the process exits.
System.Runtime.Loader.AssemblyLoadContext.Default.Unloading += _ =>
{
var gracePeriodMs = int.TryParse(
Environment.GetEnvironmentVariable("STOP_GRACE_PERIOD"), out var ms) ? ms : 5000;
Task.Delay(gracePeriodMs).Wait();
server?.ShutdownAsync().Wait();
};
server = new Server
{
Services =
{
boundService,
#if DEBUG || DEVELOPMENT
// Reflection lets tools like grpcurl introspect the service without a
// local .proto file deliberately excluded from release builds.
Grpc.Reflection.V1Alpha.ServerReflection.BindService(
new ReflectionServiceImpl(ProductLookup.Descriptor)),
#endif
},
Ports = { new ServerPort(options.Host, options.Port, ServerCredentials.Insecure) }
};
server.Start();
Shutdown.WaitOne();
}
}
Why Teams Choose This Over ASP.NET Core Hosting
| Reason | Detail |
|---|---|
| Legacy continuity | Services written before ASP.NET Core's gRPC support matured or before .NET Core existed at all, were built on Grpc.Core directly and never migrated |
| Thinner runtime | No HTTP middleware pipeline, routing or MVC-adjacent machinery to carry around when the process does nothing but serve RPCs |
| Fine-grained lifecycle control | Full manual control over startup order, warmup and shutdown sequencing, which some high-throughput or latency-sensitive services want explicitly rather than delegated to the framework |
| Multiple services/ports on one process | Binding several unrelated gRPC contracts (and even a second, debug-only port) onto a single Server instance is a one-line addition to the Services/Ports collections |
The trade-off is that you lose out on conveniences ASP.NET Core hosting gives you for free built-in health check endpoints, HTTP/JSON transcoding, easier integration with IHostedService background workers and a hosting model your team likely already understands from other ASP.NET Core services. For a brand-new microservice today, most teams still default to the ASP.NET Core hosting model shown in Part 3; reach for the raw Grpc.Core.Server approach mainly when you're maintaining an existing service built this way or you have a specific, measured reason to avoid the ASP.NET Core pipeline.
Production Details Worth Copying Either Way
Regardless of which hosting model you pick, a few patterns from the example above are worth adopting broadly:
- Gate reflection behind
DEBUG/DEVELOPMENT. Server reflection is extremely convenient for local testing withgrpcurlbut shouldn't be exposed in production, since it lets anyone enumerate your entire API surface. - Handle a shutdown grace period explicitly. When running under Kubernetes, a pod can receive
SIGTERMwhile requests are still in flight. Delaying shutdown briefly (often via an environment variable likeSTOP_GRACE_PERIOD) before callingShutdownAsync()gives the load balancer time to stop routing new traffic and lets existing calls finish cleanly. - Warm up expensive dependencies before opening the port. If your service's first real request is also its first database connection, first cache client initialization or first JIT compilation of a hot path, that request pays for all of it. Running a synthetic warmup call against your own service during startup before
server.Start()smooths out that first-request latency spike, which matters a lot for autoscalers that react to latency. - Attach interceptors once, globally, rather than sprinkling logging/tracing/auth logic through individual RPC methods. Whether you're on
Grpc.Core.Interceptors.Interceptoror ASP.NET Core's server interceptor model, the principle is the same: cross-cutting concerns belong in one place, not repeated per method.
Wrapping Up
Microservices earn their complexity when independent scaling, independent deployment or organizational boundaries genuinely require them not by default. Once you're in a microservices architecture, gRPC is one of the strongest options for internal service-to-service communication thanks to its strict contracts, compact binary payloads and native streaming support.
The walkthrough above covers the full loop: defining a contract, generating strongly typed code, implementing the service, calling it from a client and the production concerns that turn a working prototype into something deployable. From here, the natural next steps are adding more RPCs to the same contract, introducing server-side streaming for larger payloads and wiring the service into your broader system with proper observability and deployment automation.
