Write a Validator
Derived page. The behaviour described here is specified by the
request-validationcapability underopenspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.
Stratara.Validation runs request validation as a mediator pipeline behavior: every registered
IValidator<TRequest> executes before the handler, so an invalid command never reaches your domain
logic. The contract is vendor-neutral (no FluentValidation dependency) but FluentValidation-shape-compatible,
so a thin adapter can wrap an existing FluentValidation validator later.
The contract
IValidator<in T> lives in Stratara.Abstractions.Validation (so you can reference the contract
without the behavior package):
using Stratara.Abstractions.Validation;
public interface IValidator<in T>
{
ValueTask<ValidationResult> ValidateAsync(T instance, CancellationToken cancellationToken = default);
}
ValidationResult carries a (possibly empty) list of ValidationFailure. Never return null —
return ValidationResult.Success when the instance is valid.
public sealed record ValidationFailure(
string PropertyName,
string ErrorMessage,
string? ErrorCode = null,
object? AttemptedValue = null,
ValidationSeverity Severity = ValidationSeverity.Error);
Severity — only Error blocks
| Severity | Behaviour |
|---|---|
Error (default) |
Blocks the request. The pipeline throws StrataraValidationException; the handler never runs. |
Warning |
Passes through to the handler. Logged for the operator. |
Info |
Passes through to the handler. Logged for the operator. |
Write a validator
using Stratara.Abstractions.Validation;
public sealed record RegisterUserCommand(string Email, int Age) : ICommand<Guid>;
public sealed class RegisterUserValidator : IValidator<RegisterUserCommand>
{
public ValueTask<ValidationResult> ValidateAsync(
RegisterUserCommand instance,
CancellationToken cancellationToken = default)
{
var failures = new List<ValidationFailure>();
if (string.IsNullOrWhiteSpace(instance.Email) || !instance.Email.Contains('@'))
{
failures.Add(new ValidationFailure(
nameof(instance.Email),
"Email must be a non-empty address containing '@'.",
ErrorCode: "email.invalid",
AttemptedValue: instance.Email));
}
if (instance.Age < 18)
{
failures.Add(new ValidationFailure(
nameof(instance.Age),
"Age must be at least 18.",
ErrorCode: "age.minimum",
AttemptedValue: instance.Age));
}
return ValueTask.FromResult(
failures.Count == 0 ? ValidationResult.Success : new ValidationResult(failures));
}
}
The handler carries no input guards — by the time it runs, validation has already passed.
Register it
Call AddStrataraValidation() before any other AddPipelineBehavior* registration so validation
runs as the outermost behavior — rejecting invalid requests before authorization, auditing, or the
handler. Pair it with AddValidatorsFromAssemblyContaining<T>(), which discovers and registers every
concrete IValidator<T> in the marker's assembly as a scoped service.
builder.Services
.AddMediator()
.AddStrataraValidation() // behavior first (outermost)
.AddValidatorsFromAssemblyContaining<Program>() // discover every IValidator<T>
.AddCommandHandlersFromAssemblyContaining<Program>()
.AddQueryHandlersFromAssemblyContaining<Program>();
Map the failure to an HTTP response
Register the built-in mapping and you are done:
builder.Services.AddStrataraProblemDetails(); // Stratara.ServiceDefaults.AspNetCore
app.UseExceptionHandler();
A validation rejection becomes 400 with the failures grouped by the field each concerns; an
authorization refusal and a tenant-access denial each become 403, in the same RFC 7807 shape.
Anything the framework did not raise is left alone and reaches your own diagnostics unchanged.
It supersedes UseAuthorizationExceptionTo403(), which maps the same two refusals to a bare
status code with no body. That method is marked obsolete and is removed in the next major version.
Do not register both: the middleware answers first and the handler never sees the exception.
If you want your own error model
StrataraValidationException is declared in Stratara.Abstractions.Validation, so a global exception
handler can catch it and map Failures yourself — without referencing the Stratara.Validation
behavior package. Simply do not call AddStrataraProblemDetails(), and nothing is converted:
catch (StrataraValidationException ex)
{
var errors = ex.Failures
.GroupBy(f => f.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(f => f.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
See it run
Stratara.Sample.Validation is a ~80-line runnable program that
dispatches a valid command, a warning-only command (still handled), and an invalid command (blocked).
Related
- Write a Command Handler — the handler the validator guards.
- DI Extensions Cheat Sheet —
AddStrataraValidation()at a glance.