dotnet-cqrs/docs/grpc-integration/service-implementation.md

12 KiB

Service Implementation

Understanding auto-generated gRPC service implementations.

Generated Services

The source generator creates two main service implementations:

  1. CommandServiceImpl - Handles all command RPCs
  2. QueryServiceImpl - Handles all query RPCs

Both inherit from gRPC-generated base classes and integrate with CQRS handlers.

CommandServiceImpl

Structure

public partial class CommandServiceImpl : CommandService.CommandServiceBase
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<CommandServiceImpl> _logger;

    public CommandServiceImpl(
        IServiceProvider serviceProvider,
        ILogger<CommandServiceImpl> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    // RPC implementations generated here
}

Command With Result

public override async Task<CreateUserResponse> CreateUser(
    CreateUserCommand request,
    ServerCallContext context)
{
    using var scope = _serviceProvider.CreateScope();

    try
    {
        // 1. Validate
        await ValidateAsync(request, scope, context.CancellationToken);

        // 2. Get handler
        var handler = scope.ServiceProvider
            .GetRequiredService<ICommandHandler<CreateUserCommand, int>>();

        // 3. Execute
        var userId = await handler.HandleAsync(request, context.CancellationToken);

        // 4. Return response
        return new CreateUserResponse { UserId = userId };
    }
    catch (KeyNotFoundException ex)
    {
        throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error executing CreateUser");
        throw new RpcException(new Status(StatusCode.Internal, "An error occurred"));
    }
}

Command Without Result

public override async Task<Empty> DeleteUser(
    DeleteUserCommand request,
    ServerCallContext context)
{
    using var scope = _serviceProvider.CreateScope();

    try
    {
        var handler = scope.ServiceProvider
            .GetRequiredService<ICommandHandler<DeleteUserCommand>>();

        await handler.HandleAsync(request, context.CancellationToken);

        return new Empty();
    }
    catch (KeyNotFoundException ex)
    {
        throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
    }
}

QueryServiceImpl

Structure

public partial class QueryServiceImpl : QueryService.QueryServiceBase
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<QueryServiceImpl> _logger;

    public QueryServiceImpl(
        IServiceProvider serviceProvider,
        ILogger<QueryServiceImpl> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    // RPC implementations generated here
}

Query Implementation

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    using var scope = _serviceProvider.CreateScope();

    try
    {
        var handler = scope.ServiceProvider
            .GetRequiredService<IQueryHandler<GetUserQuery, UserDto>>();

        var result = await handler.HandleAsync(request, context.CancellationToken);

        return result;
    }
    catch (KeyNotFoundException ex)
    {
        throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
    }
}

Validation Integration

Automatic Validation

private async Task ValidateAsync<TCommand>(
    TCommand command,
    IServiceScope scope,
    CancellationToken cancellationToken)
{
    var validator = scope.ServiceProvider.GetService<IValidator<TCommand>>();

    if (validator == null)
        return;

    var validationResult = await validator.ValidateAsync(command, cancellationToken);

    if (!validationResult.IsValid)
    {
        throw CreateValidationException(validationResult);
    }
}

Validation Exception

private RpcException CreateValidationException(ValidationResult validationResult)
{
    var badRequest = new BadRequest();

    foreach (var error in validationResult.Errors)
    {
        badRequest.FieldViolations.Add(new BadRequest.Types.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();
}

private string ToCamelCase(string value)
{
    if (string.IsNullOrEmpty(value) || char.IsLower(value[0]))
        return value;

    return char.ToLower(value[0]) + value.Substring(1);
}

Error Handling

Exception to StatusCode Mapping

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    try
    {
        // Handler execution
    }
    catch (KeyNotFoundException ex)
    {
        throw new RpcException(new Status(StatusCode.NotFound, ex.Message));
    }
    catch (UnauthorizedAccessException ex)
    {
        throw new RpcException(new Status(StatusCode.PermissionDenied, ex.Message));
    }
    catch (ArgumentException ex)
    {
        throw new RpcException(new Status(StatusCode.InvalidArgument, ex.Message));
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unhandled error in GetUser");
        throw new RpcException(new Status(StatusCode.Internal, "An error occurred"));
    }
}

Status Code Reference

Exception gRPC Status Code Description
KeyNotFoundException NOT_FOUND (5) Entity not found
UnauthorizedAccessException PERMISSION_DENIED (7) Authorization failure
ArgumentException INVALID_ARGUMENT (3) Invalid input
ValidationException INVALID_ARGUMENT (3) Validation failure
TimeoutException DEADLINE_EXCEEDED (4) Operation timeout
Generic Exception INTERNAL (13) Unknown error

Dependency Injection

Scoped Services

public override async Task<CreateUserResponse> CreateUser(
    CreateUserCommand request,
    ServerCallContext context)
{
    // Create scope for request
    using var scope = _serviceProvider.CreateScope();

    // Resolve scoped services
    var handler = scope.ServiceProvider
        .GetRequiredService<ICommandHandler<CreateUserCommand, int>>();

    var validator = scope.ServiceProvider
        .GetService<IValidator<CreateUserCommand>>();

    // Execute...
}

Why Scoping?

  • DbContext per request - Entity Framework requires scoped DbContext
  • Clean disposal - Resources disposed after request
  • Isolation - Each request gets its own service instances

Logging

Request Logging

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    _logger.LogInformation(
        "GetUser request: UserId={UserId}, Client={Peer}",
        request.UserId,
        context.Peer);

    try
    {
        var result = await ExecuteQuery(request, context);

        _logger.LogInformation("GetUser completed successfully: UserId={UserId}", request.UserId);

        return result;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "GetUser failed: UserId={UserId}", request.UserId);
        throw;
    }
}

Performance Logging

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    var stopwatch = Stopwatch.StartNew();

    try
    {
        var result = await ExecuteQuery(request, context);

        stopwatch.Stop();

        _logger.LogInformation(
            "GetUser completed in {ElapsedMs}ms: UserId={UserId}",
            stopwatch.ElapsedMilliseconds,
            request.UserId);

        return result;
    }
    catch
    {
        stopwatch.Stop();
        _logger.LogWarning(
            "GetUser failed after {ElapsedMs}ms: UserId={UserId}",
            stopwatch.ElapsedMilliseconds,
            request.UserId);
        throw;
    }
}

Metadata & Headers

Reading Metadata

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    // Read request headers
    var metadata = context.RequestHeaders;

    var correlationId = metadata.GetValue("correlation-id");
    var clientVersion = metadata.GetValue("client-version");

    _logger.LogInformation(
        "GetUser: CorrelationId={CorrelationId}, ClientVersion={ClientVersion}",
        correlationId,
        clientVersion);

    // Execute...
}

Writing Response Headers

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    // Add response headers
    await context.WriteResponseHeadersAsync(new Metadata
    {
        { "server-version", "1.0.0" },
        { "request-id", Guid.NewGuid().ToString() }
    });

    // Execute query...
}

Deadlines & Cancellation

Respecting Deadlines

public override async Task<UserDto> GetUser(
    GetUserQuery request,
    ServerCallContext context)
{
    // Check if deadline exceeded
    if (context.CancellationToken.IsCancellationRequested)
    {
        throw new RpcException(new Status(StatusCode.DeadlineExceeded, "Request deadline exceeded"));
    }

    // Pass cancellation token to handler
    var handler = GetHandler<IQueryHandler<GetUserQuery, UserDto>>(scope);
    var result = await handler.HandleAsync(request, context.CancellationToken);

    return result;
}

Interceptors

Custom Interceptors

While service implementations are auto-generated, you can add interceptors:

public class LoggingInterceptor : Interceptor
{
    private readonly ILogger<LoggingInterceptor> _logger;

    public LoggingInterceptor(ILogger<LoggingInterceptor> logger)
    {
        _logger = logger;
    }

    public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation)
    {
        _logger.LogInformation("gRPC call: {Method}", context.Method);

        try
        {
            return await continuation(request, context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "gRPC error: {Method}", context.Method);
            throw;
        }
    }
}

// Registration
builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<LoggingInterceptor>();
});

Testing

Unit Testing

public class CommandServiceImplTests
{
    private readonly Mock<IServiceProvider> _mockServiceProvider;
    private readonly Mock<IServiceScope> _mockScope;
    private readonly CommandServiceImpl _service;

    public CommandServiceImplTests()
    {
        _mockServiceProvider = new Mock<IServiceProvider>();
        _mockScope = new Mock<IServiceScope>();

        _mockServiceProvider
            .Setup(sp => sp.CreateScope())
            .Returns(_mockScope.Object);

        _service = new CommandServiceImpl(_mockServiceProvider.Object, Mock.Of<ILogger<CommandServiceImpl>>());
    }

    [Fact]
    public async Task CreateUser_WithValidData_ReturnsUserId()
    {
        // Arrange
        var mockHandler = new Mock<ICommandHandler<CreateUserCommand, int>>();
        mockHandler
            .Setup(h => h.HandleAsync(It.IsAny<CreateUserCommand>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(42);

        _mockScope
            .Setup(s => s.ServiceProvider.GetService(typeof(ICommandHandler<CreateUserCommand, int>)))
            .Returns(mockHandler.Object);

        var request = new CreateUserCommand { Name = "John", Email = "john@example.com" };
        var context = TestServerCallContext.Create();

        // Act
        var response = await _service.CreateUser(request, context);

        // Assert
        Assert.Equal(42, response.UserId);
    }
}

Best Practices

DO

  • Use scoped services for each request
  • Log important operations
  • Handle exceptions appropriately
  • Pass cancellation tokens
  • Use dependency injection
  • Respect deadlines
  • Add metadata for tracing

DON'T

  • Don't catch and swallow exceptions
  • Don't ignore cancellation tokens
  • Don't use static dependencies
  • Don't skip validation
  • Don't leak implementation details in errors
  • Don't block async operations

See Also