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.

Both lines are needed. AddStrataraProblemDetails() registers the handler and app.UseExceptionHandler() is what reaches it — register the first without the second and nothing is converted, exactly as if the mapping were absent.

Removed in 4.0.0: UseAuthorizationExceptionTo403() mapped the two refusals to a bare status code with no body. It was obsolete from 3.3.0 and is gone; a host still calling it registers the two lines above instead and gains an RFC 7807 body on the 403.

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).