.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:
| Property | Description | Example |
|---|---|---|
project | Service domain name for entities and services | Shopping Cart |
suffix | Used with project name for package naming | Service |
group-prefix | Package naming prefix | com.company |
team-name | Team ownership identifier | Growth |
service-port | gRPC service port | 5000 |
management-port | Health check and metrics port | 5001 |
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
-
Generate the service:
archetect render git@github.com:p6m-archetypes/dotnet-grpc-service-basic.archetype.git -
Run locally with Docker Compose:
docker-compose up -d -
Apply database migrations:
dotnet ef database update -
Start the service:
dotnet run --project src/ServiceName.Api -
Test gRPC endpoints:
# Using grpcurl
grpcurl -plaintext localhost:5000 list
grpcurl -plaintext localhost:5000 shoppingcart.ShoppingCartService/GetItems -
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