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:
- Scans the input record's properties for
System.ComponentModel.DataAnnotationsattributes - Generates a
Validatemethod usingFieldError.Fail<T>()/FieldError.Success<T>() - Combines all field validations applicatively — all errors are collected, not short-circuited
- 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.
| 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¶
-
Handler validates using applicative
ValidateorFieldResult<T>: -
ToEff<RT>()convertsValidation<Seq<FieldError>, T>intoEff<RT, T>. If validation fails, it raises aValidationErrorwith all accumulated field errors. -
ValidationErrorextendsExpectedwith HTTP status 422 and carries the field errors: -
Generated endpoint catches the error and maps it via
WebError.ToResult()→ HTTP 422 with structured JSON:
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 |