dotnet-cqrs/docs/core-features/validation/grpc-validation.md

13 KiB

gRPC Validation

gRPC validation with Google Rich Error Model for structured error responses.

Overview

When validation fails for gRPC endpoints, the framework returns structured errors following the Google Rich Error Model. This provides:

  • Standardized format - Consistent with Google APIs
  • Field violations - Know exactly which fields failed
  • Status codes - gRPC status codes (INVALID_ARGUMENT)
  • Error details - Additional context via google.rpc types
  • Language-agnostic - Works across all gRPC implementations

Google Rich Error Model

Error Structure

google.rpc.Status {
  code: 3                        // INVALID_ARGUMENT
  message: "Validation failed"
  details: [
    google.rpc.BadRequest {
      field_violations: [
        { field: "name", description: "Name is required" },
        { field: "email", description: "Valid email address is required" },
        { field: "age", description: "User must be at least 18 years old" }
      ]
    }
  ]
}

gRPC Status Codes

  • INVALID_ARGUMENT (3) - Validation errors
  • UNAUTHENTICATED (16) - Missing or invalid authentication
  • PERMISSION_DENIED (7) - Authorization failures
  • NOT_FOUND (5) - Entity not found
  • ALREADY_EXISTS (6) - Duplicate entity

Setup

Install Required Packages

dotnet add package Grpc.StatusProto

Proto File Configuration

Your .proto files must import the error model definitions:

syntax = "proto3";

import "google/rpc/status.proto";
import "google/rpc/error_details.proto";

package yourapp;

service CommandService {
  rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
}

message CreateUserCommand {
  string name = 1;
  string email = 2;
  int32 age = 3;
}

message CreateUserResponse {
  int32 user_id = 1;
}

Service Registration

var builder = WebApplication.CreateBuilder(args);

// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();

// Register commands with validators
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();

// Add gRPC
builder.Services.AddGrpc();

var app = builder.Build();

// Map auto-generated service implementation
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcReflectionService();

app.Run();

Validation Flow

gRPC Request: CreateUser
       │
       ▼
┌──────────────────┐
│  Deserialize     │
│  Proto Message   │
└──────┬───────────┘
       │
       ▼
┌──────────────────┐
│  Run Validator   │
└──────┬───────────┘
       │
       ├─ Valid ──────────▶ Execute Handler ──▶ Success Response
       │
       └─ Invalid ────────▶ Return RpcException with BadRequest ──▶ INVALID_ARGUMENT

Example Validation Errors

Single Field Error

gRPC Request:

var client = new CommandService.CommandServiceClient(channel);

var request = new CreateUserCommand
{
    Name = "",  // Invalid
    Email = "john@example.com"
};

try
{
    var response = await client.CreateUserAsync(request);
}
catch (RpcException ex)
{
    // ex.StatusCode = StatusCode.InvalidArgument
    // ex.Message = "Validation failed"

    var status = ex.GetRpcStatus();
    var badRequest = status.GetDetail<BadRequest>();

    foreach (var violation in badRequest.FieldViolations)
    {
        Console.WriteLine($"{violation.Field}: {violation.Description}");
    }
}

Error Details:

google.rpc.Status {
  code: 3
  message: "Validation failed"
  details: [
    google.rpc.BadRequest {
      field_violations: [
        { field: "name", description: "Name is required" }
      ]
    }
  ]
}

Multiple Field Errors

gRPC Request:

var request = new CreateUserCommand
{
    Name = "",        // Invalid
    Email = "invalid", // Invalid
    Age = 16          // Invalid
};

try
{
    var response = await client.CreateUserAsync(request);
}
catch (RpcException ex)
{
    var status = ex.GetRpcStatus();
    var badRequest = status.GetDetail<BadRequest>();

    // badRequest.FieldViolations contains 3 violations
}

Error Details:

google.rpc.Status {
  code: 3
  message: "Validation failed"
  details: [
    google.rpc.BadRequest {
      field_violations: [
        { field: "name", description: "Name is required" },
        { field: "email", description: "Valid email address is required" },
        { field: "age", description: "User must be at least 18 years old" }
      ]
    }
  ]
}

Client-Side Handling

C# gRPC Client

using Grpc.Core;
using Google.Rpc;

public class UserGrpcClient
{
    private readonly CommandService.CommandServiceClient _client;

    public async Task<int?> CreateUserAsync(string name, string email, int age)
    {
        var request = new CreateUserCommand
        {
            Name = name,
            Email = email,
            Age = age
        };

        try
        {
            var response = await _client.CreateUserAsync(request);
            return response.UserId;
        }
        catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
        {
            var status = ex.GetRpcStatus();
            var badRequest = status.GetDetail<BadRequest>();

            if (badRequest != null)
            {
                foreach (var violation in badRequest.FieldViolations)
                {
                    Console.WriteLine($"Validation error - {violation.Field}: {violation.Description}");
                }
            }

            return null;
        }
    }
}

TypeScript gRPC-Web Client

import { RpcError } from 'grpc-web';
import { Status } from 'google-rpc/status_pb';
import { BadRequest } from 'google-rpc/error_details_pb';

async function createUser(name: string, email: string, age: number) {
  const request = new CreateUserCommand();
  request.setName(name);
  request.setEmail(email);
  request.setAge(age);

  try {
    const response = await client.createUser(request, {});
    return response.getUserId();
  } catch (error) {
    const rpcError = error as RpcError;

    if (rpcError.code === 3) { // INVALID_ARGUMENT
      const status = Status.deserializeBinary(rpcError.metadata['grpc-status-details-bin']);

      status.getDetailsList().forEach(detail => {
        if (detail.getTypeUrl().includes('BadRequest')) {
          const badRequest = BadRequest.deserializeBinary(detail.getValue_asU8());

          badRequest.getFieldViolationsList().forEach(violation => {
            console.error(`${violation.getField()}: ${violation.getDescription()}`);
          });
        }
      });
    }

    return null;
  }
}

Go gRPC Client

import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "google.golang.org/genproto/googleapis/rpc/errdetails"
)

func createUser(client pb.CommandServiceClient, name, email string, age int32) (int32, error) {
    req := &pb.CreateUserCommand{
        Name:  name,
        Email: email,
        Age:   age,
    }

    resp, err := client.CreateUser(context.Background(), req)
    if err != nil {
        if st, ok := status.FromError(err); ok {
            if st.Code() == codes.InvalidArgument {
                for _, detail := range st.Details() {
                    if br, ok := detail.(*errdetails.BadRequest); ok {
                        for _, violation := range br.GetFieldViolations() {
                            fmt.Printf("%s: %s\n", violation.Field, violation.Description)
                        }
                    }
                }
            }
        }
        return 0, err
    }

    return resp.UserId, nil
}

Validation Rules

FluentValidation Integration

The framework automatically converts FluentValidation errors to Google Rich Error Model:

public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
    public CreateUserCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .WithMessage("Name is required")
            .MaximumLength(100)
            .WithMessage("Name must not exceed 100 characters");

        RuleFor(x => x.Email)
            .EmailAddress()
            .WithMessage("Valid email address is required");

        RuleFor(x => x.Age)
            .GreaterThanOrEqualTo(18)
            .WithMessage("User must be at least 18 years old");
    }
}

Field Name Mapping

Field names in error responses match proto field names (case-insensitive):

message CreateUserCommand {
  string name = 1;        // Error field: "name"
  string email = 2;       // Error field: "email"
  int32 age = 3;          // Error field: "age"
}

C# property names are automatically converted to proto field names:

public class CreateUserCommand
{
    public string Name { get; set; }   // Maps to "name"
    public string Email { get; set; }  // Maps to "email"
    public int Age { get; set; }       // Maps to "age"
}

Testing Validation

Integration Tests

public class CreateUserCommandGrpcTests : IClassFixture<GrpcTestFixture>
{
    private readonly CommandService.CommandServiceClient _client;

    public CreateUserCommandGrpcTests(GrpcTestFixture fixture)
    {
        _client = fixture.CreateClient<CommandService.CommandServiceClient>();
    }

    [Fact]
    public async Task CreateUser_WithMissingName_ReturnsInvalidArgument()
    {
        var request = new CreateUserCommand
        {
            Name = "",
            Email = "john@example.com"
        };

        var ex = await Assert.ThrowsAsync<RpcException>(
            () => _client.CreateUserAsync(request).ResponseAsync);

        Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);

        var status = ex.GetRpcStatus();
        var badRequest = status.GetDetail<BadRequest>();

        Assert.NotNull(badRequest);
        Assert.Contains(badRequest.FieldViolations, v => v.Field == "name");
    }

    [Fact]
    public async Task CreateUser_WithValidData_ReturnsUserId()
    {
        var request = new CreateUserCommand
        {
            Name = "John Doe",
            Email = "john@example.com",
            Age = 25
        };

        var response = await _client.CreateUserAsync(request);

        Assert.True(response.UserId > 0);
    }
}

Custom Error Details

Add Additional Context

public static class ValidationErrorHelper
{
    public static 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 static string ToCamelCase(string value)
    {
        if (string.IsNullOrEmpty(value) || char.IsLower(value[0]))
            return value;

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

Best Practices

DO

  • Use Google Rich Error Model for structured errors
  • Return INVALID_ARGUMENT for validation errors
  • Provide field-level error messages
  • Use descriptive error messages
  • Test validation scenarios
  • Handle validation errors gracefully in clients
  • Map C# property names to proto field names

DON'T

  • Don't return INTERNAL for validation errors
  • Don't use generic error messages
  • Don't expose internal details
  • Don't skip validation
  • Don't throw unhandled exceptions
  • Don't return plain text error messages

Comparison: HTTP vs gRPC

Feature HTTP (RFC 7807) gRPC (Rich Error Model)
Format JSON Protobuf
Status HTTP 400 INVALID_ARGUMENT (3)
Field Errors errors object field_violations array
Structure ProblemDetails google.rpc.Status
Details Extensions Typed details (BadRequest, etc.)
Size Larger (JSON) Smaller (binary)

See Also