Unary RPC — Request-Response gRPC in ASP.NET Core
Implement unary gRPC endpoints in ASP.NET Core: service implementation, error handling with StatusCode, authentication, dependency injection, and calling gRPC services from .NET clients.
Unary RPC — The Simplest Pattern
Unary RPC: one request, one response. Equivalent to a standard HTTP call but over gRPC.
Client: gRPC stub call → sends CreatePatientRequest
Server: executes service method → returns CreatePatientResponse
One round trip, one resultService Implementation
// Generated from .proto — you implement the abstract methods
// Generated class: SystemForge.Clinical.Grpc.V1.ClinicalPatientService.ClinicalPatientServiceBase
// Infrastructure/Grpc/ClinicalPatientGrpcService.cs
public sealed class ClinicalPatientGrpcService
: ClinicalPatientService.ClinicalPatientServiceBase
{
private readonly CreatePatientHandler _createHandler;
private readonly GetPatientHandler _getHandler;
private readonly ILogger<ClinicalPatientGrpcService> _logger;
public ClinicalPatientGrpcService(
CreatePatientHandler createHandler,
GetPatientHandler getHandler,
ILogger<ClinicalPatientGrpcService> logger)
{
_createHandler = createHandler;
_getHandler = getHandler;
_logger = logger;
}
public override async Task<CreatePatientResponse> CreatePatient(
CreatePatientRequest request,
ServerCallContext context)
{
_logger.LogInformation(
"gRPC CreatePatient: MRN={MRN}", request.Mrn);
var cmd = new CreatePatientCommand(
request.Name,
DateOnly.Parse(request.DateOfBirth),
request.Mrn);
var result = await _createHandler.Handle(cmd, context.CancellationToken);
if (result.IsFailure)
{
throw result.Error.Code switch
{
"Patient.MRNAlreadyExists" =>
new RpcException(new Status(StatusCode.AlreadyExists, result.Error.Description)),
"Patient.InvalidDateOfBirth" =>
new RpcException(new Status(StatusCode.InvalidArgument, result.Error.Description)),
_ =>
new RpcException(new Status(StatusCode.Internal, "An error occurred."))
};
}
return new CreatePatientResponse { PatientId = result.Value.ToString() };
}
public override async Task<GetPatientResponse> GetPatient(
GetPatientRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.PatientId, out var id))
throw new RpcException(new Status(StatusCode.InvalidArgument,
"PatientId must be a valid UUID."));
var result = await _getHandler.Handle(
new GetPatientQuery(id), context.CancellationToken);
if (result.IsFailure)
throw new RpcException(new Status(StatusCode.NotFound,
$"Patient {request.PatientId} not found."));
var p = result.Value;
return new GetPatientResponse
{
Id = p.Id.ToString(),
Name = p.Name,
Mrn = p.MRN,
Department = p.Department,
IsActive = p.IsActive,
};
}
}Server Registration
// Program.cs
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4MB
options.MaxSendMessageSize = 4 * 1024 * 1024; // 4MB
});
builder.Services.AddGrpcReflection(); // allows grpcurl and Postman discovery
// Register service handlers (same as HTTP API handlers)
builder.Services.AddScoped<CreatePatientHandler>();
builder.Services.AddScoped<GetPatientHandler>();
var app = builder.Build();
app.MapGrpcService<ClinicalPatientGrpcService>();
if (app.Environment.IsDevelopment())
app.MapGrpcReflectionService(); // enables discovery toolsgRPC Status Codes
gRPC status codes — use instead of HTTP status codes:
OK = 0 ← success
CANCELLED = 1 ← client cancelled the request
INVALID_ARGUMENT = 3 ← bad input (HTTP 400)
NOT_FOUND = 5 ← resource not found (HTTP 404)
ALREADY_EXISTS = 6 ← duplicate resource (HTTP 409)
PERMISSION_DENIED = 7 ← auth failure (HTTP 403)
UNAUTHENTICATED = 16 ← not authenticated (HTTP 401)
RESOURCE_EXHAUSTED = 8 ← rate limited (HTTP 429)
INTERNAL = 13 ← unexpected error (HTTP 500)
UNAVAILABLE = 14 ← service unavailable (HTTP 503)
DEADLINE_EXCEEDED = 4 ← timeout (HTTP 408/504)Authentication in gRPC
// Program.cs — add JWT bearer auth for gRPC
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Same JWT configuration as REST API
options.TokenValidationParameters = /* ... */;
});
builder.Services.AddAuthorization();
// Service: read the user from the context
public override async Task<CreatePatientResponse> CreatePatient(
CreatePatientRequest request,
ServerCallContext context)
{
// Access the authenticated user via context
var user = context.GetHttpContext().User;
var userId = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (!user.IsInRole("Doctor"))
throw new RpcException(new Status(StatusCode.PermissionDenied,
"Only doctors can create patients."));
// ...
}
// Or use policy-based auth on the service class
[Authorize(Policy = "DoctorsOnly")]
public sealed class ClinicalPatientGrpcService
: ClinicalPatientService.ClinicalPatientServiceBase { }gRPC Client in .NET
// NuGet: Grpc.Net.Client, Google.Protobuf, Grpc.Tools (client-side)
builder.Services.AddGrpcClient<ClinicalPatientService.ClinicalPatientServiceClient>(options =>
{
options.Address = new Uri("https://clinical-api.systemforge.internal");
});
// Or manual client creation
using var channel = GrpcChannel.ForAddress("https://clinical-api.internal");
var client = new ClinicalPatientService.ClinicalPatientServiceClient(channel);
// Call the service
var request = new CreatePatientRequest
{
Name = "John Smith",
Mrn = "MRN-001",
DateOfBirth = "1985-03-15"
};
try
{
var response = await client.CreatePatientAsync(request,
deadline: DateTime.UtcNow.AddSeconds(10));
Console.WriteLine($"Created: {response.PatientId}");
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
{
Console.WriteLine($"MRN already exists: {ex.Status.Detail}");
}Deadlines
// Always set a deadline — prevents indefinite hanging
var response = await client.GetPatientAsync(
new GetPatientRequest { PatientId = id },
deadline: DateTime.UtcNow.AddSeconds(5), // absolute deadline
cancellationToken: cancellationToken); // or CancellationToken
// Client sees: RpcException with StatusCode.DeadlineExceeded if exceeded
// Server sees: context.CancellationToken cancelled — clean up earlyProduction issue I've seen: A gRPC client calling a downstream pharmacy service had no deadline set. The pharmacy service had a bug that caused some requests to hang indefinitely. The clinical API accumulated thousands of in-flight gRPC calls, each holding a thread. Within minutes, the thread pool was exhausted. Adding a 5-second deadline meant the call failed fast with
DeadlineExceededrather than hanging forever.
Red Flag / Green Answer
Red Flag: "We throw Exception in our gRPC service methods — the client sees StatusCode.Internal with no details."
Exception(notRpcException) causes the framework to returnINTERNALwith a generic message. The client cannot distinguish "patient not found" from "database down" — both look like server errors. UseRpcExceptionwith the appropriateStatusCode.
Green Answer:
Map Result failures to
RpcExceptionwith the rightStatusCode.PatientErrors.NotFound→StatusCode.NotFound.PatientErrors.MRNAlreadyExists→StatusCode.AlreadyExists. Clients can handle these discriminately.
Key Takeaway
Unary gRPC in ASP.NET Core: inherit from the generated service base, inject application handlers, delegate to them. Map Result failures to
RpcExceptionwith the correctStatusCode— gRPC status codes replace HTTP status codes. Always set deadlines on gRPC calls — missing deadlines cause thread pool exhaustion under failure scenarios. Use gRPC reflection in development to enable discovery tools like grpcurl and Postman.
gRPC Knowledge Check
5 questions · Test what you just learned · Instant explanations
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.