Как разделить логику валидации между сервером и Blazor WebAssembly
Главный источник расхождений в валидации между Blazor WebAssembly клиентом и ASP.NET Core API -- это соблазн писать правила дважды. Это руководство показывает единственную раскладку, которая масштабируется в .NET 11: библиотека классов Shared, владеющая DTO и их валидаторами, потребляемая и WASM клиентом (EditForm + DataAnnotationsValidator или Blazored.FluentValidation), и сервером (фильтр endpoint в minimal API или model binding в MVC), с протестированным циклом, который возвращает серверные ValidationProblemDetails обратно в EditContext.
Если ваш Blazor WebAssembly клиент и ASP.NET Core API хранят отдельные копии правил валидации, они расходятся уже на первом спринте и порождают худший вид багов: форма проходит на клиенте, сервер её отклоняет, пользователь видит 400 без какого-либо встроенного сообщения. Единственное долговременное решение — положить и DTO, и их валидаторы в третий проект, на который ссылаются и клиент, и сервер, а ответ об ошибке от сервера отображать в том же EditContext, который использовал клиент. Это руководство выстраивает такую раскладку от начала до конца на .NET 11 (Microsoft.AspNetCore.App 11.0.0, Microsoft.AspNetCore.Components.Web 11.0.0, C# 14), сначала со встроенным System.ComponentModel.DataAnnotations, затем с FluentValidation 12 для правил, которые data annotations выразить не могут.
Почему Shared проект, а не дублированные правила и не пакет NuGet
Два паттерна, которые проваливаются, очевидны задним числом. Копирование атрибутов [Required] из DTO API в почти идентичную view model на клиенте порождает расхождение каждый раз, когда кто-то редактирует одно и забывает другое. Положить контракты во внешний пакет NuGet работает для больших систем, но это перебор для одного приложения: вы платите версионными бампами, задержкой восстановления пакетов и внутренним фидом за то, что должно быть проектной ссылкой.
Библиотека классов Contracts (или Shared) внутри той же solution — правильная форма. Она нацелена на net11.0, не имеет зависимостей от ASP.NET, и на неё ссылаются как WebApp.Client (проект Blazor WASM), так и WebApp.Server (API ASP.NET Core). Шаблон проекта Blazor WebAssembly, поставляемый с .NET 11 (dotnet new blazorwasm --hosted был удалён в .NET 8 и так и остался удалённым в .NET 11; теперь вы создаёте три проекта вручную или используете dotnet new blazor --interactivity WebAssembly --auth Individual для унифицированного шаблона Blazor) уже принимает такую раскладку: возьмите тот скаффолд, которым пользуетесь, и добавьте третий проект.
# .NET 11 SDK (11.0.100)
dotnet new sln -n WebApp
dotnet new classlib -n WebApp.Contracts -f net11.0
dotnet new webapi -n WebApp.Server -f net11.0
dotnet new blazorwasm -n WebApp.Client -f net11.0
dotnet sln add WebApp.Contracts WebApp.Server WebApp.Client
dotnet add WebApp.Server reference WebApp.Contracts
dotnet add WebApp.Client reference WebApp.Contracts
Два правила сохраняют WebApp.Contracts чистым и предотвращают случайное затягивание серверного кода в WASM-бандл:
- В
.csprojнет ниFrameworkReference, ни пакетовMicrosoft.AspNetCore.*. Если вам нуженIFormFileилиHttpContextв контракте, вы смешиваете формат провода с серверной логикой; разделите их. - Установлено
<IsTrimmable>true</IsTrimmable>, чтобы шаг публикации WASM не предупреждал на каждом валидаторе, использующем рефлексию. Мы вернёмся к этому в разделе про AOT.
DTO, который проходит через все примеры
// WebApp.Contracts/RegistrationRequest.cs
// .NET 11, C# 14, System.ComponentModel.DataAnnotations 11.0.0
using System.ComponentModel.DataAnnotations;
namespace WebApp.Contracts;
public sealed record RegistrationRequest
{
[Required, EmailAddress, StringLength(254)]
public required string Email { get; init; }
[Required, StringLength(72, MinimumLength = 12)]
public required string Password { get; init; }
[Required, Compare(nameof(Password))]
public required string ConfirmPassword { get; init; }
[Range(13, 130)]
public int Age { get; init; }
[Required, RegularExpression(@"^[a-zA-Z0-9_]{3,20}$",
ErrorMessage = "Username must be 3-20 letters, digits, or underscores.")]
public required string Username { get; init; }
}
Члены required в сочетании с init-only сеттерами дают record, который клиент может построить с синтаксисом инициализатора объекта и который System.Text.Json 11 может десериализовать на сервере без беспараметрового конструктора (он протягивает эквивалент [JsonConstructor]-инференса через required-члены в .NET 11). Тот же record — это тип, который связывается endpoint-ом API и моделью EditForm. Изменить правило можно ровно в одном месте.
Путь DataAnnotations: ноль дополнительных пакетов
Для большинства CRUD приложений data annotations на общем DTO достаточно. Они выполняются на клиенте, потому что <DataAnnotationsValidator> Blazor (в Microsoft.AspNetCore.Components.Forms) рефлексирует над моделью и подаёт сообщения в EditContext, и они выполняются на сервере, потому что pipeline model binding-а ASP.NET Core вызывает ObjectGraphValidator для любого типа, помеченного [ApiController], или любого параметра minimal API, который проходит через стандартный IValidationProblemDetailsService (введённый в рамках работы по endpoint filter validation, отслеживаемой в aspnetcore#52281).
Серверный endpoint в стиле minimal API:
// WebApp.Server/Program.cs
// .NET 11, ASP.NET Core 11.0.0
using Microsoft.AspNetCore.Http.HttpResults;
using WebApp.Contracts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddValidation(); // .NET 11 endpoint filter that runs DataAnnotations
var app = builder.Build();
app.MapPost("/api/register",
Results<Ok<RegistrationResponse>, ValidationProblem> (RegistrationRequest req) =>
{
// model is already validated by the endpoint filter
return TypedResults.Ok(new RegistrationResponse(Guid.NewGuid()));
});
app.Run();
public sealed record RegistrationResponse(Guid UserId);
AddValidation() — это helper из .NET 11, который регистрирует endpoint filter, обходящий обнаруженные через [Validator] или аннотированные DataAnnotations члены каждого параметра и закорачивающийся телом 400 ValidationProblemDetails до запуска вашего handler-а. Форма ответа та же, что клиент читает обратно ниже.
Форма клиента в WebApp.Client/Pages/Register.razor:
@* Blazor WebAssembly, .NET 11. Microsoft.AspNetCore.Components 11.0.0 *@
@page "/register"
@using System.Net.Http.Json
@using WebApp.Contracts
@inject HttpClient Http
<EditForm Model="model" OnValidSubmit="SubmitAsync" FormName="register">
<DataAnnotationsValidator />
<ValidationSummary />
<label>Email <InputText @bind-Value="model.Email" /></label>
<ValidationMessage For="() => model.Email" />
<label>Password <InputText type="password" @bind-Value="model.Password" /></label>
<ValidationMessage For="() => model.Password" />
<button type="submit">Register</button>
</EditForm>
@code {
private RegistrationRequest model = new()
{
Email = "", Password = "", ConfirmPassword = "", Username = ""
};
private async Task SubmitAsync()
{
var response = await Http.PostAsJsonAsync("api/register", model);
if (!response.IsSuccessStatusCode)
{
await ApplyServerValidationAsync(response);
}
}
}
Две вещи делают это историей общей валидации, а не двух параллельных. Во-первых, model — это RegistrationRequest, тот же DTO, что связывает сервер. Во-вторых, когда <DataAnnotationsValidator> оценивает форму, он выполняет ровно тот же проход Validator.TryValidateObject, что и endpoint filter сервера. Что принимает клиент, то принимает и сервер; что сервер отклоняет с EmailAddress, то отклоняет и клиент.
Перенос ValidationProblemDetails с сервера обратно в EditContext
Даже с общими правилами два класса ошибок приходят только с сервера: межагрегатные проверки (email уникален в таблице пользователей) и инфраструктурные сбои (rate limit, ограничение БД). Для них сервер возвращает 400 с ValidationProblemDetails, и клиент должен извлечь каждую ошибку поля и прикрепить её к правильному FieldIdentifier в EditContext, чтобы пользователь увидел сообщение встроенно рядом с проблемным полем, а не как универсальный alert “регистрация не удалась”.
// WebApp.Client/Validation/EditContextExtensions.cs
// .NET 11, C# 14
using Microsoft.AspNetCore.Components.Forms;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
public static class EditContextExtensions
{
private static readonly JsonSerializerOptions Options =
new(JsonSerializerDefaults.Web);
public static async Task ApplyValidationProblemAsync(
this EditContext editContext,
HttpResponseMessage response)
{
if ((int)response.StatusCode != 400) return;
var problem = await response.Content
.ReadFromJsonAsync<ValidationProblemDetails>(Options);
if (problem?.Errors is null) return;
var messageStore = new ValidationMessageStore(editContext);
messageStore.Clear();
foreach (var (fieldName, messages) in problem.Errors)
{
// ASP.NET Core uses lowercase-first names by default; normalize.
var pascal = char.ToUpperInvariant(fieldName[0]) + fieldName[1..];
var identifier = new FieldIdentifier(editContext.Model, pascal);
foreach (var msg in messages) messageStore.Add(identifier, msg);
}
editContext.NotifyValidationStateChanged();
}
}
Тогда handler в Razor-файле принимает вид:
private EditContext editContext = default!;
protected override void OnInitialized() =>
editContext = new EditContext(model);
private async Task SubmitAsync()
{
var response = await Http.PostAsJsonAsync("api/register", model);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
await editContext.ApplyValidationProblemAsync(response);
}
Это важно потому, что сервер — единственное место, где могут выполняться некоторые проверки. Правило “username уже занят” не может жить в общей библиотеке, потому что требует обращения к базе. Передавая его сбой в тот же EditContext, пользователь получает единую ментальную модель: каждая ошибка появляется рядом с проблемным полем, независимо от того, сработало правило в браузере или в API.
Когда DataAnnotations недостаточно: FluentValidation 12 в общем проекте
DataAnnotations не может выразить условные правила (“Postcode обязателен, если Country равен ‘US’”), не может запускать асинхронные проверки против сервиса, и его сообщения об ошибках неудобно локализовать дальше одного файла ресурсов на атрибут. FluentValidation 12, выпущенный в 2026 с первоклассной поддержкой .NET 11, спокойно живёт в том же общем проекте и работает в обе стороны.
Добавьте пакет и напишите валидатор рядом с DTO:
dotnet add WebApp.Contracts package FluentValidation --version 12.0.0
// WebApp.Contracts/RegistrationRequestValidator.cs
// FluentValidation 12.0.0, .NET 11, C# 14
using FluentValidation;
namespace WebApp.Contracts;
public sealed class RegistrationRequestValidator : AbstractValidator<RegistrationRequest>
{
public RegistrationRequestValidator()
{
RuleFor(r => r.Email).NotEmpty().EmailAddress().MaximumLength(254);
RuleFor(r => r.Password).NotEmpty().MinimumLength(12).MaximumLength(72);
RuleFor(r => r.ConfirmPassword).Equal(r => r.Password)
.WithMessage("Passwords do not match.");
RuleFor(r => r.Username).Matches(@"^[a-zA-Z0-9_]{3,20}$");
RuleFor(r => r.Age).InclusiveBetween(13, 130);
}
}
На сервере зарегистрируйте FluentValidation как источник валидаторов для того же фильтра AddValidation() или вызывайте его явно из фильтра minimal API:
// WebApp.Server/Program.cs additions
using FluentValidation;
using WebApp.Contracts;
builder.Services.AddScoped<IValidator<RegistrationRequest>,
RegistrationRequestValidator>();
app.MapPost("/api/register", async (
RegistrationRequest req,
IValidator<RegistrationRequest> validator) =>
{
var result = await validator.ValidateAsync(req);
if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());
return Results.Ok(new RegistrationResponse(Guid.NewGuid()));
});
result.ToDictionary() производит форму IDictionary<string, string[]>, которую ожидает Results.ValidationProblem, поэтому формат провода, который декодирует клиент, идентичен пути DataAnnotations. Ваше расширение ApplyValidationProblemAsync продолжает работать.
На клиенте установите Blazored.FluentValidation (форк aksoftware — это активно поддерживаемый в 2026, версия 2.4.0, нацеленная на net11.0) и замените <DataAnnotationsValidator /> на <FluentValidationValidator />:
dotnet add WebApp.Client package Blazored.FluentValidation --version 2.4.0
@using Blazored.FluentValidation
<EditForm Model="model" OnValidSubmit="SubmitAsync">
<FluentValidationValidator />
<ValidationSummary />
@* same fields as before *@
</EditForm>
Компонент находит валидатор по соглашению (FooValidator для Foo) в сборке, содержащей модель, то есть WebApp.Contracts. Поскольку валидатор находится в общем проекте, клиент и сервер исполняют один и тот же экземпляр одних и тех же правил. Единственное различие — где они исполняются.
Асинхронные правила, которые должны выполняться только на сервере
FluentValidation позволяет смешивать синхронные и асинхронные правила. Соблазнительно положить MustAsync(IsUsernameAvailableAsync) в валидатор и считать дело сделанным. Не надо: сторона клиента не имеет доступа к вашему UserManager, а синхронный Blazor EditForm не может ожидать асинхронное правило прямо посреди нажатия клавиши. Работающий паттерн — пометить async-only правила через RuleSet:
public sealed class RegistrationRequestValidator : AbstractValidator<RegistrationRequest>
{
public RegistrationRequestValidator(IUserUniqueness? uniqueness = null)
{
// rules that run everywhere
RuleFor(r => r.Email).NotEmpty().EmailAddress();
// ... shared rules omitted
RuleSet("Server", () =>
{
if (uniqueness is null) return; // skipped on client
RuleFor(r => r.Email).MustAsync(uniqueness.IsEmailFreeAsync)
.WithMessage("This email is already registered.");
RuleFor(r => r.Username).MustAsync(uniqueness.IsUsernameFreeAsync)
.WithMessage("Username taken.");
});
}
}
// WebApp.Contracts/IUserUniqueness.cs - interface only, no implementation
public interface IUserUniqueness
{
ValueTask<bool> IsEmailFreeAsync(string email, CancellationToken ct);
ValueTask<bool> IsUsernameFreeAsync(string username, CancellationToken ct);
}
Интерфейс лежит в WebApp.Contracts, чтобы валидатор скомпилировался, но реализации там нет. Сервер предоставляет реальную реализацию на базе EF Core; клиент её не регистрирует, поэтому параметр конструктора равен null, и ruleset Server не добавляет правил. На сервере вы явно его включаете:
await validator.ValidateAsync(req,
options => options.IncludeRuleSets("default", "Server"));
Так межагрегатная проверка срабатывает только там, где может, и возвращается клиенту через тот же маппинг ValidationProblemDetails, который вы уже построили.
Подводные камни trim и AOT в шаге публикации WASM
Публикация Blazor WebAssembly в .NET 11 по умолчанию выполняет IL trimming и поддерживает отдельный AOT проход с <RunAOTCompilation>true</RunAOTCompilation>. Оба прохода выдают предупреждения, когда библиотека использует неограниченную рефлексию, что и делают и DataAnnotations, и FluentValidation. Три конкретные вещи, которые нужно сделать:
- Пометьте общий проект как обрезаемый:
<IsTrimmable>true</IsTrimmable>и<IsAotCompatible>true</IsAotCompatible>вWebApp.Contracts.csproj. Это заставляет SDK выводить trim-предупреждения внутри общей библиотеки, где вы можете их исправить, вместо того чтобы молча обрезать обнаружение правил у потребителя. - Для DataAnnotations runtime со времён .NET 8 поставляет аннотации
[DynamicallyAccessedMembers(All)]наValidator.TryValidateObject, и они по-прежнему действуют в .NET 11; вам не нужно делать ничего больше, пока ваш DTOpublicи достижим из корня, который trimmer видит.EditFormдостигает типа модели через generic-аргумент, что засчитывается. - Для FluentValidation 12 каждый определяемый вами валидатор подвергается рефлексии при старте. Компонент
Blazored.FluentValidation2.4.0 сканирует сборку с применёнными аннотациями[DynamicDependency], чтобы пережить trimming, но если вы публикуете сRunAOTCompilation, добавьте<TrimmerRootAssembly Include="WebApp.Contracts" />в.csprojклиента. Это укореняет всю общую сборку и является самым простым корректным ответом; стоимость по размеру WASM мала, поскольку единственные публичные типы вWebApp.Contracts— это DTO и валидаторы, которые вы и так используете.
Если пропустить эти шаги, клиент выглядит здоровым в dotnet run, а затем выкатывается Release-билд, в котором валидация молча ничего не делает, потому что trimmer удалил правила, использование которых не смог доказать статически.
Регистр в именах полей и ловушка snake_case
Стандартные JSON-параметры ASP.NET Core 11 сериализуют имена свойств в camelCase. Поэтому ValidationProblemDetails.Errors приходит с ключом email, а не Email, и FieldIdentifier чувствителен к регистру. Нормализация в pascal в ApplyValidationProblemAsync покрывает обычный случай, но не вложенные члены (Address.PostalCode становится address.PostalCode, если поднять только первую букву). Для вложенных DTO разделите по ., поднимите первую букву каждого сегмента и затем спускайтесь во вложенный объект, используя сегменты для построения цепочки экземпляров FieldIdentifier(parent, propertyName). Или, если вы контролируете JSON-параметры, установите JsonNamingPolicy = null только для ProblemDetails, написав свой IProblemDetailsService. Более простой ответ — держать DTO достаточно плоскими, чтобы переключение регистра было однострочным.
Если вы глобально применяете другую naming policy (snake_case популярен в 2026 из-за инструментов OpenAPI), идея та же: распарсите политику, инвертируйте её и подайте исправленное имя в FieldIdentifier. Встроенного helper-а для этого в Microsoft.AspNetCore.Components.Forms нет; EditContext был спроектирован до того, как ProblemDetails стал стандартной формой ошибки, и они до сих пор не соединены.
Связанные руководства и исходные материалы
Для вспомогательной обвязки, которую это руководство предполагает у вас наличной: паттерн глобального exception filter в ASP.NET Core 11 ловит не-валидационные сбои, которые никогда не должны доходить до пользователя как 500. Если хотите глубже взглянуть на endpoint, поддерживающий эту форму, refresh-токены в ASP.NET Core Identity показывают продолжение /api/register. Для типизированных клиентов, генерируемых из того же DTO, чтобы вы не печатали URL вручную, см. генерация строго типизированных клиентов из спецификации OpenAPI на .NET 11. А на стороне JSON пользовательский JsonConverter в System.Text.Json — это правильный аварийный выход, когда одно поле общего DTO нуждается в разной форме на проводе.
Первичные источники, использованные при написании:
- Endpoint validation filter для minimal API в ASP.NET Core 11, MS Learn.
- Blazor
EditFormиDataAnnotationsValidator, MS Learn. - Справка по
ValidationProblemDetails, .NET API Browser. - Документация FluentValidation 12, страница интеграции с Blazor.
- Blazored.FluentValidation 2.4.0, README на GitHub.
- Руководство по trimming и AOT для Blazor WebAssembly в .NET 11, MS Learn.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.