11 KiB
Source Generators
How automatic gRPC service implementation generation works.
Overview
Svrnty.CQRS.Grpc.Generators uses Roslyn source generators to automatically create gRPC service implementations at compile time. This eliminates boilerplate code and ensures type safety between .proto definitions and C# handlers.
Benefits:
- ✅ Zero boilerplate - No manual service implementation
- ✅ Compile-time safety - Errors caught during build
- ✅ Type checking - Ensures proto and C# types match
- ✅ Automatic updates - Regenerates when proto changes
- ✅ IDE support - IntelliSense for generated code
How It Works
┌──────────────────────────────────┐
│ Build Process │
├──────────────────────────────────┤
│ 1. Compile .proto files │
│ 2. Generate C# types (Grpc.Tools)│
│ 3. Source generator runs │
│ - Reads proto definitions │
│ - Discovers CQRS handlers │
│ - Generates service impls │
│ 4. Compile generated code │
│ 5. Build completes │
└──────────────────────────────────┘
Generated Code
CommandServiceImpl
From .proto:
service CommandService {
rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
rpc DeleteUser (DeleteUserCommand) returns (google.protobuf.Empty);
}
Generated C# (simplified):
public class CommandServiceImpl : CommandService.CommandServiceBase
{
private readonly IServiceProvider _serviceProvider;
public CommandServiceImpl(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public override async Task<CreateUserResponse> CreateUser(
CreateUserCommand request,
ServerCallContext context)
{
using var scope = _serviceProvider.CreateScope();
// Get validator
var validator = scope.ServiceProvider
.GetService<IValidator<CreateUserCommand>>();
// Validate
if (validator != null)
{
var validationResult = await validator.ValidateAsync(
request,
context.CancellationToken);
if (!validationResult.IsValid)
{
throw CreateValidationException(validationResult);
}
}
// Get handler
var handler = scope.ServiceProvider
.GetRequiredService<ICommandHandler<CreateUserCommand, int>>();
// Execute
var userId = await handler.HandleAsync(request, context.CancellationToken);
// Return response
return new CreateUserResponse { UserId = userId };
}
public override async Task<Empty> DeleteUser(
DeleteUserCommand request,
ServerCallContext context)
{
using var scope = _serviceProvider.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<ICommandHandler<DeleteUserCommand>>();
await handler.HandleAsync(request, context.CancellationToken);
return new Empty();
}
private RpcException CreateValidationException(ValidationResult validationResult)
{
var badRequest = new BadRequest();
foreach (var error in validationResult.Errors)
{
badRequest.FieldViolations.Add(new FieldViolation
{
Field = ToCamelCase(error.PropertyName),
Description = error.ErrorMessage
});
}
var status = new Google.Rpc.Status
{
Code = (int)Code.InvalidArgument,
Message = "Validation failed",
Details = { Any.Pack(badRequest) }
};
return status.ToRpcException();
}
}
QueryServiceImpl
From .proto:
service QueryService {
rpc GetUser (GetUserQuery) returns (UserDto);
}
Generated C# (simplified):
public class QueryServiceImpl : QueryService.QueryServiceBase
{
private readonly IServiceProvider _serviceProvider;
public QueryServiceImpl(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public override async Task<UserDto> GetUser(
GetUserQuery request,
ServerCallContext context)
{
using var scope = _serviceProvider.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IQueryHandler<GetUserQuery, UserDto>>();
var result = await handler.HandleAsync(request, context.CancellationToken);
return result;
}
}
Type Mapping
Proto to C# Mapping
The source generator maps .proto types to C# CQRS types:
| Proto Message | C# Type | Handler Type |
|---|---|---|
CreateUserCommand |
CreateUserCommand |
ICommandHandler<CreateUserCommand, TResult> |
DeleteUserCommand |
DeleteUserCommand |
ICommandHandler<DeleteUserCommand> |
GetUserQuery |
GetUserQuery |
IQueryHandler<GetUserQuery, UserDto> |
UserDto |
UserDto |
Return type |
Response Type Detection
Command with result:
rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
Generator looks for: ICommandHandler<CreateUserCommand, TResult>
Command without result:
rpc DeleteUser (DeleteUserCommand) returns (google.protobuf.Empty);
Generator looks for: ICommandHandler<DeleteUserCommand>
Build Integration
Project Configuration
.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- Include .proto files -->
<Protobuf Include="Protos\**\*.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<!-- Source generator package -->
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0" />
<!-- gRPC packages -->
<PackageReference Include="Grpc.AspNetCore" Version="2.68.0" />
<PackageReference Include="Grpc.Tools" Version="2.76.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
Build Output
During build, you'll see:
Restoring NuGet packages...
Generating C# from .proto files...
Running source generators...
- Svrnty.CQRS.Grpc.Generators
Generated: CommandServiceImpl.g.cs
Generated: QueryServiceImpl.g.cs
Compiling...
Build succeeded.
Viewing Generated Code
In Visual Studio
- Expand project in Solution Explorer
- Expand "Dependencies" → "Analyzers" → "Svrnty.CQRS.Grpc.Generators"
- View generated files
In Rider
- Navigate to a service usage
- Right-click → "Go to Declaration"
- View generated implementation
Output Directory
Generated files are written to:
obj/Debug/net10.0/generated/Svrnty.CQRS.Grpc.Generators/
Manual Inspection
# View generated files
cat obj/Debug/net10.0/generated/Svrnty.CQRS.Grpc.Generators/*.cs
Customization
Disabling Generation
Temporarily disable the generator:
.csproj:
<ItemGroup>
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0">
<!-- Disable analyzer/generator -->
<PrivateAssets>all</PrivateAssets>
<ExcludeAssets>analyzers</ExcludeAssets>
</PackageReference>
</ItemGroup>
Excluding Specific Services
Use [GrpcIgnore] attribute:
[GrpcIgnore]
public record InternalCommand
{
public int Id { get; init; }
}
No gRPC RPC will be generated for this command.
Error Handling
Common Build Errors
Error: Handler not found
Message:
Could not find ICommandHandler<CreateUserCommand, int>
Cause: Handler not registered in DI
Solution:
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
Error: Type mismatch
Message:
CreateUserCommand in .proto does not match C# type
Cause: Property names don't match
Proto:
message CreateUserCommand {
string user_name = 1; // snake_case
}
C#:
public record CreateUserCommand
{
public string UserName { get; init; } // PascalCase - OK
}
Properties match (case-insensitive) - this should work.
Proto:
message CreateUserCommand {
string name = 1;
}
C#:
public record CreateUserCommand
{
public string FullName { get; init; } // Different name - ERROR
}
Solution: Ensure property names match (case-insensitive).
Error: Circular dependency
Message:
Circular reference detected in message definitions
Cause: Proto messages reference each other
Solution: Break circular reference using separate DTOs.
Performance
Compile-Time Generation
- Zero runtime overhead - Code generated at build time
- No reflection - Direct method calls
- Optimized - IL identical to hand-written code
Incremental Build
Source generators support incremental builds:
- Only regenerate when .proto files change
- Fast rebuilds when only C# code changes
Troubleshooting
Generator Not Running
Symptoms: No service implementations generated
Checks:
-
Verify package installed:
<PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0" /> -
Clean and rebuild:
dotnet clean dotnet build -
Check build output for generator messages
Generated Code Not Visible
Symptoms: Can't find generated classes in IDE
Solutions:
- Close and reopen solution
- Rebuild project
- Restart IDE
- Check obj/Debug/net10.0/generated/ directory
Build Warnings
Warning: "Generator produced no output"
Cause: No matching handlers found
Solution: Ensure handlers are registered before build.
Best Practices
✅ DO
- Keep .proto files in Protos/ directory
- Use GrpcServices="Server" in .csproj
- Register all handlers in DI
- Clean build after .proto changes
- Review generated code occasionally
❌ DON'T
- Don't modify generated files (they'll be overwritten)
- Don't commit generated files to source control
- Don't disable generators without reason
- Don't ignore build warnings
Advanced Scenarios
Multiple Proto Files
<ItemGroup>
<Protobuf Include="Protos\commands.proto" GrpcServices="Server" />
<Protobuf Include="Protos\queries.proto" GrpcServices="Server" />
<Protobuf Include="Protos\common.proto" GrpcServices="None" />
</ItemGroup>
Shared Proto Files
<ItemGroup>
<!-- Reference shared proto from another project -->
<Protobuf Include="..\Shared.Contracts\Protos\*.proto"
GrpcServices="Client"
Link="Protos\Shared\%(Filename)%(Extension)" />
</ItemGroup>