dotnet-cqrs/docs/grpc-integration/source-generators.md

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

  1. Expand project in Solution Explorer
  2. Expand "Dependencies" → "Analyzers" → "Svrnty.CQRS.Grpc.Generators"
  3. View generated files

In Rider

  1. Navigate to a service usage
  2. Right-click → "Go to Declaration"
  3. 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:

  1. Verify package installed:

    <PackageReference Include="Svrnty.CQRS.Grpc.Generators" Version="1.0.0" />
    
  2. Clean and rebuild:

    dotnet clean
    dotnet build
    
  3. Check build output for generator messages

Generated Code Not Visible

Symptoms: Can't find generated classes in IDE

Solutions:

  1. Close and reopen solution
  2. Rebuild project
  3. Restart IDE
  4. 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>

See Also