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

Но там не очень очевидно объяснена разница между строкой и лямбдой.

Промежуточный итог

Что хотим

Как делать

Статическое сообщение

WithMessage("текст")

Подставить свойства объекта

WithMessage(x => $"текст {x.Property}")

Сообщение зависит от внешних данных, меняющихся во время валидации

WithMessage(_ => внешняя_переменная)

Сообщение из Must с накоплением ошибок

WithMessage(x => string.Join(", ", список))

Главное правило: если сообщение должно учитывать что-то, что происходит в процессе валидации — используйте лямбду. Если сообщение статическое — можно строку.

Часть 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 и т.д.)

Проверка зависит от других полей

Must с объектом или Custom

Нужен асинхронный вызов

MustAsync или CustomAsync

Много ошибок в одной проверке

Custom с накоплением

Хочется прикрепить данные к ошибке

ValidationFailure + CustomState

Проблемы с производительностью

Отложенное формирование через плейсхолдеры

Главный совет: не усложняйте там, где можно обойтись простыми правилами. Custom и Must — для сложной логики, а для простых проверок есть встроенные методы.

На этом у меня всё) В следующей части расскажу о переиспользуемых валидаторах!