Validation
Validation is built in. In a form, write the rule beside the field:
Input.Bind(() => _model.Email)
.Validate(v => v.Contains('@') ? [] : ["Email looks wrong."])
That is the whole of it for a form — a rule on a field, a rule on the form for what spans fields,
either of them async, the simple ones kept in a value object. See
forms — validation.
A rule that must also hold where no form is — on a dispatched request, on a posted HTTP body — lives on
the type instead: [Required] and its relatives, or an AbstractValidator<T>. Those are also
supported in a form, and they run with nothing declared.
Included in
Rask.ServerandRask.Wasm— nothing to install. The automatic validators are on; an app that writes its rules inline and wants only those says so:app.Configure(c => c.Validation.Off()); // or RaskValidation.AutoValidate = false; on any hostInline
.Validate(…)rules keep running either way, and so do the two kinds a data layer registers for its models: the rule a property's type carries, and the rule the store owns — a unique index, a range that must not overlap — which a form checks before the save. Rask.Data registers both for every aggregate's generated model: a one-value value object's staticValidate, and each unique index declared withIsUnique("…")(data — what a form over the model asks by itself).
Where it runs
| Where | What runs | Guide |
|---|---|---|
A Form.Model(m) |
The inline .Validate(…) rules, then the model's DataAnnotations attributes, then the AbstractValidator<T> for it |
forms-validation.md |
| A dispatched query or command | The request's attributes, then its AbstractValidator<T>, then any IRequestValidator<T> you registered |
below |
| A controller action or a minimal API endpoint | The bound body's attributes, then the AbstractValidator<T> for it — asynchronous rules included |
HTTP endpoints |
All three share a rule written on the type: an AbstractValidator<Order> validates a Form.Model(order) while the user
types, an Order command when it is dispatched, and an Order posted to /api/orders. Write the
rules once.
Also supported: rules on the type
Two sources, both optional, both run by a form, a dispatch and an endpoint alike.
DataAnnotations lives in Rask, the core every host depends on, so it costs no reference
at all. It covers [Required], [Range], [StringLength], [EmailAddress],
[RegularExpression], custom ValidationAttribute subclasses and IValidatableObject, across the
whole reachable object graph.
FluentValidation is the Rask.Validation.FluentValidation package, referenced for you by
Rask.Server and Rask.Wasm alike. Declaring the validator is the registration — a
generator finds every AbstractValidator<T> at compile time and emits a [ModuleInitializer]. There
is no assembly scan, which is what lets a WebAssembly app use it and still publish trimmed.
A validator with constructor dependencies is resolved from the scope, so the uniqueness-check validator — the usual reason to reach for FluentValidation — needs no extra wiring. It is built when validation runs, not when the form renders, so a rule edited and hot-reloaded takes effect on the next validation rather than the next page load.
Two things to know
Discovery is per assembly. The generator sees the compilation it runs in, so a validator in a
referenced class library is registered only if that library also has the Rask analyzer payload and a
Rask.Validation.FluentValidation reference — which a plain class library does not. Keep validators
beside the app, or register them explicitly:
RaskValidators.Register(typeof(Order), _ => new OrderValidator());
A discovered validator makes the form validate asynchronously. FluentValidation runs async, so a
model with a validator is checked through the awaitable EditContext.Validate() / ValidateField(field) —
the only ones there are: every rule, synchronous or asynchronous, runs through them, so writing an
AbstractValidator<T> changes nothing at the call site.
Requests
Every dispatched request is validated before its handler runs, by a ValidationBehavior that
AddRaskCqrs registers outermost: a request that is not valid should never reach a transaction,
a log line saying it was handled, or the handler.
Rules that fit neither source go in an IRequestValidator<TRequest>, which is asynchronous by
construction:
public sealed class NoDuplicateOrders(IOrderStore store) : IRequestValidator<PlaceOrder>
{
public async ValueTask<IReadOnlyList<RequestValidationError>> Validate(PlaceOrder request) =>
await store.Exists(request.Reference, Current.Cancellation)
? [new RequestValidationError(nameof(PlaceOrder.Reference), "That reference is already used.")]
: [];
}
builder.Services.AddSingleton<IRequestValidator<PlaceOrder>, NoDuplicateOrders>();
Every validator runs and their failures are collected. This is deliberately not the form's first-error-wins: a form gates per field because the user is typing into it, while a caller fixing a request wants the whole list rather than one problem per round trip.
A rejected request
In process, a failure reaches the caller as a RaskValidationException whose Errors are grouped by
field, with the empty key holding rules about the request as a whole. It also names its fields for a form
(IFieldFailures): let it out of a Form's submit handler and each message is shown under the field it is
about, when the form's model is the request — the field names are the request's property paths.
Over remote dispatch
it becomes 400 application/problem+json:
{
"type": "https://github.com/pal-tamas/rask/blob/main/docs/validation.md#rejected",
"title": "Validation failed",
"status": 400,
"errors": {
"Product": ["No such product."],
"Quantity": ["Quantity must be at least 1."]
}
}
The type is stable across releases, so it is the right thing for a client to branch on. The client
surfaces it as a RemoteDispatchException with Errors populated.
Note that this is the one failure whose text crosses the wire. A handler exception is opaque by default because its message is written for an operator and routinely names tables, paths and credentials; a validation message is the opposite — it was authored to be shown to whoever sent the request.
Inside a form nobody catches it. A submit handler that throws an exception carrying field failures leaves each message under its field.
What raises it:
- a validator that rejected the request before its handler ran
- a save that violated a unique index declared with a message,
IsUnique("…")— under the index's fields - a delete of a row that is still referenced, on an aggregate that says what that means —
OnDelete(behavior, "…")orWhenReferenced("…")— under the empty key, because it is about no field
The browser checks first
On a WebAssembly client the request is validated before it is sent, so an invalid command costs a message rather than a round trip. The server runs the same rules again and remains the authority — the local check is a convenience, never a control, and a caller that skips it gains nothing.
Catch both: a rejection caught in the browser is a RaskValidationException, and one the browser
could not evaluate — a MustAsync that needs the database — comes back from the server as a
RemoteDispatchException whose Errors carry the same field map.
Events are not validated. Publish does not go through the request pipeline, so a rule on
an event would be enforced nowhere; put it on the command that raises the event instead.
HTTP endpoints
An endpoint runs the same two passes, with nothing declared. Put [Required] on the
body type, or write an AbstractValidator<T> for it, and a controller action and a minimal API both
reject an invalid request with the 400 shown above — the same document, from
the same type, that a rejected dispatch sends, so one client-side handler covers every seam.
public sealed class NewOrder
{
[Required] public string? Reference { get; set; }
[Range(1, 100)] public int Quantity { get; set; }
}
public sealed class NewOrderValidator : AbstractValidator<NewOrder>
{
public NewOrderValidator(IOrderStore store) =>
RuleFor(o => o.Reference)
.MustAsync(async (reference, ct) => !await store.Exists(reference!, ct))
.WithMessage("That reference is already used.");
}
That validator now runs in three places: in a Form.Model(newOrder) as each field's value arrives and on submit, on a NewOrder
command when it is dispatched, and on both of these:
[HttpPost("")]
public ActionResult<Order> Place(NewOrder body) => …; // a controller action
app.MapEndpoints(e =>
e.MapPost("/api/orders", (NewOrder body, AppDb db) => …)); // a minimal API
The asynchronous rule is the point. MVC's ModelState and Validator.TryValidateObject are both
synchronous, so a MustAsync — the uniqueness check, the usual reason to reach for FluentValidation at
all — could never ride the platform's own pass. Rask adds an asynchronous filter beside it: an action
filter for controllers, an endpoint filter for minimal APIs.
Only what the caller sent is validated. An injected service is left alone — a DbContext bound as
an endpoint parameter is the container's, not the request's, and walking its object graph would be both
wrong and slow.
Where the filter reaches
A controller is covered wherever it lives; MVC runs its filters for every action.
A minimal API is covered when it is mapped through app.MapEndpoints(e => …), which is where an app
writes them. ASP.NET has no such thing as a global endpoint filter — a convention reaches only what is
mapped into the group carrying it — so an endpoint mapped somewhere else asks for the convention by
name:
var app = raskApp.Build<App>();
app.MapPost("/api/orders", (NewOrder body) => …).RequireRaskValidation();
A host assembled by hand, without RaskApp, adds the services half itself:
builder.Services.AddRaskApiValidation();
What turning it off does, and does not do
app.Configure(c => c.Validation.Off()) stops the Rask pass everywhere, endpoints included: the
discovered AbstractValidator<T> no longer runs on a controller action or a minimal API.
It deliberately leaves ASP.NET's own behaviour alone. AddRaskApi registers
AddMvcCore().AddDataAnnotations(), so a controller's [Required] and [Range] are still enforced by
ModelState exactly as in any ASP.NET app — dropping that would silently start accepting bodies the
endpoint used to reject, which is worse than a heavier registration. For a controller, then, what
turning validation off changes is the shape of the answer: on, the rejection is the Rask problem
document; off, it is MVC's own ValidationProblemDetails.
A minimal API has no such platform pass to fall back on — .NET 10 ships one behind AddValidation(),
which Rask does not switch on — so turning validation off leaves a minimal API's body unvalidated. If
that is what you want for the app but not for one endpoint, keep the battery on and take the endpoint
out of the group instead.
On the client
A generated API client surfaces the rejection as an ApiException
whose Errors hold the same field map, keyed the same way, that RemoteDispatchException.Errors
carries for a rejected dispatch:
catch (ApiException ex) when (ex.Errors is { } errors)
{
foreach (var (field, messages) in errors) { … }
}
See also
- forms-validation.md — inline
.Validate(…)rules, value-object rules, async, and the validating indicator. - forms.md — binding and the
EditContext. - cqrs.md — dispatch, pipeline behaviors, remote errors.
- diagnostics.md — RASKVAL001, RASKVAL002.