FluentValidation — это мощная библиотека для валидации объектов в .NET. Все знают базовые правила, но когда доходит до кастомных сообщений об ошибках, начинаются танцы с бубном.
В этой статье разберем:
как работают сообщения в
WithMessage(и почему там есть подводные камни)чем отличается передача строки от лямбда-выражения
что происходит, когда мы лезем в
CustomиMustкак не выстрелить себе в ногу с производительностью
Часть 1. WithMessage: строки и лямбды
1.1. Простой вывод сообщения об ошибке
Когда вы используете метод WithMessage и передаете строку напрямую:
RuleFor(customer => customer.FirstName) .NotNull() .WithMessage("Это сообщение об ошибке.");
В данном случае сообщение выводится как есть, но оно не позволяет динамически управлять содержимым. Если вам необходимо добавить динамически изменяемые или кастомные сообщения, стоит рассмотреть использование лямбда-выражений.
1.2. Динамическое формирование сообщения с помощью лямбда-выражения
Используя перегрузку метода WithMessage, вы можете передать лямбда-выражение, которое будет возвращать строку:
RuleFor(customer => customer.FirstName) .NotNull() .WithMessage(customer => $"Ошибка: {customer.FirstName} не может быть пустой.");
Этот способ позволяет вам динамически использовать значения, которые могут меняться в зависимости от состояния объекта в процессе валидации. Также вы можете использовать ссылки на другие свойства проверяемого объекта.
1.3. Важное различие: строка vs лямбда
Многие думают, что можно сделать так:
// НЕ РАБОТАЕТ (отличается от 1.2 тем, что тут нет .WithMessage(customer => ) RuleFor(customer => customer.FirstName) .NotNull() .WithMessage($"Ошибка: {customer.FirstName} не может быть пустым.");
Это не скомпилируется, потому что customer не существует в этом контексте. Даже если вынести в переменную:
// ТОЖЕ НЕ РАБОТАЕТ string message = $"Ошибка: {someCustomer.FirstName} не может быть пустым"; RuleFor(customer => customer.FirstName) .NotNull() .WithMessage(message);
Сообщение зафиксируется один раз при создании правил и не будет меняться при валидации разных объектов.
Правило: если в сообщении нужны данные из конкретного экземпляра — используйте лямбду.
1.4. А если объект не нужен?
Иногда нужно, чтобы сообщение вычислялось в момент валидации, но сам объект не требуется (например, для локализации). Можно использовать _:
RuleFor(customer => customer.FirstName) .NotNull() .WithMessage(_ => GetLocalizedMessage("FirstNameRequired"));
1.5. Подводный камень с переменными
Рассмотрим пример:
var validationErrors = new List<string>(); RuleFor(x => x) .Must(x => { validationErrors.Add("Ошибка 1"); return false; }) .WithMessage(x => string.Join(", ", validationErrors));
Это будет работать, потому что лямбда в WithMessage захватывает переменную validationErrors и выполняется после Must, когда ошибки уже добавлены.
А вот так не сработает:
var str = ""; RuleFor(x => x) .Must(x => { str += "Ошибка"; return false; }) .WithMessage(str); // Здесь str еще пустая!
Почему? Потому что WithMessage(str) получает значение переменной на момент создания правила, а не на момент валидации.
1.6. Еще пример с интерполяцией
var str = "Ошибка: "; RuleFor(x => x) .Must(x => { str += "ошибка"; return false; }) .WithMessage($"Ошибка: {str}"); // Вернет: "Ошибка: "
Интерполяция выполнилась сразу, подставив текущее значение str (пустое).
1.7. Как исправить
var str = "Ошибка: "; RuleFor(x => x) .Must(x => { str += "ошибка"; return false; }) .WithMessage(_ => str); // Вернет: "Ошибка: ошибка"
Лямбда захватывает переменную и берет ее актуальное значение в момент валидации.
1.8. Что говорит документация
В официальной документации FluentValidation можно найти примеры с кастомными аргументами:
RuleFor(customer => customer.LastName) .NotNull() .WithMessage(customer => string.Format( "Это сообщение ссылается на константы: {0} {1}", "привет", 5 ));
Но там не очень очевидно объяснена разница между строкой и лямбдой.
Промежуточный итог
Что хотим | Как делать |
|---|---|
Статическое сообщение |
|
Подставить свойства объекта |
|
Сообщение зависит от внешних данных, меняющихся во время валидации |
|
Сообщение из Must с накоплением ошибок |
|
Главное правило: если сообщение должно учитывать что-то, что происходит в процессе валидации — используйте лямбду. Если сообщение статическое — можно строку.
Часть 2. Custom и Must
В первой части мы разобрались с WithMessage и лямбдами. Но что делать, если логика валидации сложнее простых проверок? Например, нужно:
проверить несколько связанных полей одновременно
вызвать внешний сервис или сходить в базу данных
накопить список ошибок перед формированием сообщения
добавить к ошибке дополнительные данные
Тут на помощь приходят методы Custom и Must (и их асинхронные версии).
2.1. Базовый пример с Custom
RuleFor(customer => customer.FirstName) .Custom((firstName, context) => { if (string.IsNullOrEmpty(firstName)) { context.AddFailure("Имя обязательно для заполнения"); } });
В Custom мы получаем два параметра:
значение свойства, которое валидируем (
firstName)контекст валидации (
context), через который можно добавлять ошибки
2.2. Динамическое сообщение в Custom
RuleFor(customer => customer.FirstName) .Custom((firstName, context) => { if (string.IsNullOrEmpty(firstName)) { var customerInstance = context.InstanceToValidate; context.AddFailure($"{customerInstance.LastName}, заполните имя"); } });
Важно: InstanceToValidate — это сам валидируемый объект. Он доступен в контексте всегда.
2.3. Когда использовать Custom
Когда нужно:
проверка нескольких полей вместе
вызов внешних сервисов
сложная бизнес-логика
добавление ошибок с дополнительными данными
Когда НЕ нужно:
простые проверки (NotNull, Length, Matches) — для них есть встроенные методы
проверка одного поля без внешних зависимостей
2.4. Must и MustAsync
Метод Must — это более декларативный способ написать кастомную проверку:
RuleFor(customer => customer.Age) .Must(age => age >= 18 && age <= 100) .WithMessage("Возраст должен быть от 18 до 100 лет");
Но здесь есть нюанс: в Must передается только значение поля, а не весь объект. Если проверка зависит от других полей, нужно использовать перегрузку с объектом:
RuleFor(customer => customer.Age) .Must((customer, age) => age >= customer.MinimumAge) .WithMessage((customer, age) => $"Возраст {age} меньше минимального {customer.MinimumAge} для {customer.FirstName}");
Обратите внимание: лямбда в WithMessage теперь принимает два параметра — объект и значение.
2.5. Асинхронная валидация
Если нужно сходить в базу или вызвать API:
RuleFor(customer => customer.Email) .MustAsync(async (email, cancellationToken) => { var isAvailable = await _emailService.CheckAvailability(email); return isAvailable; }) .WithMessage("Email уже занят");
Или с объектом:
RuleFor(customer => customer.Email) .MustAsync(async (customer, email, cancellationToken) => { var isAvailable = await _emailService.CheckAvailability(email, customer.Id); return isAvailable; }) .WithMessage((customer, email) => $"Email {email} уже используется другим пользователем");
2.6. Проблема: множество ошибок и производительность
RuleFor(customer => customer.ComplexObject) .Custom((obj, context) => { var errors = ValidateDeep(obj); // Тяжелая валидация foreach (var error in errors) { // Если ошибок много, а сообщение формируется тяжело string message = GenerateDetailedMessage(error, context.InstanceToValidate); context.AddFailure(message); } });
Если GenerateDetailedMessage делает что-то нетривиальное (лезет в БД, парсит JSON, считает хэши), а ошибок много — получаем тормоза.
2.7. Решение: отложенное формирование сообщений
Вместо готовой строки можно создать ValidationFailure с плейсхолдерами:
RuleFor(customer => customer.FirstName) .Custom((firstName, context) => { if (string.IsNullOrEmpty(firstName)) { var failure = new ValidationFailure( propertyName: nameof(customer.FirstName), errorMessage: null ) { ErrorMessage = "{LastName}, заполните имя", FormattedMessagePlaceholderValues = new Dictionary<string, object> { ["LastName"] = context.InstanceToValidate.LastName } }; context.AddFailure(failure); } });
FluentValidation подставит значения только когда сообщение реально понадобится (например, при выводе пользователю).
2.8. Добавляем дополнительные данные к ошибке
Иногда нужно прикрепить к ошибке не только текст, но и дополнительные данные:
RuleFor(customer => customer.Email) .CustomAsync(async (email, context, cancellationToken) => { var service = context.GetService<IEmailValidationService>(); if (!await service.IsEmailAvailable(email, cancellationToken)) { var suggestions = await service.GetSuggestions(email); context.AddFailure(new ValidationFailure("Email", null) { ErrorMessage = $"Email {email} уже занят", CustomState = new { SuggestedAlternatives = suggestions, ErrorCode = "EMAIL_TAKEN" } }); } });
Потом в месте обработки ошибок можно достать CustomState и показать пользователю подсказки.
2.9. Must с накоплением ошибок (подводный камень)
Помните пример из первой части с накоплением ошибок в списке?
var validationErrors = new List<string>(); RuleFor(x => x) .Must(x => { validationErrors.Add("Ошибка 1"); return false; }) .WithMessage(x => string.Join(", ", validationErrors));
Это работает, но есть нюанс: Must выполняется для каждого правила отдельно. Если у вас несколько Must в одном объекте, список будет общим.
Лучше использовать Custom, если нужно собрать несколько ошибок в одном правиле:
RuleFor(x => x) .Custom((obj, context) => { var errors = new List<string>(); if (string.IsNullOrEmpty(obj.FirstName)) errors.Add("Имя обязательно"); if (string.IsNullOrEmpty(obj.LastName)) errors.Add("Фамилия обязательна"); if (obj.Age < 18) errors.Add("Возраст должен быть 18+"); if (errors.Any()) { context.AddFailure(string.Join("; ", errors)); } });
Резюме по второй части
Ситуация | Что использовать |
|---|---|
Простая проверка одного поля | Встроенные валидаторы (NotNull, Length и т.д.) |
Проверка зависит от других полей |
|
Нужен асинхронный вызов |
|
Много ошибок в одной проверке |
|
Хочется прикрепить данные к ошибке |
|
Проблемы с производительностью | Отложенное формирование через плейсхолдеры |
Главный совет: не усложняйте там, где можно обойтись простыми правилами. Custom и Must — для сложной логики, а для простых проверок есть встроенные методы.
На этом у меня всё) В следующей части расскажу о переиспользуемых валидаторах!
