Email має бути валідним, Password — мінімум 8 символів з великою літерою, а Username — унікальним у базі даних. З DataAnnotations кожне правило — це окремий атрибут над полем. Але що робити, коли правило залежить від бази даних? Або коли одне поле валідне лише у певному контексті? DataAnnotations починають тріщати по швах саме там, де флексибільність стає критичною.Більшість ASP.NET Core розробників розпочинають із вбудованої системи валідації через атрибути (DataAnnotations). Ця система проста у вивченні та достатня для базових сценаріїв:
public class RegisterRequest
{
[Required]
[StringLength(50, MinimumLength = 3)]
public string Username { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[MinLength(8)]
public string Password { get; set; } = string.Empty;
}
На перший погляд це зручно: правила існують прямо біля DTO (Data Transfer Object). Проте з ростом проєкту проявляються серйозні обмеження:
Проблема 1: Складна умовна логіка. DataAnnotations не мають вбудованого механізму для взаємозалежних правил. Наприклад: «поле PromoCode є обов'язковим лише якщо OrderType == OrderType.Promotional». Для цього потрібно реалізовувати IValidatableObject, що порушує принцип єдиної відповідальності.
Проблема 2: Валідація з доступом до БД. Перевірка унікальності email не може бути реалізована просто через атрибут, бо атрибут не має доступу до DbContext. Потрібна окрема логіка в контролері або сервісі.
Проблема 3: Тестування. Атрибути важко тестувати в ізоляції. Для перевірки правила доводиться піднімати весь стек валідації або вручну викликати Validator.TryValidateObject.
Проблема 4: Повторне використання. Якщо однакові правила потрібні для кількох DTO, правила дублюються у вигляді атрибутів на кожному класі.
Проблема 5: Вбудований синтаксис обмежений. Немає атрибута для «один з» (Must be one of [...]), для regex з читабельним повідомленням, для умовного require тощо.
FluentValidation — це бібліотека від Jeremy Skinner, яка реалізує патерн Validator Object. Замість того, щоб «чіпляти» правила до моделі через атрибути, ви створюєте окремий клас-валідатор, що успадковується від AbstractValidator<T>.
Ключова ідея: один клас відповідає за одну задачу. RegisterRequest — це просто DTO без будь-якої логіки. RegisterRequestValidator — це валідатор, і тільки валідатор. Це дотримання принципу Single Responsibility (SRP).
Для інтеграції з ASP.NET Core потрібно два пакети: сам FluentValidation та пакет для автоматичної інтеграції з pipeline.
dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore
Install-Package FluentValidation
Install-Package FluentValidation.AspNetCore
using FluentValidation;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Реєструємо всі валідатори з поточного assembly автоматично
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
var app = builder.Build();
app.MapControllers();
app.Run();
Метод AddValidatorsFromAssemblyContaining<T>() сканує збірку та автоматично реєструє всі класи, що успадковуються від AbstractValidator<T>. Це означає, що при додаванні нового валідатора нічого не потрібно змінювати в Program.cs.
Створіть директорію Validators/ і перший файл:
using FluentValidation;
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
public RegisterRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty()
.Length(3, 50);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8);
}
}
Кожне правило будується за шаблоном:
RuleFor(x => x.PropertyName)
.ValidatorMethod1()
.ValidatorMethod2()
.WithMessage("Кастомне повідомлення")
.WithName("Людська назва поля");
RuleFor — вибирає властивість через лямбда-вираз. Це strongly-typed: помилки компіляції при рефакторингу.WithMessage — перевизначає повідомлення про помилку для останнього доданого валідатора.WithName — змінює назву поля у повідомленні (за замовчуванням ім'я властивості).FluentValidation містить десятки готових валідаторів:
| Валідатор | Опис |
|---|---|
.NotEmpty() | Не null, не порожній рядок, не whitespace |
.NotNull() | Не null (допускає порожній рядок) |
.Empty() | Має бути порожнім |
.Length(min, max) | Довжина між min та max |
.MinimumLength(n) | Мінімальна довжина |
.MaximumLength(n) | Максимальна довжина |
.EmailAddress() | Валідний email |
.Matches(regex) | Відповідає регулярному виразу |
.StartsWith(str) | Починається з рядка |
.EndsWith(str) | Закінчується рядком |
| Валідатор | Опис |
|---|---|
.GreaterThan(n) | Більше за n (строго) |
.GreaterThanOrEqualTo(n) | Більше або рівне n |
.LessThan(n) | Менше за n (строго) |
.LessThanOrEqualTo(n) | Менше або рівне n |
.InclusiveBetween(min, max) | Між min та max (включно) |
.ExclusiveBetween(min, max) | Між min та max (виключно) |
.PrecisionScale(precision, scale, ignoreTrailing) | Для decimal: точність та масштаб |
| Валідатор | Опис |
|---|---|
.NotEmpty() | Колекція не порожня |
.Must(x => x.Count <= 10) | Кастомна умова для колекції |
.ForEach(rule => ...) | Правила для кожного елемента |
| Валідатор | Опис |
|---|---|
.Equal(value) | Дорівнює значенню |
.NotEqual(value) | Не дорівнює значенню |
.IsInEnum() | Входить до переліку enum |
.Must(predicate) | Кастомна логіка через лямбда |
.MustAsync(asyncPredicate) | Асинхронна кастомна логіка |
Розберімо реалістичний валідатор для форми замовлення:
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
// Правило 1: CustomerName — обов'язкове, 2-100 символів
RuleFor(x => x.CustomerName)
.NotEmpty()
.WithMessage("Ім'я покупця є обов'язковим.")
.Length(2, 100)
.WithMessage("Ім'я має бути від {MinLength} до {MaxLength} символів.");
// Правило 2: Email — обов'язковий, валідний формат
RuleFor(x => x.Email)
.NotEmpty()
.WithMessage("Email є обов'язковим.")
.EmailAddress()
.WithMessage("'{PropertyValue}' не є валідним email.");
// Правило 3: Кількість товарів — від 1 до 100
RuleFor(x => x.Quantity)
.InclusiveBetween(1, 100)
.WithMessage("Кількість має бути між {From} та {To}.");
// Правило 4: Ціна — більше нуля, з двома знаками після коми
RuleFor(x => x.Price)
.GreaterThan(0)
.WithMessage("Ціна має бути більшою за нуль.")
.PrecisionScale(10, 2, false)
.WithMessage("Ціна має мати не більше 2 знаків після коми.");
// Правило 5: DeliveryDate — у майбутньому
RuleFor(x => x.DeliveryDate)
.GreaterThan(DateTime.UtcNow)
.WithMessage("Дата доставки має бути в майбутньому.");
}
}
Зверніть увагу на плейсхолдери у повідомленнях. FluentValidation підтримує кілька вбудованих змінних:
{PropertyName} — назва властивості{PropertyValue} — поточне значення{MinLength}, {MaxLength} — для Length(){From}, {To} — для Between().Must() та .MustAsync()Метод .Must() приймає предикат — функцію, яка повертає bool. Це відкриває двері для будь-якої логіки без зовнішніх залежностей:
public class ChangePasswordValidator : AbstractValidator<ChangePasswordRequest>
{
public ChangePasswordValidator()
{
RuleFor(x => x.NewPassword)
.NotEmpty()
.MinimumLength(8)
// Перевірка наявності великої літери
.Must(password => password.Any(char.IsUpper))
.WithMessage("Пароль має містити хоча б одну велику літеру.")
// Перевірка наявності цифри
.Must(password => password.Any(char.IsDigit))
.WithMessage("Пароль має містити хоча б одну цифру.")
// Перевірка наявності спецсимволу
.Must(password => password.Any(c => !char.IsLetterOrDigit(c)))
.WithMessage("Пароль має містити хоча б один спеціальний символ.");
// Перевірка, що новий пароль відрізняється від старого
// Must() може приймати весь об'єкт як другий аргумент
RuleFor(x => x.NewPassword)
.Must((request, newPassword) => newPassword != request.OldPassword)
.WithMessage("Новий пароль не може збігатися зі старим.");
}
}
Ключові деталі:
.Must(value => bool) — перевіряє лише значення поля..Must((rootObject, value) => bool) — перевіряє значення у контексті всього об'єкта. Це дозволяє порівнювати поля між собою.Найпотужніший аспект FluentValidation — підтримка асинхронних перевірок. Для перевірки унікальності email у базі даних:
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
private readonly AppDbContext _db;
// Валідатор наслідує залежності через конструктор
public RegisterRequestValidator(AppDbContext db)
{
_db = db;
RuleFor(x => x.Username)
.NotEmpty()
.Length(3, 50)
// Перевірка унікальності username в БД
.MustAsync(BeUniqueUsernameAsync)
.WithMessage("Ім'я користувача '{PropertyValue}' вже зайнято.");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
// Перевірка унікальності email в БД
.MustAsync(async (email, cancellationToken) =>
!await _db.Users.AnyAsync(u => u.Email == email, cancellationToken))
.WithMessage("Ця електронна адреса вже зареєстрована.");
}
// Виносимо логіку в окремий приватний метод для читабельності
private async Task<bool> BeUniqueUsernameAsync(
string username,
CancellationToken cancellationToken)
{
return !await _db.Users
.AnyAsync(u => u.Username == username, cancellationToken);
}
}
Оскільки AppDbContext реєструється як Scoped сервіс, а FluentValidation реєструє валідатори також як Scoped — ін'єкція залежностей працює коректно.
.MustAsync() не спрацює при синхронному виклику validator.Validate(instance). Для асинхронних правил завжди використовуйте await validator.ValidateAsync(instance).When() та Unless()Іноді правила потрібно застосовувати лише за певних умов. FluentValidation надає для цього методи When() та Unless():
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
// PromoCode валідується лише якщо UsePromo == true
RuleFor(x => x.PromoCode)
.NotEmpty()
.WithMessage("Промокод є обов'язковим при використанні акції.")
.Length(6, 20)
.WithMessage("Промокод має бути від 6 до 20 символів.")
.When(x => x.UsePromo); // Умова!
// ShippingAddress є обов'язковим лише для доставки, не для самовивозу
RuleFor(x => x.ShippingAddress)
.NotEmpty()
.WithMessage("Адреса доставки є обов'язковою для кур'єрської доставки.")
.Unless(x => x.DeliveryMethod == DeliveryMethod.Pickup);
// CompanyName є обов'язковим лише для юридичних осіб
RuleFor(x => x.CompanyName)
.NotEmpty()
.WithMessage("Назва компанії є обов'язковою для корпоративних замовлень.")
.When(x => x.CustomerType == CustomerType.Business);
}
}
Методи When() та Unless() приймають предикат на рівні кореневого об'єкта, що дозволяє приймати складні рішення на основі будь-яких властивостей.
RuleSet для групованої валідаціїУ складних сценаріях, коли однакова модель валідується по-різному залежно від контексту (наприклад, Create vs Update), використовуються RuleSets:
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
// Загальне правило — завжди активне
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(200);
// Правила лише для Create
RuleSet("Create", () =>
{
RuleFor(x => x.Sku)
.NotEmpty()
.WithMessage("SKU є обов'язковим при створенні товару.");
});
// Правила лише для Update
RuleSet("Update", () =>
{
RuleFor(x => x.Id)
.GreaterThan(0)
.WithMessage("Невалідний ID для оновлення.");
});
}
}
// Валідація з конкретним RuleSet
var validationResult = await _validator.ValidateAsync(
product,
options => options.IncludeRuleSets("Create"),
cancellationToken);
Якщо DTO містить вкладений об'єкт, для нього також можна визначити окремий валідатор та підключити його через SetValidator():
public class CreateOrderRequest
{
public string CustomerName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public List<OrderItemDto> Items { get; set; } = [];
public AddressDto? ShippingAddress { get; set; }
}
public class AddressDto
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Zip { get; set; } = string.Empty;
}
public class OrderItemDto
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}
public class AddressDtoValidator : AbstractValidator<AddressDto>
{
public AddressDtoValidator()
{
RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
RuleFor(x => x.City).NotEmpty().MaximumLength(100);
RuleFor(x => x.Zip)
.NotEmpty()
.Matches(@"^\d{5}(-\d{4})?$")
.WithMessage("Zip code має відповідати формату 12345 або 12345-6789.");
}
}
public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
public OrderItemDtoValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.Quantity).InclusiveBetween(1, 999);
}
}
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerName).NotEmpty().Length(2, 100);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
// Валідація вкладеного об'єкта через SetValidator
RuleFor(x => x.ShippingAddress)
.NotNull()
.WithMessage("Адреса доставки є обов'язковою.")
.SetValidator(new AddressDtoValidator());
// Валідація кожного елемента колекції через ForEach або RuleForEach
RuleForEach(x => x.Items)
.SetValidator(new OrderItemDtoValidator());
// Додаткове правило для всієї колекції
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Замовлення має містити хоча б один товар.")
.Must(items => items.Count <= 50)
.WithMessage("В одному замовленні не може бути більше 50 позицій.");
}
}
При валідації CreateOrderRequest, FluentValidation автоматично:
CustomerName та EmailShippingAddress на null, потім запускає AddressDtoValidatorItems запускає OrderItemDtoValidatorПомилки вкладених об'єктів мають шлях у вигляді: ShippingAddress.Street, Items[0].Quantity, що дозволяє точно ідентифікувати джерело помилки на фронтенді.
Якщо одне правило потрібне у кількох валідаторах, його можна винести в extension method:
public static class PhoneValidationExtensions
{
// Розширення для IRuleBuilder — дозволяє додавати до ланцюжка
public static IRuleBuilderOptions<T, string> ValidPhoneNumber<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.Matches(@"^\+?[1-9]\d{1,14}$")
.WithMessage("'{PropertyValue}' не є валідним номером телефону у форматі E.164.")
.MaximumLength(20);
}
public static IRuleBuilderOptions<T, string> ValidUkrainianPhone<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.Matches(@"^\+380\d{9}$")
.WithMessage("Номер телефону має бути у форматі +380XXXXXXXXX.");
}
}
Тепер це правило можна використовувати як вбудований метод у будь-якому валідаторі:
public class UserValidator : AbstractValidator<UserDto>
{
public UserValidator()
{
RuleFor(x => x.Phone)
.NotEmpty()
.ValidUkrainianPhone(); // Наше розширення!
}
}
Для складнішої логіки можна створити клас, що реалізує IPropertyValidator<T, TProperty>:
public class LuhnCreditCardValidator<T> : PropertyValidator<T, string>
{
public override string Name => "LuhnCreditCardValidator";
protected override string GetDefaultMessageTemplate(string errorCode)
=> "'{PropertyName}' не є валідним номером кредитної картки.";
public override bool IsValid(ValidationContext<T> context, string value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
// Алгоритм Луна для перевірки номера картки
var digits = value.Replace(" ", "").Replace("-", "");
if (!digits.All(char.IsDigit)) return false;
int sum = 0;
bool alternate = false;
for (int i = digits.Length - 1; i >= 0; i--)
{
int digit = digits[i] - '0';
if (alternate)
{
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
alternate = !alternate;
}
return sum % 10 == 0;
}
}
// Extension method для зручного використання
public static class CreditCardValidationExtensions
{
public static IRuleBuilderOptions<T, string> ValidCreditCardNumber<T>(
this IRuleBuilder<T, string> ruleBuilder)
=> ruleBuilder.SetValidator(new LuhnCreditCardValidator<T>());
}
Існує два підходи до запуску валідації в контролерах.
Ручна валідація — явний виклик валідатора всередині ендпоінта:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IValidator<RegisterRequest> _validator;
public UsersController(IValidator<RegisterRequest> validator)
{
_validator = validator;
}
[HttpPost("register")]
public async Task<IActionResult> Register(
[FromBody] RegisterRequest request,
CancellationToken cancellationToken)
{
// Явний виклик
var validationResult = await _validator
.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
// Повертаємо помилки у вигляді словника для фронтенду
return BadRequest(validationResult.ToDictionary());
}
// ... логіка реєстрації
return Ok();
}
}
Автоматична валідація — через middleware або фільтри. Для цього встановлюється пакет FluentValidation.AspNetCore:
builder.Services.AddControllers();
// Реєструємо валідатори
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Підключаємо автоматичну валідацію (замінює ModelState)
// УВАГА: FluentValidation.AspNetCore підтримує цей підхід,
// але команда FluentValidation рекомендує ручну валідацію
// або валідацію через Filters для кращого контролю.
IValidator<T>. Це дає більший контроль над форматом відповіді та є більш явним. Автоматична інтеграція через AddFluentValidationAutoValidation() зручна, але може приховувати логіку.Для стандартизованих відповідей використовуйте RFC 7807 Problem Details:
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
public class ValidationFilter<T> : IAsyncActionFilter
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
// Знаходимо аргумент типу T серед параметрів дії
var argument = context.ActionArguments.Values
.OfType<T>()
.FirstOrDefault();
if (argument is null)
{
await next();
return;
}
var validationResult = await _validator.ValidateAsync(argument);
if (!validationResult.IsValid)
{
// Формат Problem Details з вкладеними помилками
var problemDetails = new ValidationProblemDetails(
validationResult.ToDictionary())
{
Status = StatusCodes.Status400BadRequest,
Title = "Помилки валідації",
Detail = "Одне або більше полів містять недійсні значення."
};
context.Result = new BadRequestObjectResult(problemDetails);
}
else
{
await next();
}
}
}
Для Minimal API, де немає контролерів та фільтрів, рекомендований підхід — ін'єкція IValidator<T> безпосередньо в ендпоінт:
using FluentValidation;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
var app = builder.Build();
app.MapPost("/api/users", async (
RegisterRequest request,
IValidator<RegisterRequest> validator,
CancellationToken ct) =>
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
// Формуємо словник помилок: { "Email": ["Not valid email"] }
var errors = result.ToDictionary();
return Results.ValidationProblem(errors);
}
// ... логіка створення користувача
return Results.Created($"/api/users/1", new { message = "Користувача створено." });
});
app.Run();
Results.ValidationProblem() — вбудований метод Minimal API, що повертає 400 Bad Request з тілом у форматі application/problem+json.
Одна з головних переваг FluentValidation над DataAnnotations — тестованість. Валідатор — це простий клас, який легко тестується без запуску веб-сервера.
using FluentValidation.TestHelper;
public class RegisterRequestValidatorTests
{
private readonly RegisterRequestValidator _validator;
public RegisterRequestValidatorTests()
{
// Для валідаторів без залежностей — просте створення
_validator = new RegisterRequestValidator();
}
[Fact]
public void Should_have_error_when_email_is_empty()
{
var request = new RegisterRequest { Email = "" };
var result = _validator.TestValidate(request);
result.ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public void Should_have_error_when_email_is_invalid()
{
var request = new RegisterRequest { Email = "not-an-email" };
var result = _validator.TestValidate(request);
result.ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public void Should_not_have_error_when_email_is_valid()
{
var request = new RegisterRequest { Email = "user@example.com" };
var result = _validator.TestValidate(request);
result.ShouldNotHaveValidationErrorFor(x => x.Email);
}
[Theory]
[InlineData("ab")] // Задовгий (мінімум 3)
[InlineData("")] // Порожній
[InlineData("this_is_way_too_long_username_for_our_system")] // Занадто довгий
public void Should_have_error_for_invalid_username(string username)
{
var request = new RegisterRequest { Username = username };
var result = _validator.TestValidate(request);
result.ShouldHaveValidationErrorFor(x => x.Username);
}
[Fact]
public void Should_pass_for_valid_request()
{
var request = new RegisterRequest
{
Username = "valid_user",
Email = "user@example.com",
Password = "SecureP@ss1"
};
var result = _validator.TestValidate(request);
result.ShouldNotHaveAnyValidationErrors();
}
}
Для валідаторів з залежностями (наприклад, AppDbContext) використовуємо Moq або NSubstitute:
using NSubstitute;
using Microsoft.EntityFrameworkCore;
public class RegisterRequestValidatorAsyncTests
{
[Fact]
public async Task Should_have_error_when_email_already_exists()
{
// Arrange: мокуємо DbContext
var db = CreateMockDbContext(emailExists: true);
var validator = new RegisterRequestValidator(db);
var request = new RegisterRequest
{
Username = "newuser",
Email = "existing@example.com",
Password = "SecureP@ss1"
};
// Act
var result = await validator.ValidateAsync(request);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors,
e => e.PropertyName == "Email" &&
e.ErrorMessage.Contains("вже зареєстрована"));
}
private static AppDbContext CreateMockDbContext(bool emailExists)
{
// Використання InMemory БД для тестів
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new AppDbContext(options);
if (emailExists)
{
db.Users.Add(new AppUser { Email = "existing@example.com" });
db.SaveChanges();
}
return db;
}
}
FluentValidation підтримує локалізацію через:
.resxILanguageManagerusing FluentValidation.Resources;
public class UkrainianLanguageManager : LanguageManager
{
public UkrainianLanguageManager()
{
// Перевизначаємо вбудовані повідомлення
AddTranslation("uk", "NotEmptyValidator",
"'{PropertyName}' не повинно бути порожнім.");
AddTranslation("uk", "EmailValidator",
"'{PropertyName}' не є коректною електронною адресою.");
AddTranslation("uk", "MinimumLengthValidator",
"'{PropertyName}' повинно бути не менше {MinLength} символів." +
" Ви ввели {TotalLength}.");
AddTranslation("uk", "MaximumLengthValidator",
"'{PropertyName}' не може перевищувати {MaxLength} символів." +
" Ви ввели {TotalLength}.");
AddTranslation("uk", "InclusiveBetweenValidator",
"'{PropertyName}' повинно бути між {From} та {To}." +
" Ви ввели {PropertyValue}.");
AddTranslation("uk", "GreaterThanValidator",
"'{PropertyName}' повинно бути більше '{ComparisonValue}'.");
}
}
ValidatorOptions.Global.LanguageManager = new UkrainianLanguageManager();
ValidatorOptions.Global.LanguageManager.Culture = new CultureInfo("uk");
У реальних проєктах валідатори найчастіше розміщують поряд із відповідними запитами або командами:
За замовчуванням FluentValidation перевіряє всі правила та збирає всі помилки. Для зміни поведінки:
public class StrictOrderValidator : AbstractValidator<CreateOrderRequest>
{
public StrictOrderValidator()
{
// CASCADE: зупиняємо перевірку цього поля після першої помилки
RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmailAsync); // Не дійде, якщо вище є помилки
// Для всього валідатора:
// ValidatorOptions.Global.DefaultClassLevelCascadeMode = CascadeMode.Stop;
}
}
Це особливо важливо для асинхронних правил: немає сенсу робити запит до БД для перевірки унікальності email, якщо email вже невалідний за форматом.
Завдання 1.1. Створіть DTO CreateProductRequest з полями: Name (string), Price (decimal), Stock (int). Напишіть CreateProductValidator, що перевіряє:
Name — не порожній, максимум 200 символівPrice — більше нуляStock — від 0 до 9999Завдання 1.2. Виправте валідатор нижче — знайдіть помилку:
RuleFor(x => x.Age)
.GreaterThan(18)
.WithMessage("Вік має бути більше 18.");
// Питання: чи буде 18 проходити валідацію?
Завдання 2.1. Реалізуйте валідатор для форми доставки:
FullName — завжди обов'язковийEmail — обов'язковий і валідний emailPhone — обов'язковий якщо NotificationType == NotificationType.SmsAddress.Street — обов'язковий якщо DeliveryType == DeliveryType.CourierЗавдання 2.2. Напишіть extension method ValidPassword<T>(), що перевіряє: мінімум 8 символів, наявність великої літери, цифри та спецсимволу.
Завдання 3.1. Реалізуйте RegistrationValidator з асинхронною перевіркою унікальності email та username через IUserRepository (не DbContext напряму). Напишіть unit-тести з mock-репозиторієм для трьох сценаріїв: email унікальний, email зайнятий, username зайнятий.
Завдання 3.2. Створіть endpoint POST /api/products у Minimal API, що приймає CreateProductRequest, запускає валідацію та повертає Results.ValidationProblem() при помилках або Results.Created() при успіху.
FluentValidation — це не просто альтернатива DataAnnotations, це зовсім інша філософія валідації. Замість декларативних атрибутів, розкиданих по полях DTO, ви отримуєте зосереджену, тестовану та гнучку систему:
Тестованість
TestValidate() без підняття веб-сервера.Гнучкість
.Must(), .MustAsync(), When() дозволяють реалізувати будь-яку логіку, включаючи запити до БД та зовнішніх API.Повторне використання
Чистий код
Посилання:
Чекліст виходу в Production
20 пунктів перевірки перед запуском платіжної системи в production — конфігурація, безпека, моніторинг, логування, юридична готовність.
Маппінг об
Повний огляд Mapster: базовий маппінг через Adapt, конфігурація правил, code generation, маппінг складних структур та інтеграція з ASP.NET Core.