Skip to main content

.NET gRPC Service Basic

A production-ready .NET gRPC service archetype that provides high-performance, strongly-typed RPC communication with Entity Framework Core, comprehensive testing, and enterprise-grade features.

Overview

This archetype generates a fully-functional .NET gRPC service optimized for inter-service communication in microservice architectures. It includes Protocol Buffers for service definition, Entity Framework Core for data persistence, and comprehensive testing infrastructure.

Technology Stack

  • .NET 8: Latest long-term support version
  • gRPC: High-performance RPC framework
  • Protocol Buffers: Interface definition language
  • Entity Framework Core: Object-relational mapping
  • PostgreSQL: Primary database
  • Testcontainers .NET: Integration testing
  • Docker: Containerization
  • k6: Load testing for gRPC and HTTP endpoints
  • Tilt: Local Kubernetes development

Key Features

Core Functionality

  • gRPC Services: High-performance binary protocol communication
  • Protocol Buffer Definitions: Strongly-typed service contracts
  • CRUD Operations: Complete Create, Read, Update, Delete operations
  • Entity Framework Integration: Code-first database approach
  • gRPC Stub Publication: Shareable client libraries

Production Features

  • Health Checks: gRPC health checking protocol
  • Logging: Structured logging with Serilog
  • Configuration: Environment-based configuration management
  • Metrics: gRPC and application performance monitoring
  • Docker Support: Optimized multi-stage builds

Performance Features

  • Binary Serialization: Efficient Protocol Buffer serialization
  • HTTP/2: Multiplexed connections and streaming
  • Connection Pooling: Optimized database connections
  • Async Operations: Non-blocking I/O operations

Project Structure

dotnet-grpc-service/
├── src/
│ ├── ServiceName.Api/ # gRPC service implementation
│ │ ├── Services/ # gRPC service implementations
│ │ ├── Protos/ # Protocol buffer definitions
│ │ ├── Health/ # Health check implementations
│ │ ├── Configuration/ # Service configuration
│ │ └── Program.cs # Application entry point
│ ├── ServiceName.Core/ # Business logic
│ │ ├── Entities/ # Domain entities
│ │ ├── Services/ # Business services
│ │ ├── Interfaces/ # Service contracts
│ │ └── Validators/ # Input validation
│ ├── ServiceName.Infrastructure/ # Data access layer
│ │ ├── Data/ # Entity Framework context
│ │ ├── Repositories/ # Data repositories
│ │ ├── Migrations/ # Database migrations
│ │ └── Configuration/ # EF configuration
│ └── ServiceName.Client/ # gRPC client library
│ ├── Generated/ # Generated gRPC client code
│ └── Extensions/ # Client extensions
├── tests/
│ ├── ServiceName.Api.Tests/ # gRPC integration tests
│ ├── ServiceName.Core.Tests/ # Unit tests
│ └── ServiceName.LoadTests/ # k6 load tests
├── k8s/ # Kubernetes manifests
├── scripts/ # Build and deployment scripts
├── Dockerfile # Multi-stage Docker build
├── Tiltfile # Local development with Tilt
└── docker-compose.yml # Local development stack

Configuration & Prompts

During archetype generation, you'll be prompted for:

PropertyDescriptionExample
projectService domain name for entities and servicesShopping Cart
suffixUsed with project name for package namingService
group-prefixPackage naming prefixcom.company
team-nameTeam ownership identifierGrowth
service-portgRPC service port5000
management-portHealth check and metrics port5001

Protocol Buffer Definition

Service Definition

syntax = "proto3";

option csharp_namespace = "ServiceName.Api";

package shoppingcart;

// Shopping Cart service definition
service ShoppingCartService {
// Gets all shopping cart items
rpc GetItems(GetItemsRequest) returns (GetItemsResponse);

// Gets a specific shopping cart item by ID
rpc GetItem(GetItemRequest) returns (GetItemResponse);

// Creates a new shopping cart item
rpc CreateItem(CreateItemRequest) returns (CreateItemResponse);

// Updates an existing shopping cart item
rpc UpdateItem(UpdateItemRequest) returns (UpdateItemResponse);

// Deletes a shopping cart item
rpc DeleteItem(DeleteItemRequest) returns (DeleteItemResponse);

// Streaming endpoint for real-time updates
rpc WatchItems(WatchItemsRequest) returns (stream ItemUpdate);
}

// Message definitions
message ShoppingCartItem {
int32 id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 quantity = 5;
int64 created_at = 6;
int64 updated_at = 7;
}

message GetItemsRequest {
int32 page_size = 1;
string page_token = 2;
string filter = 3;
}

message GetItemsResponse {
repeated ShoppingCartItem items = 1;
string next_page_token = 2;
int32 total_count = 3;
}

message GetItemRequest {
int32 id = 1;
}

message GetItemResponse {
ShoppingCartItem item = 1;
}

message CreateItemRequest {
string name = 1;
string description = 2;
double price = 3;
int32 quantity = 4;
}

message CreateItemResponse {
ShoppingCartItem item = 1;
}

message UpdateItemRequest {
int32 id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 quantity = 5;
}

message UpdateItemResponse {
ShoppingCartItem item = 1;
}

message DeleteItemRequest {
int32 id = 1;
}

message DeleteItemResponse {
bool success = 1;
}

message WatchItemsRequest {
string filter = 1;
}

message ItemUpdate {
enum UpdateType {
CREATED = 0;
UPDATED = 1;
DELETED = 2;
}

UpdateType type = 1;
ShoppingCartItem item = 2;
}

gRPC Service Implementation

Service Implementation

public class ShoppingCartServiceImpl : ShoppingCartService.ShoppingCartServiceBase
{
private readonly IShoppingCartItemService _itemService;
private readonly ILogger<ShoppingCartServiceImpl> _logger;
private readonly IMapper _mapper;

public ShoppingCartServiceImpl(
IShoppingCartItemService itemService,
ILogger<ShoppingCartServiceImpl> logger,
IMapper mapper)
{
_itemService = itemService;
_logger = logger;
_mapper = mapper;
}

public override async Task<GetItemsResponse> GetItems(
GetItemsRequest request,
ServerCallContext context)
{
try
{
_logger.LogInformation("Getting shopping cart items with filter: {Filter}", request.Filter);

var items = await _itemService.GetItemsAsync(
request.PageSize,
request.PageToken,
request.Filter);

var response = new GetItemsResponse
{
NextPageToken = items.NextPageToken,
TotalCount = items.TotalCount
};

response.Items.AddRange(items.Items.Select(_mapper.Map<ShoppingCartItem>));

return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting shopping cart items");
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"));
}
}

public override async Task<GetItemResponse> GetItem(
GetItemRequest request,
ServerCallContext context)
{
try
{
var item = await _itemService.GetItemByIdAsync(request.Id);

if (item == null)
{
throw new RpcException(new Status(StatusCode.NotFound, $"Item with ID {request.Id} not found"));
}

return new GetItemResponse
{
Item = _mapper.Map<ShoppingCartItem>(item)
};
}
catch (RpcException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting shopping cart item {Id}", request.Id);
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"));
}
}

public override async Task<CreateItemResponse> CreateItem(
CreateItemRequest request,
ServerCallContext context)
{
try
{
// Validate request
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Name is required"));
}

if (request.Price <= 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Price must be greater than 0"));
}

if (request.Quantity <= 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Quantity must be greater than 0"));
}

var createDto = new CreateShoppingCartItemDto
{
Name = request.Name,
Description = request.Description,
Price = (decimal)request.Price,
Quantity = request.Quantity
};

var createdItem = await _itemService.CreateItemAsync(createDto);

return new CreateItemResponse
{
Item = _mapper.Map<ShoppingCartItem>(createdItem)
};
}
catch (RpcException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating shopping cart item");
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"));
}
}

public override async Task<UpdateItemResponse> UpdateItem(
UpdateItemRequest request,
ServerCallContext context)
{
try
{
var updateDto = new UpdateShoppingCartItemDto
{
Name = request.Name,
Description = request.Description,
Price = request.Price > 0 ? (decimal)request.Price : null,
Quantity = request.Quantity > 0 ? request.Quantity : null
};

var updatedItem = await _itemService.UpdateItemAsync(request.Id, updateDto);

if (updatedItem == null)
{
throw new RpcException(new Status(StatusCode.NotFound, $"Item with ID {request.Id} not found"));
}

return new UpdateItemResponse
{
Item = _mapper.Map<ShoppingCartItem>(updatedItem)
};
}
catch (RpcException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating shopping cart item {Id}", request.Id);
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"));
}
}

public override async Task<DeleteItemResponse> DeleteItem(
DeleteItemRequest request,
ServerCallContext context)
{
try
{
var success = await _itemService.DeleteItemAsync(request.Id);

return new DeleteItemResponse
{
Success = success
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting shopping cart item {Id}", request.Id);
throw new RpcException(new Status(StatusCode.Internal, "Internal server error"));
}
}

public override async Task WatchItems(
WatchItemsRequest request,
IServerStreamWriter<ItemUpdate> responseStream,
ServerCallContext context)
{
try
{
_logger.LogInformation("Starting item watch with filter: {Filter}", request.Filter);

// Subscribe to item updates (implementation depends on your event system)
await foreach (var update in _itemService.WatchItemsAsync(request.Filter, context.CancellationToken))
{
var itemUpdate = new ItemUpdate
{
Type = MapUpdateType(update.Type),
Item = _mapper.Map<ShoppingCartItem>(update.Item)
};

await responseStream.WriteAsync(itemUpdate);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in WatchItems stream");
throw new RpcException(new Status(StatusCode.Internal, "Stream error"));
}
}

private ItemUpdate.Types.UpdateType MapUpdateType(UpdateType type)
{
return type switch
{
UpdateType.Created => ItemUpdate.Types.UpdateType.Created,
UpdateType.Updated => ItemUpdate.Types.UpdateType.Updated,
UpdateType.Deleted => ItemUpdate.Types.UpdateType.Deleted,
_ => throw new ArgumentException($"Unknown update type: {type}")
};
}
}

AutoMapper Configuration

public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Core.Entities.ShoppingCartItem, ShoppingCartItem>()
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => src.CreatedAt.ToUnixTimeSeconds()))
.ForMember(dest => dest.UpdatedAt, opt => opt.MapFrom(src => src.UpdatedAt.ToUnixTimeSeconds()))
.ForMember(dest => dest.Price, opt => opt.MapFrom(src => (double)src.Price));

CreateMap<ShoppingCartItem, Core.Entities.ShoppingCartItem>()
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => DateTimeOffset.FromUnixTimeSeconds(src.CreatedAt).DateTime))
.ForMember(dest => dest.UpdatedAt, opt => opt.MapFrom(src => DateTimeOffset.FromUnixTimeSeconds(src.UpdatedAt).DateTime))
.ForMember(dest => dest.Price, opt => opt.MapFrom(src => (decimal)src.Price));
}
}

gRPC Client Usage

Client Configuration

// Dependency injection setup
services.AddGrpcClient<ShoppingCartService.ShoppingCartServiceClient>(options =>
{
options.Address = new Uri("https://shopping-cart-service:5000");
})
.ConfigureChannel(options =>
{
options.HttpHandler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
EnableMultipleHttp2Connections = true
};
});

Client Usage Example

public class ShoppingCartClient
{
private readonly ShoppingCartService.ShoppingCartServiceClient _client;
private readonly ILogger<ShoppingCartClient> _logger;

public ShoppingCartClient(
ShoppingCartService.ShoppingCartServiceClient client,
ILogger<ShoppingCartClient> logger)
{
_client = client;
_logger = logger;
}

public async Task<List<ShoppingCartItem>> GetAllItemsAsync()
{
try
{
var request = new GetItemsRequest
{
PageSize = 100,
Filter = ""
};

var response = await _client.GetItemsAsync(request);
return response.Items.ToList();
}
catch (RpcException ex)
{
_logger.LogError(ex, "gRPC error getting items: {Status}", ex.Status);
throw;
}
}

public async Task<ShoppingCartItem> CreateItemAsync(string name, string description, double price, int quantity)
{
try
{
var request = new CreateItemRequest
{
Name = name,
Description = description,
Price = price,
Quantity = quantity
};

var response = await _client.CreateItemAsync(request);
return response.Item;
}
catch (RpcException ex)
{
_logger.LogError(ex, "gRPC error creating item: {Status}", ex.Status);
throw;
}
}

public async Task WatchItemUpdatesAsync(CancellationToken cancellationToken = default)
{
try
{
var request = new WatchItemsRequest { Filter = "" };

using var streamingCall = _client.WatchItems(request, cancellationToken: cancellationToken);

await foreach (var update in streamingCall.ResponseStream.ReadAllAsync(cancellationToken))
{
_logger.LogInformation("Received item update: {Type} for item {Id}",
update.Type, update.Item.Id);

// Handle the update
await HandleItemUpdate(update);
}
}
catch (RpcException ex)
{
_logger.LogError(ex, "gRPC streaming error: {Status}", ex.Status);
throw;
}
}

private async Task HandleItemUpdate(ItemUpdate update)
{
switch (update.Type)
{
case ItemUpdate.Types.UpdateType.Created:
_logger.LogInformation("Item created: {Name}", update.Item.Name);
break;
case ItemUpdate.Types.UpdateType.Updated:
_logger.LogInformation("Item updated: {Name}", update.Item.Name);
break;
case ItemUpdate.Types.UpdateType.Deleted:
_logger.LogInformation("Item deleted: {Id}", update.Item.Id);
break;
}
}
}

Testing Strategy

gRPC Integration Tests

public class ShoppingCartServiceTests : IClassFixture<GrpcTestFixture<Program>>
{
private readonly GrpcTestFixture<Program> _fixture;
private readonly ShoppingCartService.ShoppingCartServiceClient _client;

public ShoppingCartServiceTests(GrpcTestFixture<Program> fixture)
{
_fixture = fixture;
_client = _fixture.CreateGrpcClient<ShoppingCartService.ShoppingCartServiceClient>();
}

[Fact]
public async Task GetItems_ReturnsEmptyList_WhenNoItemsExist()
{
// Arrange
var request = new GetItemsRequest();

// Act
var response = await _client.GetItemsAsync(request);

// Assert
Assert.Empty(response.Items);
Assert.Equal(0, response.TotalCount);
}

[Fact]
public async Task CreateItem_ReturnsCreatedItem_WhenValidRequest()
{
// Arrange
var request = new CreateItemRequest
{
Name = "Test Item",
Description = "Test Description",
Price = 19.99,
Quantity = 2
};

// Act
var response = await _client.CreateItemAsync(request);

// Assert
Assert.NotNull(response.Item);
Assert.Equal(request.Name, response.Item.Name);
Assert.Equal(request.Price, response.Item.Price);
Assert.True(response.Item.Id > 0);
}

[Fact]
public async Task CreateItem_ThrowsInvalidArgument_WhenNameIsEmpty()
{
// Arrange
var request = new CreateItemRequest
{
Name = "",
Price = 19.99,
Quantity = 2
};

// Act & Assert
var exception = await Assert.ThrowsAsync<RpcException>(() => _client.CreateItemAsync(request));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}

[Fact]
public async Task WatchItems_ReceivesUpdates_WhenItemsChange()
{
// Arrange
var request = new WatchItemsRequest();
var updates = new List<ItemUpdate>();

// Act
using var streamingCall = _client.WatchItems(request);

// Create an item in another thread to trigger an update
_ = Task.Run(async () =>
{
await Task.Delay(100);
await _client.CreateItemAsync(new CreateItemRequest
{
Name = "Streaming Test Item",
Price = 9.99,
Quantity = 1
});
});

// Read the first update
await foreach (var update in streamingCall.ResponseStream.ReadAllAsync())
{
updates.Add(update);
if (updates.Count >= 1) break;
}

// Assert
Assert.Single(updates);
Assert.Equal(ItemUpdate.Types.UpdateType.Created, updates[0].Type);
Assert.Equal("Streaming Test Item", updates[0].Item.Name);
}
}

Load Testing with k6

import grpc from 'k6/net/grpc';
import { check, sleep } from 'k6';

const client = new grpc.Client();
client.load(['../src/ServiceName.Api/Protos'], 'shoppingcart.proto');

export let options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '1m', target: 50 },
{ duration: '30s', target: 0 },
],
};

export default function () {
client.connect('localhost:5000', { plaintext: true });

// Test CreateItem
const createResponse = client.invoke('shoppingcart.ShoppingCartService/CreateItem', {
name: 'Load Test Item',
description: 'Created during load test',
price: 9.99,
quantity: 1
});

check(createResponse, {
'CreateItem successful': (r) => r.status === grpc.StatusOK,
'CreateItem response time < 100ms': (r) => r.time < 100,
});

if (createResponse.status === grpc.StatusOK) {
const itemId = createResponse.message.item.id;

// Test GetItem
const getResponse = client.invoke('shoppingcart.ShoppingCartService/GetItem', {
id: itemId
});

check(getResponse, {
'GetItem successful': (r) => r.status === grpc.StatusOK,
'GetItem response time < 50ms': (r) => r.time < 50,
});
}

client.close();
sleep(1);
}

Deployment

Docker Configuration

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 5000
EXPOSE 5001

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/ServiceName.Api/ServiceName.Api.csproj", "src/ServiceName.Api/"]
COPY ["src/ServiceName.Core/ServiceName.Core.csproj", "src/ServiceName.Core/"]
COPY ["src/ServiceName.Infrastructure/ServiceName.Infrastructure.csproj", "src/ServiceName.Infrastructure/"]
RUN dotnet restore "src/ServiceName.Api/ServiceName.Api.csproj"
COPY . .
WORKDIR "/src/src/ServiceName.Api"
RUN dotnet build "ServiceName.Api.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ServiceName.Api.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ServiceName.Api.dll"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: shopping-cart-grpc-service
spec:
replicas: 3
selector:
matchLabels:
app: shopping-cart-grpc-service
template:
metadata:
labels:
app: shopping-cart-grpc-service
spec:
containers:
- name: shopping-cart-grpc-service
image: your-registry/shopping-cart-grpc-service:latest
ports:
- containerPort: 5000
name: grpc
- containerPort: 5001
name: management
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: db-secret
key: connection-string
livenessProbe:
grpc:
port: 5000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
grpc:
port: 5000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: shopping-cart-grpc-service
spec:
selector:
app: shopping-cart-grpc-service
ports:
- name: grpc
port: 5000
targetPort: 5000
- name: management
port: 5001
targetPort: 5001

Quick Start

  1. Generate the service:

    archetect render git@github.com:p6m-archetypes/dotnet-grpc-service-basic.archetype.git
  2. Run locally with Docker Compose:

    docker-compose up -d
  3. Apply database migrations:

    dotnet ef database update
  4. Start the service:

    dotnet run --project src/ServiceName.Api
  5. Test gRPC endpoints:

    # Using grpcurl
    grpcurl -plaintext localhost:5000 list
    grpcurl -plaintext localhost:5000 shoppingcart.ShoppingCartService/GetItems
  6. Run tests:

    dotnet test

Best Practices

gRPC Design

  • Use semantic versioning for proto files
  • Design for backward compatibility
  • Use streaming for real-time updates
  • Implement proper error handling

Performance

  • Use connection pooling
  • Implement client-side load balancing
  • Monitor gRPC metrics
  • Optimize serialization

Security

  • Use TLS in production
  • Implement authentication
  • Validate all inputs
  • Use deadline/timeout settings

Monitoring

  • Track gRPC method latency
  • Monitor connection health
  • Log service interactions
  • Set up alerting for errors