Table of Contents

Write a Validator

Derived page. The behaviour described here is specified by the request-validation capability under openspec/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).