Skip to content

Dispatch Validation

The dispatch pipeline supports declarative validation that runs before the handler, collecting all field errors using applicative validation. Failed validation short-circuits the pipeline and produces a structured ValidationError — a 422 response with per-field error details.

Quick Start

Add Validated = true to your handler attribute and use standard DataAnnotations on the input type:

using System.ComponentModel.DataAnnotations;

public record CreateOrder(
    [property: Required, MinLength(1)] string Name,
    [property: Range(1, int.MaxValue)] int Quantity);

public static class OrderCommands
{
    [CommandHandler(Validated = true)]
    public static Eff<AppRuntime, OrderCreated> Handle(CreateOrder cmd) => ...
}

That's it. The generator scans the DataAnnotations, generates applicative validation that collects all field errors (not just the first), and injects it before the handler. No validator class, no ceremony.

How It Works

When Validated = true, the generator:

  1. Scans the input record's properties for System.ComponentModel.DataAnnotations attributes
  2. Generates a Validate method using FieldError.Fail<T>() / FieldError.Success<T>()
  3. Combines all field validations applicatively — all errors are collected, not short-circuited
  4. Injects the validation step before the handler in the dispatch pipeline

Supported DataAnnotations

Attribute Generated Rule
[Required] Not null / not empty string
[MinLength(n)] String or collection length ≥ n
[MaxLength(n)] String or collection length ≤ n
[Range(min, max)] Numeric value within range
[RegularExpression(pattern)] String matches regex
[EmailAddress] Valid email format
[StringLength(max, MinimumLength)] String length within bounds

DataAnnotations are the zero-learning-curve path — they work with existing Swagger/OpenAPI, Entity Framework, and Blazor tooling.

Custom Field Rules

For validation beyond DataAnnotations, add partial Validate{Field} methods to the input record:

public partial record CreateOrder(
    [property: Required] string Name,
    [property: Range(1, int.MaxValue)] int Quantity)
{
    public static partial FieldResult<string> ValidateName(string value) =>
        value.Contains("test")
            ? FieldError.Fail<string>("name", "no_test_data", "Test data not allowed")
            : FieldError.Success(value);
}

The generator discovers partial Validate{Field} methods and combines them with DataAnnotation rules for that field. Both run — errors from both sources are collected.

Code fix: Add custom validator

Your IDE will offer a DSDSP13 code fix on any validated record parameter: "Add custom validator for {FieldName}". Accept it to scaffold the partial method stub.

FieldResult<T>

Custom validation methods return FieldResult<T> — a thin facade over Validation<Seq<FieldError>, T>:

// These are equivalent:
FieldResult<string> result = FieldError.Success("value");
FieldResult<string> result = FieldError.Fail<string>("field", "code", "message");

FieldResult<T> has implicit conversions to and from Validation<Seq<FieldError>, T>, so it works seamlessly with the existing validation infrastructure. It just provides a cleaner signature for partial methods.

ValidatorType (Advanced)

For complete control over validation logic, use ValidatorType instead of Validated = true:

public static class CreateOrderValidator
{
    public static Validation<Seq<FieldError>, CreateOrder> Validate(CreateOrder cmd) =>
        (ValidateName(cmd), ValidateQuantity(cmd))
            .Apply((_, _) => cmd);

    static Validation<Seq<FieldError>, string> ValidateName(CreateOrder cmd) =>
        string.IsNullOrWhiteSpace(cmd.Name)
            ? FieldError.Fail<string>("name", "required", "Name is required")
            : FieldError.Success(cmd.Name);

    static Validation<Seq<FieldError>, int> ValidateQuantity(CreateOrder cmd) =>
        cmd.Quantity <= 0
            ? FieldError.Fail<int>("quantity", "min_value", "Quantity must be positive")
            : FieldError.Success(cmd.Quantity);
}

public static class OrderCommands
{
    [CommandHandler(ValidatorType = typeof(CreateOrderValidator))]
    public static Eff<AppRuntime, OrderCreated> Handle(CreateOrder cmd) => ...
}

The validator type must contain a static Validate method with signature Validation<Seq<FieldError>, T> Validate(T).

Warning

Validated = true and ValidatorType are mutually exclusive. Specifying both raises DSDSP10.

Validation Types

FieldError

A structured error for a single field, designed for direct mapping to form-level error display in client applications.

public record FieldError(string Field, string Code, string Message);
Property Type Description
Field string The field name (e.g., "email", "quantity")
Code string Machine-readable error code (e.g., "required", "too_short")
Message string Human-readable error message

Helper Methods

// Create a failed validation with a single field error
FieldError.Fail<T>(string field, string code, string message)

// Create a successful validation wrapping a value
FieldError.Success<T>(T value)

ValidationError

An Expected error type carrying all field-level errors. Recognized by the web layer to produce 422 Unprocessable Entity responses.

public record ValidationError(Seq<FieldError> Errors)
    : Expected("Validation failed", 422, Option<Error>.None);

JSON Error Response

When validation fails in the web layer, ValidationError produces a 422 Unprocessable Entity response with structured field errors:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21",
  "title": "Validation failed",
  "status": 422,
  "errors": [
    {
      "field": "name",
      "code": "required",
      "message": "Name is required"
    },
    {
      "field": "quantity",
      "code": "min_value",
      "message": "Quantity must be positive"
    }
  ]
}

The Code field enables client-side localization — map codes like "required" or "too_short" to translated strings without parsing messages.

Progressive Disclosure

Complexity Approach What You Write
DataAnnotations only Validated = true on handler Standard [Required], [Range], etc. on the record
Custom field rules Partial Validate{Field} methods FieldResult<T> returning method on the record
Full control ValidatorType on handler Static class with Validate(T) method

Testing

Testing Validators Directly

Validators are pure static functions — test them without any runtime:

[Test]
public async Task Validate_RejectsEmptyName()
{
    var result = CreateOrderValidator.Validate(
        new CreateOrder("", 5));

    await Assert.That(result.IsFail).IsTrue();

    var errors = result.FailAsEnumerable().SelectMany(x => x).ToList();
    await Assert.That(errors).HasCount().EqualTo(1);
    await Assert.That(errors[0].Field).IsEqualTo("name");
    await Assert.That(errors[0].Code).IsEqualTo("required");
}

[Test]
public async Task Validate_CollectsAllErrors()
{
    var result = CreateOrderValidator.Validate(
        new CreateOrder("", -1));

    await Assert.That(result.IsFail).IsTrue();

    var errors = result.FailAsEnumerable().SelectMany(x => x).ToList();
    await Assert.That(errors).HasCount().EqualTo(2);
}

Testing via the Dispatch Pipeline

Test that validation integrates correctly with the full dispatch pipeline:

[Test]
public async Task Dispatch_ReturnsValidationError_WhenInvalid()
{
    var runtime = TestAppRuntime.Create();

    var result = await AppDispatch
        .Dispatch(new CreateOrder("", -1))
        .RunAsync(runtime);

    var error = result.Match(
        Succ: _ => throw new Exception("Expected failure"),
        Fail: e => e);

    await Assert.That(error).IsTypeOf<ValidationError>();

    var validation = (ValidationError)error;
    await Assert.That(validation.Errors).HasCount().EqualTo(2);
}

Full Error Pipeline: Validation → HTTP 422

Validation errors flow from dispatch handlers all the way to HTTP responses without manual mapping:

FieldResult<T> / Validate<RT>()
    ↓ fails
ValidationError (status 422, Seq<FieldError>)
    ↓ caught by generated endpoint
WebError.ToResult() → HTTP 422 + JSON field errors

Step by step

  1. Handler validates using applicative Validate or FieldResult<T>:

    var result =
        from name in Require(cmd.Name, "name", "required")
        from amount in Guard(cmd.Amount > 0, "amount", "must_be_positive", cmd.Amount)
        select new CreateOrder(name, amount);
    
  2. ToEff<RT>() converts Validation<Seq<FieldError>, T> into Eff<RT, T>. If validation fails, it raises a ValidationError with all accumulated field errors.

  3. ValidationError extends Expected with HTTP status 422 and carries the field errors:

    public sealed class ValidationError : Expected
    {
        public Seq<FieldError> Errors { get; }
        public override int Code => 422;
    }
    
  4. Generated endpoint catches the error and maps it via WebError.ToResult() → HTTP 422 with structured JSON:

    {
        "errors": [
            { "field": "name", "code": "required", "message": "Name is required" },
            { "field": "amount", "code": "must_be_positive", "message": "Amount must be positive" }
        ]
    }
    

Applicative vs. Monadic Validation

Style Behavior When to use
Applicative (Validate) Collects ALL errors Form validation — show every invalid field at once
Monadic (Guard / Require in from...select) Short-circuits on first error Sequential checks where later checks depend on earlier values

WebError Mapping Table

Error Type HTTP Status When
ValidationError 422 Field-level validation failures
Expected (default) 400 Generic bad request
UnauthorizedError 401 Not authenticated
ForbiddenError 403 Not authorized
NotFoundError 404 Entity not found
ConflictError 409 Duplicate or conflict

Diagnostics

ID Severity Description
DSDSP07 Error ValidatorType is missing a Validate method with correct signature
DSDSP10 Error Handler specifies both Validated = true and ValidatorType
DSDSP11 Warning Validated = true but input type has no DataAnnotations and no partial Validate methods
DSDSP12 Error Partial Validate{Field} method has wrong return type (must be FieldResult<TField>)
DSDSP13 Info Validated record parameter — code fix scaffolds custom validator method