Уявіть, що ви будуєте систему онлайн-банкінгу. Користувач намагається перевести гроші:
// ❌ Naive підхід - що може піти не так?
public void TransferMoney(string fromAccount, string toAccount, decimal amount)
{
var from = _accounts[fromAccount];
var to = _accounts[toAccount];
from.Balance -= amount;
to.Balance += amount;
_repository.Save(from);
_repository.Save(to);
}
Проблеми цього коду:
fromAccount не існує? → KeyNotFoundExceptionamount від'ємний? → Помилкова логікаtoAccount порожній? → Некоректна операціяКожна з цих ситуацій призведе до винятку (Exception), краху програми або некоректного стану даних. Але більшість з них не є "винятковими" — це передбачувані бізнес-правила та валідації, які мають бути частиною нормального потоку програми.
if)try-catch блокахПравильний підхід полягає в розділенні:
Result<T>), а не виняткиРозуміння того, як ми дійшли до сучасних підходів, допоможе оцінити їхню цінність:
Програмісти писали вручну всі перевірки через if:
if (string.IsNullOrEmpty(email))
throw new Exception("Email is required");
if (!email.Contains("@"))
throw new Exception("Invalid email");
Проблеми: Дублювання коду, складність підтримки, неструктуровані помилки.
Введення атрибутів для декларативної валідації:
public class User
{
[Required]
[EmailAddress]
public string Email { get; set; }
}
Переваги: Декларативність, менше коду. Недоліки: Обмежена гнучкість, складні правила важко виразити.
Винесення валідації в окремі класи з fluent API:
public class UserValidator : AbstractValidator<User>
{
public UserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}
Переваги: Розділення відповідальностей, потужна композиція правил, тестовність.
Відмова від винятків для бізнес-логіки на користь явних результатів:
public Result<User> CreateUser(string email)
{
if (string.IsNullOrEmpty(email))
return Result.Fail<User>("Email is required");
// ...
return Result.Ok(user);
}
Переваги: Явна обробка помилок, Railway Oriented Programming, краща продуктивність.
Після вивчення цього матеріалу ви зможете:
IValidatableObjectПеред вивченням цього матеріалу переконайтеся, що розумієте:
Валідація (Validation) — це процес перевірки, чи відповідають дані встановленим правилам та обмеженням перед їх використанням у бізнес-логіці.
Валідація — це перша лінія оборони проти некоректних даних. Без неї система перетворюється на "GIGO" (Garbage In, Garbage Out).
public void ProcessPayment(Payment payment)
{
// ✅ Валідація спочатку
ArgumentNullException.ThrowIfNull(payment);
if (payment.Amount <= 0)
throw new ArgumentException("Amount must be positive", nameof(payment.Amount));
// Тепер безпечно працювати з payment
_gateway.Charge(payment);
}
// ❌ Змішано
public void CreateUser(string email)
{
if (string.IsNullOrEmpty(email)) throw ...;
if (!email.Contains("@")) throw ...;
// бізнес-логіка
_repository.Add(new User(email));
}
// ✅ Розділено
public Result CreateUser(string email)
{
var validationResult = _validator.Validate(email);
if (!validationResult.IsValid)
return Result.Fail(validationResult.Errors);
_repository.Add(new User(email));
return Result.Ok();
}
// ❌ Незрозуміло
"Invalid input"
// ✅ Зрозуміло
"Email must be in format 'user@example.com'. Provided value: 'user@'"
var errors = new List<string>();
if (string.IsNullOrEmpty(user.Email))
errors.Add("Email is required");
if (user.Age < 18)
errors.Add("User must be at least 18 years old");
if (errors.Any())
return Result.Fail(errors);
Валідацію можна класифікувати за різними критеріями. Розуміння цих категорій допоможе вибрати правильний підхід.
| Категорія | Опис | Приклад | Коли використовувати |
|---|---|---|---|
| Client-side | Виконується в браузері або клієнтському додатку | JavaScript перевірки форм | Покращення UX, швидкий фідбек |
| Server-side | Виконується на сервері перед обробкою даних | ASP.NET Model Validation | Обов'язкова, фінальна лінія оборони |
| Database-level | Обмеження на рівні БД (CHECK, UNIQUE, FK) | SQL: CHECK (age >= 18) | Цілісність даних, multiple entry points |
| Domain-level | Бізнес-правила всередині domain моделі | User.ChangeEmail() з валідацією | DDD, складні інваріанти |
Чітке розуміння типу валідації дозволяє вибрати правильний інструмент та місце для її виконання.
"Is it well-formed?"
Це найбазовіший рівень перевірки, який відповідає на питання: "Чи відповідають дані очікуваному формату?". Вона не потребує жодних зовнішніх даних або бізнес-контексту. Це "stateless" перевірка — результат залежить тільки від вхідного значення.
Характеристики:
Типові перевірки:
public class SyntacticExamples
{
public void ValidateInput(string email, string ageRaw, string json)
{
// 1. Формат (Regex/Library)
if (!new EmailAddressAttribute().IsValid(email))
throw new FormatException("Invalid email format");
// 2. Тип даних (Parsing)
if (!int.TryParse(ageRaw, out _))
throw new FormatException("Age must be a number");
// 3. Структура (Well-formedness)
try {
JsonDocument.Parse(json);
} catch (JsonException) {
throw new FormatException("Invalid JSON structure");
}
}
}
"Does it make sense?"
Перевірка сенсу даних у контексті бізнес-домену. Дані можуть бути синтаксично правильними (валідний формат email), але некоректними семантично (домен example.com заблокований або не існує). Ця валідація часто потребує "знань" про світ або довідкових даних.
Характеристики:
Типові перевірки:
public void SemanticChecks(DateTime dateOfBirth, string countryCode)
{
// Синтаксично дата валідна (це struct DateTime),
// але семантично вона не може бути в майбутньому для народження.
if (dateOfBirth > DateTime.Now)
return Result.Fail("Date of birth cannot be in the future");
// "ZZ" — це валідний рядок з 2 літер (синтаксис),
// але такої країни не існує (семантика).
if (!IsoCountries.Contains(countryCode))
return Result.Fail($"Country code '{countryCode}' is not supported");
}
"Is the object consistent?"
Фокусується на цілісності об'єкта або групи полів. Це перевірка залежностей між даними, де валідність одного поля залежить від значення іншого. Це часто називають "Cross-field validation".
Характеристики:
Типові перевірки:
StartDate < EndDate.PaymentType == CreditCard, то CardNumber має бути заповнений.public class Period : IValidatableObject
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
// Перевірка відношення між полями
if (End < Start)
{
yield return new ValidationResult(
"End date must be greater than or equal to Start date",
new[] { nameof(End) });
}
}
}
// Приклад умовної залежності
public void ValidatePayment(PaymentDto dto)
{
if (dto.Method == PaymentMethod.CreditCard && string.IsNullOrEmpty(dto.CardNumber))
{
// CardNumber сам по собі може бути null (для готівки),
// але в комбінації з CreditCard — це помилка структури.
AddError("CardNumber is required for Credit Card payments");
}
}
"Is it allowed in this state?"
Найскладніший рівень валідації, який залежить від поточного стану системи, прав користувача або зовнішніх умов. Те саме значення може бути валідним в одному контексті (створення) і невалідним в іншому (редагування).
Характеристики:
Типові перевірки:
public async Task<Result> CancelOrderAsync(int orderId, int userId)
{
var order = await _repo.GetByIdAsync(orderId);
// 1. State-based validation
if (order.Status == OrderStatus.Shipped)
return Result.Fail("Cannot cancel an order that has already been shipped.");
// 2. Ownership/Permission evaluation (Authorization as Validation)
if (order.CustomerId != userId && !User.IsAdmin)
return Result.Fail("You do not have permission to cancel this order.");
// 3. External Constraint
if (await _warehouse.IsProcessingAsync(orderId))
return Result.Fail("Order is currently being processed by warehouse.");
// Якщо дійшли сюди — контекст дозволяє дію
order.Status = OrderStatus.Cancelled;
await _repo.SaveAsync(order);
return Result.Ok();
}
NotNull, NotEmpty, MaxLength(50)Must(BeUnique), Must(BeBetween(startDate, endDate))RuleFor(x => x.Email).NotEmpty().EmailAddress().Must(BeUniqueEmail)MustAsync(BeUniqueEmail) → перевіряє БДMustAsync(ExistInExternalApi) → HTTP запитВажливо не плутати валідацію з суміжними концепціями:
| Концепція | Призначення | Приклад |
|---|---|---|
| Validation | Перевірка, чи відповідають дані правилам | Email має бути валідним форматом |
| Authorization | Перевірка прав доступу користувача | Користувач має роль Admin для видалення |
| Sanitization | Очищення даних від небажаних символів | Видалити HTML теги з введення |
| Normalization | Приведення даних до стандартного формату | "JOHN DOE" → "John Doe" |
| Business Rules | Бізнес-логіка, яка визначає поведінку | Знижка 20% при покупці >5 товарів |
| Invariants | Умови, що завжди мають бути істинними | Баланс рахунку ніколи не може бути від'ємним |
" user@example.com " → "user@example.com""User@Example.COM" → "user@example.com"Defensive Programming — це підхід до написання коду, який передбачає помилки та намагається їх попередити або мінімізувати їх вплив через явні перевірки та захисні механізми.
Пишіть код так, ніби він буде використаний найгіршим розробником у світі. Цим розробником можете виявитися ви самі через шість місяців.
Guard Clause — це перевірка на початку методу, яка негайно повертає результат або кидає виняток, якщо умови виконання не дотримані.
public void ProcessOrder(Order order, User user)
{
// ❌ Без guard clauses - nested hell
if (order != null)
{
if (user != null)
{
if (order.Items.Count > 0)
{
if (user.IsActive)
{
// Фактична логіка десь тут...
}
else
throw new InvalidOperationException("User is not active");
}
else
throw new ArgumentException("Order has no items");
}
else
throw new ArgumentNullException(nameof(user));
}
else
throw new ArgumentNullException(nameof(order));
}
// ✅ З guard clauses - linear and clear
public void ProcessOrder(Order order, User user)
{
// Guards спочатку - виходимо рано при проблемах
ArgumentNullException.ThrowIfNull(order);
ArgumentNullException.ThrowIfNull(user);
if (order.Items.Count == 0)
throw new ArgumentException("Order must contain at least one item", nameof(order));
if (!user.IsActive)
throw new InvalidOperationException("Cannot process order for inactive user");
// Happy path - без вкладеності
var total = order.CalculateTotal();
_paymentService.Charge(user, total);
_emailService.SendConfirmation(user.Email, order);
}
Переваги Guard Clauses:
Починаючи з .NET 6, є вбудовані helper методи для guard clauses:
public class ArgumentNullExceptionExamples
{
// ✅ Modern way (.NET 6+)
public void ModernGuards(string name, int age, List<string> items)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(items);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(age);
ArgumentOutOfRangeException.ThrowIfZero(items.Count);
}
// ❌ Old way (before .NET 6)
public void OldGuards(string name, int age, List<string> items)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
if (items == null)
throw new ArgumentNullException(nameof(items));
if (age <= 0)
throw new ArgumentOutOfRangeException(nameof(age), "Age must be positive");
if (items.Count == 0)
throw new ArgumentOutOfRangeException(nameof(items), "Items cannot be empty");
}
}
// Null checks
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(obj, nameof(obj));
// Numeric range checks
ArgumentOutOfRangeException.ThrowIfZero(number);
ArgumentOutOfRangeException.ThrowIfNegative(number);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(number);
ArgumentOutOfRangeException.ThrowIfGreaterThan(actual, threshold);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(actual, threshold);
ArgumentOutOfRangeException.ThrowIfLessThan(actual, threshold);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(actual, threshold);
ArgumentOutOfRangeException.ThrowIfEqual(actual, unexpected);
ArgumentOutOfRangeException.ThrowIfNotEqual(actual, expected);
// String checks
ArgumentException.ThrowIfNullOrEmpty(str);
ArgumentException.ThrowIfNullOrWhiteSpace(str); // .NET 8+
Примітка: Це методи з неймспейсу System, доступні глобально.
Для специфічних перевірок можна створити власні guard методи:
public static class Guard
{
public static void AgainstNullOrEmpty(string value, string paramName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName);
}
public static void AgainstNegative(decimal value, string paramName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(paramName, $"{paramName} cannot be negative");
}
public static void AgainstInvalidEmail(string email, string paramName)
{
if (!new EmailAddressAttribute().IsValid(email))
throw new ArgumentException($"Invalid email format: {email}", paramName);
}
public static void AgainstPastDate(DateTime date, string paramName)
{
if (date < DateTime.Now)
throw new ArgumentException($"{paramName} cannot be in the past", paramName);
}
public static void AgainstOutOfRange<T>(T value, T min, T max, string paramName)
where T : IComparable<T>
{
if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0)
throw new ArgumentOutOfRangeException(paramName,
$"{paramName} must be between {min} and {max}");
}
}
// Використання
public void ScheduleEvent(string title, DateTime eventDate, decimal ticketPrice)
{
Guard.AgainstNullOrEmpty(title, nameof(title));
Guard.AgainstPastDate(eventDate, nameof(eventDate));
Guard.AgainstNegative(ticketPrice, nameof(ticketPrice));
// Business logic
}
Пояснення коду вище:
AgainstNullOrEmpty використовує вбудований ThrowIfNullOrWhiteSpace для перевірки порожніх рядківAgainstNegative перевіряє, чи не від'ємне значенняAgainstInvalidEmail використовує EmailAddressAttribute для валідації формату emailAgainstPastDate перевіряє, чи дата не в минуломуAgainstOutOfRange — generic метод для перевірки діапазону значень будь-якого типу, що реалізує IComparable<T>Обробка null — одна з найпоширеніших причин помилок у C#. Існує кілька стратегій захисту:
#nullable enable
public class UserService
{
// Compiler попереджає, якщо можливий null
public void SendEmail(User user) // user is non-nullable
{
Console.WriteLine(user.Email); // Safe
}
public void SendEmailNullable(User? user) // user is nullable
{
// ❌ Compiler warning: Possible null reference
// Console.WriteLine(user.Email);
// ✅ Explicit null check
if (user is not null)
{
Console.WriteLine(user.Email);
}
}
}
.csproj:<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
NullReferenceException на етапі розробки.public class NullSafetyExamples
{
public string GetUserName(User? user)
{
// Null coalescing operator (??)
return user?.Name ?? "Anonymous"; // Якщо null, повертає "Anonymous"
}
public int GetOrderCount(User? user)
{
// Null-conditional operator (?.) + null coalescing
return user?.Orders?.Count ?? 0; // Безпечний ланцюг
}
public void ProcessUser(User? user)
{
// Null coalescing assignment (??=) - .NET 8+
user ??= new User { Name = "Default" }; // Присвоює, якщо null
// Тепер user гарантовано не null
Console.WriteLine(user.Name);
}
}
Пояснення:
user?.Name повертає null, якщо user є null, інакше повертає Name?. дозволяє безпечно працювати з вкладеними nullable об'єктами??= присвоює значення тільки якщо змінна дорівнює nullData Annotations — це декларативний спосіб валідації через атрибути, які застосовуються до властивостей моделі.
System.ComponentModel.DataAnnotations надає багатий набір атрибутів для різних сценаріїв.
| Атрибут | Опис | Приклад |
|---|---|---|
| Перевірка наявності | ||
[Required] | Поле не може бути null, порожнім рядком або містити лише пробіли. | [Required(ErrorMessage = "Name is required")] |
| Перевірка діапазонів та довжини | ||
[StringLength] | Обмежує довжину рядка (min/max). Найефективніший для рядків. | [StringLength(50, MinimumLength = 3)] |
[MaxLength] | Вказує максимальну довжину масиву або рядка (також впливає на EF Core). | [MaxLength(100)] |
[MinLength] | Вказує мінімальну довжину масиву або рядка. | [MinLength(5)] |
[Range] | Перевіряє, чи знаходиться числове значення в заданому діапазоні. | [Range(18, 120)], [Range(typeof(decimal), "0", "100")] |
| Перевірка формату | ||
[EmailAddress] | Перевіряє формат електронної пошти. | [EmailAddress] |
[Phone] | Перевіряє формат телефонного номера (досить поблажливий). | [Phone] |
[Url] | Перевіряє формат URL (http, ftp, https). | [Url] |
[CreditCard] | Перевіряє номер кредитної картки (алгоритм Луна). | [CreditCard] |
[RegularExpression] | Перевіряє відповідність рядка регулярному виразу. | [RegularExpression(@"^\d{5}$")] |
[FileExtensions] | Перевіряє розширення файлу (для рядкових полів шляхів). | [FileExtensions(Extensions = "jpg,png")] |
[EnumDataType] | Перевіряє, чи значення відповідає константі Enum. | [EnumDataType(typeof(UserRole))] |
| Порівняння та логіка | ||
[Compare] | Порівнює значення двох властивостей (наприклад, ConfirmPassword). | [Compare("Password")] |
[CustomValidation] | Викликає кастомний метод для валідації. | [CustomValidation(typeof(MyValidator), nameof(Validate))] |
using System.ComponentModel.DataAnnotations;
public class RegisterUserDto
{
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid email format")]
[MaxLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string Email { get; set; }
[Required]
[StringLength(50, MinimumLength = 3, ErrorMessage = "Name must be between 3 and 50 characters")]
public string Name { get; set; }
[Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
public int Age { get; set; }
[Phone]
public string? PhoneNumber { get; set; }
[Url]
public string? Website { get; set; }
[RegularExpression(@"^[A-Z]{2}\d{4}$", ErrorMessage = "Invalid format. Expected: XX1234")]
public string EmployeeCode { get; set; }
[CreditCard]
public string? CardNumber { get; set; }
[Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; }
[Required]
[MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
public string Password { get; set; }
}
Пояснення атрибутів:
Email має три валідації: обов'язкове поле, правильний формат email, максимальна довжинаStringLength перевіряє і мінімальну, і максимальну довжинуRange для числових діапазонівRegularExpression для custom форматів (тут: 2 великі літери + 4 цифри)Compare для порівняння двох полів (пароль та підтвердження)public class ValidationService
{
public (bool IsValid, List<string> Errors) ValidateObject(object obj)
{
var context = new ValidationContext(obj);
var results = new List<ValidationResult>();
bool isValid = Validator.TryValidateObject(obj, context, results, validateAllProperties: true);
var errors = results.Select(r => r.ErrorMessage ?? "Unknown error").ToList();
return (isValid, errors);
}
}
// Використання
var dto = new RegisterUserDto
{
Email = "invalid-email", // ❌ Invalid format
Name = "Jo", // ❌ Too short
Age = 15 // ❌ Below minimum
};
var (isValid, errors) = new ValidationService().ValidateObject(dto);
if (!isValid)
{
foreach (var error in errors)
Console.WriteLine($"- {error}");
// Output:
// - Invalid email format
// - Name must be between 3 and 50 characters
// - Age must be between 18 and 120
}
Пояснення:
ValidationContext зберігає інформацію про об'єкт, що валідуєтьсяValidator.TryValidateObject виконує всі валідації атрибутів. validateAllProperties: true важливий для перевірки всіх властивостей, не лише тих, що анотовані RequiredКоли Data Annotations недостатньо для складної логіки, можна реалізувати інтерфейс IValidatableObject для custom валідацій.
public class EventBookingDto : IValidatableObject
{
[Required]
public DateTime StartDate { get; set; }
[Required]
public DateTime EndDate { get; set; }
[Range(1, 1000)]
public int Attendees { get; set; }
[Required]
public string EventType { get; set; } // "Conference", "Workshop", etc.
// Custom validation logic
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Cross-field validation: EndDate must be after StartDate
if (EndDate <= StartDate)
{
yield return new ValidationResult(
"End date must be after start date",
new[] { nameof(EndDate) } // Specify which property has error
);
}
// Conditional validation: Conferences require at least 10 attendees
if (EventType == "Conference" && Attendees < 10)
{
yield return new ValidationResult(
"Conferences must have at least 10 attendees",
new[] { nameof(Attendees) }
);
}
// Time-based validation: Cannot book events less than 24 hours in advance
if (StartDate < DateTime.Now.AddHours(24))
{
yield return new ValidationResult(
"Events must be booked at least 24 hours in advance",
new[] { nameof(StartDate) }
);
}
}
}
Переваги IValidatableObject:
Недоліки:
FluentValidation — це бібліотека, яка дозволяє створювати strongly-typed валідаційні правила через fluent API, повністю відокремлюючи валідацію від моделей.
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
using FluentValidation;
public class CreateUserDto
{
public string Email { get; set; }
public string Name { get; set; }
public int Age { get; set; }
public string Password { get; set; }
}
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MaximumLength(100).WithMessage("Email is too long");
RuleFor(x => x.Name)
.NotEmpty()
.Length(3, 50).WithMessage("Name must be between 3 and 50 characters")
.Matches(@"^[a-zA-Z\s]+$").WithMessage("Name can only contain letters and spaces");
RuleFor(x => x.Age)
.InclusiveBetween(18, 120).WithMessage("Age must be between 18 and 120");
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches(@"[0-9]").WithMessage("Password must contain at least one digit")
.Matches(@"[@$!%*?&#]").WithMessage("Password must contain at least one special character");
}
}
Пояснення:
AbstractValidator<T> і типізований моделлюEmail з custom повідомленнями через .WithMessage()Matches приймає regex для складних форматних перевірокMatches для різних вимог до пароля — кожен матиме власне повідомлення про помилкуvar dto = new CreateUserDto
{
Email = "invalid",
Name = "Jo",
Age = 15,
Password = "weak"
};
var validator = new CreateUserValidator();
var result = validator.Validate(dto);
if (!result.IsValid)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"[{error.PropertyName}] {error.ErrorMessage}");
}
// Output:
// [Email] Invalid email format
// [Name] Name must be between 3 and 50 characters
// [Age] Age must be between 18 and 120
// [Password] Password must be at least 8 characters
// [Password] Password must contain at least one uppercase letter
// [Password] Password must contain at least one lowercase letter
// [Password] Password must contain at least one digit
// [Password] Password must contain at least one special character
}
Зверніть увагу:
Validate(), повертає ValidationResultPropertyName та ErrorMessage, що дозволяє легко мапити помилки до полів у UIСтворення власного валідатора для складної логіки:
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
private readonly IUserRepository _userRepository;
public CreateUserValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmail).WithMessage("Email is already registered");
}
// Async custom validator
private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
{
return !await _userRepository.ExistsAsync(email, cancellationToken);
}
}
Пояснення:
MustAsync дозволяє асинхронні перевірки, які потребують I/O (БД, API)true, якщо валідація пройшла (email унікальний)Застосування правил лише за певних умов:
public class OrderValidator : AbstractValidator<Order>
{
public OrderValidator()
{
// Валідувати ShippingAddress тільки якщо RequiresShipping = true
When(x => x.RequiresShipping, () =>
{
RuleFor(x => x.ShippingAddress).NotEmpty();
RuleFor(x => x.ShippingAddress.City).NotEmpty();
RuleFor(x => x.ShippingAddress.PostalCode).Matches(@"^\d{5}$");
});
// Валідувати DiscountCode тільки якщо він заповнений
Unless(x => string.IsNullOrEmpty(x.DiscountCode), () =>
{
RuleFor(x => x.DiscountCode)
.Length(6, 12)
.Matches(@"^[A-Z0-9]+$");
});
}
}
Пояснення:
When виконує вкладені правила лише якщо умова істиннаUnless — протилежність When, виконується коли умова хибнаГрупування правил для різних сценаріїв:
public class UserValidator : AbstractValidator<User>
{
public UserValidator()
{
// Default rules - завжди виконуються
RuleFor(x => x.Id).NotEmpty();
// RuleSet для створення користувача
RuleSet("Create", () =>
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
});
// RuleSet для оновлення
RuleSet("Update", () =>
{
// При оновленні пароль опціональний
RuleFor(x => x.Password)
.MinimumLength(8)
.When(x => !string.IsNullOrEmpty(x.Password));
});
}
}
// Використання
var validator = new UserValidator();
// Валідація для створення
var createResult = validator.Validate(user, options =>
{
options.IncludeRuleSets("Create");
});
// Валідація для оновлення
var updateResult = validator.Validate(user, options =>
{
options.IncludeRuleSets("Update");
});
Коли використовувати RuleSets: Коли одна модель використовується в різних сценаріях з різними вимогами валідації.
Валідація вкладених об'єктів:
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street).NotEmpty();
RuleFor(x => x.City).NotEmpty();
RuleFor(x => x.PostalCode).Matches(@"^\d{5}$");
}
}
public class User
{
public string Name { get; set; }
public Address HomeAddress { get; set; }
public List<Address> PreviousAddresses { get; set; }
}
public class UserValidator : AbstractValidator<User>
{
public UserValidator()
{
RuleFor(x => x.Name).NotEmpty();
// Валідація одного вкладеного об'єкта
RuleFor(x => x.HomeAddress)
.SetValidator(new AddressValidator());
// Валідація колекції
RuleForEach(x => x.PreviousAddresses)
.SetValidator(new AddressValidator());
}
}
Пояснення:
SetValidator застосовує існуючий валідатор до вкладеного об'єктаRuleForEach застосовує валідатор до кожного елемента колекції// Program.cs або Startup.cs
using FluentValidation;
using FluentValidation.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Додавання FluentValidation до DI
builder.Services.AddControllers()
.AddFluentValidation(config =>
{
// Автоматична реєстрація всіх валідаторів з assembly
config.RegisterValidatorsFromAssemblyContaining<Program>();
// Автоматична валідація при Model Binding
config.AutomaticValidationEnabled = true;
});
// Controller usage
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserDto dto)
{
// Якщо валідація провалена, ASP.NET автоматично повертає 400 Bad Request
if (!ModelState.IsValid)
return BadRequest(ModelState);
// Business logic
return Ok();
}
}
Пояснення:
RegisterValidatorsFromAssemblyContaining<T>() автоматично знаходить та реєструє всі класи, що наслідуються від AbstractValidator<T>ModelStateПорівняння підходів валідації:
| Підхід | Переваги | Недоліки | Коли використовувати |
|---|---|---|---|
| Data Annotations | Простота, вбудовано в .NET | Обмежена гнучкість | Прості DTO з базовими правилами |
| IValidatableObject | Cross-field validation, легкий старт | Змішує валідацію з моделлю | Прості cross-field перевірки в DTO |
| FluentValidation | Максимальна гнучкість, SRP, тестовність | Додаткова бібліотека | Складна валідація, великі проєкти |
| Guard Clauses | Fail-fast, захист методів | Не для UI-driven валідації | Defensive programming в методах |
Багато розробників використовують винятки для контролю звичайного потоку програми, що призводить до "Exception-Driven Development" (EDD). Це anti-pattern.
// ❌ Exception-Driven Development
public User GetUserByEmail(string email)
{
var user = _repository.FindByEmail(email);
if (user == null)
throw new UserNotFoundException($"User with email {email} not found");
if (!user.IsActive)
throw new UserInactiveException("User account is deactivated");
if (user.IsLocked)
throw new UserLockedException("User account is locked");
return user;
}
// Calling code
try
{
var user = GetUserByEmail("test@example.com");
ProcessUser(user);
}
catch (UserNotFoundException ex)
{
return NotFound(ex.Message);
}
catch (UserInactiveException ex)
{
return BadRequest(ex.Message);
}
catch (UserLockedException ex)
{
return Forbidden(ex.Message);
}
Проблеми цього підходу:
catch блокахusing BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class ExceptionVsResultBenchmark
{
[Benchmark]
public int ExceptionFlow()
{
try
{
return DivideWithException(10, 0);
}
catch
{
return -1;
}
}
[Benchmark]
public int ResultFlow()
{
var result = DivideWithResult(10, 0);
return result.IsSuccess ? result.Value : -1;
}
private int DivideWithException(int a, int b)
{
if (b == 0) throw new DivideByZeroException();
return a / b;
}
private Result<int> DivideWithResult(int a, int b)
{
if (b == 0) return Result.Fail<int>("Cannot divide by zero");
return Result.Ok(a / b);
}
}
// Запуск: dotnet run -c Release
BenchmarkRunner.Run<ExceptionVsResultBenchmark>();
Типові результати бенчмарку:
| Method | Mean | Error | Ratio |
|---|---|---|---|
| ResultFlow | 5.2 ns | 0.1 ns | 1.00x |
| ExceptionFlow | 8,450 ns | 120 ns | 1625x |
Exceptions призначені для виняткових, непередбачуваних ситуацій, які порушують нормальний потік програми.
// ✅ Good use of exceptions
public void SaveToFile(string path, string content)
{
// System/Infra exception - cannot be predicted at compile time
File.WriteAllText(path, content); // Може кинути IOException
}
public void ConfigureService()
{
var config = Configuration.GetSection("Database");
// Programming error - developer mistake
if (config == null)
throw new InvalidOperationException("Database configuration is missing");
}
if"// ❌ Bad use of exceptions
public User GetUser(int id)
{
var user = _repository.Find(id);
// This is business logic, not an exceptional situation!
if (user == null)
throw new UserNotFoundException(); // ❌
return user;
}
// ✅ Correct approach
public Result<User> GetUser(int id)
{
var user = _repository.Find(id);
// Business logic failure - return Result
if (user == null)
return Result.Fail<User>("User not found"); // ✅
return Result.Ok(user);
}
Result Pattern — це підхід, коли метод явно повертає об'єкт, що представляє або успіх з результатом, або помилку, замість кидання винятку.
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public string Error { get; }
protected Result(bool isSuccess, string error)
{
if (isSuccess && !string.IsNullOrEmpty(error))
throw new InvalidOperationException("Success result cannot have an error");
if (!isSuccess && string.IsNullOrEmpty(error))
throw new InvalidOperationException("Failure result must have an error");
IsSuccess = isSuccess;
Error = error ?? string.Empty;
}
// Factory methods
public static Result Ok() => new Result(true, null);
public static Result Fail(string error) => new Result(false, error);
}
// Generic version for results with value
public class Result<T> : Result
{
public T Value { get; }
protected Result(T value, bool isSuccess, string error)
: base(isSuccess, error)
{
Value = value;
}
// Factory methods
public static Result<T> Ok(T value) => new Result<T>(value, true, null);
public static Result<T> Fail(string error) => new Result<T>(default, false, error);
}
Пояснення:
new)Tpublic class UserService
{
public Result<User> GetUserByEmail(string email)
{
// Validation
if (string.IsNullOrWhiteSpace(email))
return Result<User>.Fail("Email is required");
var user = _repository.FindByEmail(email);
// Business logic check
if (user == null)
return Result<User>.Fail("User not found");
if (!user.IsActive)
return Result<User>.Fail("User account is deactivated");
if (user.IsLocked)
return Result<User>.Fail("User account is locked");
return Result<User>.Ok(user);
}
}
// Calling code - явна обробка результату
var result = _userService.GetUserByEmail("test@example.com");
if (result.IsFailure)
{
_logger.LogWarning("Failed to get user: {Error}", result.Error);
return BadRequest(result.Error);
}
var user = result.Value;
ProcessUser(user);
Переваги Result Pattern:
У базовій імплементації Result ми використовували примітивний тип string для представлення помилки. Це працює для демонстрації концепції, але в production-ready системах має серйозні обмеження:
// ❌ String-based approach - обмеження
return Result.Fail("User not found");
return Result.Fail("User not found"); // Той самий текст?
return Result.Fail("user not found"); // Чи це та сама помилка?
// Неможливо:
// - Програмно ідентифікувати конкретну помилку
// - Локалізувати повідомлення
// - Додати метадані (userId, timestamp)
// - Перевірити тип помилки без string comparison
MessageError у контексті Result Pattern — це не виняток (Exception), а доменна модель помилки — value object, що описує, що саме пішло не так.
Фундаментальна структура складається з двох компонентів:
"User.NotFound", "Payment.InsufficientFunds")/// <summary>
/// Represents a domain error with a unique code and descriptive message.
/// </summary>
public sealed record Error
{
/// <summary>
/// Unique error code (e.g., "User.NotFound").
/// </summary>
public string Code { get; }
/// <summary>
/// Human-readable error message for debugging.
/// </summary>
public string Message { get; }
/// <summary>
/// Represents the absence of an error.
/// </summary>
public static readonly Error None = new(string.Empty, string.Empty);
private Error(string code, string message)
{
Code = code;
Message = message;
}
/// <summary>
/// Factory method to create a new error.
/// </summary>
public static Error Create(string code, string message) => new(code, message);
// Implicit conversion для зручності
public static implicit operator string(Error error) => error.Code;
}
Ключові деталі реалізації:
sealed record — гарантує immutability та structural equalityError.None використовується для позначення "успіху" (відсутність помилки)private конструктор → тільки через factory methodТепер замість string Error використовуємо Error Error:
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
// Інваріанти
if (isSuccess && error != Error.None)
throw new InvalidOperationException("Success result cannot have an error");
if (!isSuccess && error == Error.None)
throw new InvalidOperationException("Failure result must have an error");
IsSuccess = isSuccess;
Error = error;
}
// Factory methods
public static Result Ok() => new(true, Error.None);
public static Result Fail(Error error) => new(false, error);
}
// Generic версія
public class Result<T> : Result
{
public T Value { get; }
protected Result(T value, bool isSuccess, Error error)
: base(isSuccess, error)
{
Value = value;
}
public static Result<T> Ok(T value) => new(value, true, Error.None);
public static Result<T> Fail(Error error) => new(default, false, error);
}
Зміни:
Error Error замість string ErrorError.None замість string.IsNullOrEmptyError об'єктПроблема: якщо створювати Error об'єкти inline (Error.Create("User.NotFound", "...")) в кожному місці, це призводить до дублювання та проблем підтримки.
Рішення: Статичний клас DomainErrors, що містить всі можливі помилки домену як static readonly поля.
Імплементація DomainErrors:
/// <summary>
/// Централізоване сховище всіх доменних помилок.
/// </summary>
public static class DomainErrors
{
/// <summary>
/// Помилки, пов'язані з User aggregate.
/// </summary>
public static class User
{
public static readonly Error NotFound = Error.Create(
"User.NotFound",
"The user with the specified identifier was not found.");
public static readonly Error InvalidCredentials = Error.Create(
"User.InvalidCredentials",
"The provided email or password is incorrect.");
public static readonly Error EmailTaken = Error.Create(
"User.EmailTaken",
"The specified email is already registered.");
public static readonly Error Inactive = Error.Create(
"User.Inactive",
"The user account is deactivated.");
public static readonly Error Locked = Error.Create(
"User.Locked",
"The user account is temporarily locked due to multiple failed login attempts.");
}
/// <summary>
/// Помилки, пов'язані з Order aggregate.
/// </summary>
public static class Order
{
public static readonly Error NotFound = Error.Create(
"Order.NotFound",
"The order with the specified identifier was not found.");
public static readonly Error Empty = Error.Create(
"Order.Empty",
"Order must contain at least one item.");
public static readonly Error AlreadyShipped = Error.Create(
"Order.AlreadyShipped",
"Cannot modify an order that has already been shipped.");
public static readonly Error InvalidTotal = Error.Create(
"Order.InvalidTotal",
"Order total must be greater than zero.");
}
/// <summary>
/// Помилки, пов'язані з Payment process.
/// </summary>
public static class Payment
{
public static readonly Error InsufficientFunds = Error.Create(
"Payment.InsufficientFunds",
"Insufficient funds in the account.");
public static readonly Error InvalidCard = Error.Create(
"Payment.InvalidCard",
"The provided credit card is invalid or expired.");
public static readonly Error GatewayError = Error.Create(
"Payment.GatewayError",
"Payment gateway is currently unavailable.");
public static readonly Error AmountTooLow = Error.Create(
"Payment.AmountTooLow",
"Payment amount must be at least $1.00.");
}
}
Переваги цієї архітектури:
// Раніше: пошук по всьому коду
grep -r "User not found" .
// Тепер: один файл DomainErrors.cs
return Result.Fail(DomainErrors.User.); // Autocomplete всіх User errors
// Явна перевірка на конкретну помилку
result.Error.Should().Be(DomainErrors.User.NotFound);
// Замість магічного рядка
result.Error.Should().Be("User not found"); // ❌ Brittle
// Код залишається незмінним, змінюється лише Message
var localizedMessage = _localizer[result.Error.Code];
// "User.NotFound" -> "Користувач не знайдений" (UA)
// "User.NotFound" -> "User not found" (EN)
_telemetry.TrackError(result.Error.Code, new Dictionary<string, string>
{
["UserId"] = userId,
["Timestamp"] = DateTime.UtcNow.ToString()
});
// Dashboard може групувати за .Code
// "User.NotFound": 1,234 occurrences today
public class UserService
{
public Result<User> GetUserByEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return Result<User>.Fail(DomainErrors.User.InvalidCredentials);
var user = _repository.FindByEmail(email);
if (user is null)
return Result<User>.Fail(DomainErrors.User.NotFound);
if (!user.IsActive)
return Result<User>.Fail(DomainErrors.User.Inactive);
if (user.IsLocked)
return Result<User>.Fail(DomainErrors.User.Locked);
return Result<User>.Ok(user);
}
public Result RegisterUser(string email, string password)
{
if (_repository.ExistsByEmail(email))
return Result.Fail(DomainErrors.User.EmailTaken);
var user = new User(email, _passwordHasher.Hash(password));
_repository.Add(user);
return Result.Ok();
}
}
// Використання в Controller
[HttpGet("{email}")]
public IActionResult GetUser(string email)
{
var result = _userService.GetUserByEmail(email);
if (result.IsFailure)
{
// Pattern matching за кодом помилки
return result.Error.Code switch
{
"User.NotFound" => NotFound(result.Error.Message),
"User.Inactive" => BadRequest(result.Error.Message),
"User.Locked" => StatusCode(423, result.Error.Message), // 423 Locked
_ => BadRequest(result.Error.Message)
};
}
return Ok(result.Value);
}
Пояснення:
DomainErrors.* замість inline stringsDomainErrors за Aggregates (DDD concept) або Features. Це підвищує cohencion та знижує coupling:DomainErrors.
├── User
├── Order
├── Payment
├── Shipping
└── Inventory
DomainErrors.General.Unknown — вони втрачають сенс структурованих помилок.Railway Oriented Programming — це метафора для роботи з Result Pattern, де код виконується як потяг по рейках:
Розширимо наш Result методами для композиції:
public class Result<T>
{
// ... попередній код ...
// Map: Transform value if success
public Result<TNew> Map<TNew>(Func<T, TNew> mapper)
{
if (IsFailure)
return Result<TNew>.Fail(Error);
return Result<TNew>.Ok(mapper(Value));
}
// Bind (FlatMap): Chain operations that return Result
public Result<TNew> Bind<TNew>(Func<T, Result<TNew>> binder)
{
if (IsFailure)
return Result<TNew>.Fail(Error);
return binder(Value);
}
// OnSuccess: Execute action if success
public Result<T> OnSuccess(Action<T> action)
{
if (IsSuccess)
action(Value);
return this;
}
// OnFailure: Execute action if failure
public Result<T> OnFailure(Action<string> action)
{
if (IsFailure)
action(Error);
return this;
}
// Match: Handle both cases
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<string, TResult> onFailure)
{
return IsSuccess ? onSuccess(Value) : onFailure(Error);
}
}
Пояснення методів:
Map перетворює значення всередині Result, зберігаючи контекст успіху/помилкиBind (також називається FlatMap) використовується для ланцюжка операцій, що повертають ResultOnSuccess виконує side-effect (наприклад, логування) при успіхуOnFailure виконує side-effect при помилціMatch — pattern matching для обробки обох сценаріївpublic class BankingService
{
public Result TransferMoney(string fromAccountId, string toAccountId, decimal amount)
{
return GetAccount(fromAccountId)
.Bind(fromAccount => GetAccount(toAccountId)
.Map(toAccount => (From: fromAccount, To: toAccount)))
.Bind(accounts => ValidateTransfer(accounts.From, amount))
.Bind(fromAccount => DebitAccount(fromAccount, amount))
.Bind(fromAccount => CreditAccount(toAccountId, amount)
.Map(_ => fromAccount))
.OnSuccess(fromAccount =>
_logger.LogInformation("Transfer completed from {From}", fromAccount.Id))
.OnFailure(error =>
_logger.LogWarning("Transfer failed: {Error}", error));
}
private Result<Account> GetAccount(string accountId)
{
var account = _repository.Find(accountId);
return account != null
? Result<Account>.Ok(account)
: Result<Account>.Fail($"Account {accountId} not found");
}
private Result<Account> ValidateTransfer(Account account, decimal amount)
{
if (amount <= 0)
return Result<Account>.Fail("Amount must be positive");
if (account.Balance < amount)
return Result<Account>.Fail("Insufficient funds");
return Result<Account>.Ok(account);
}
private Result<Account> DebitAccount(Account account, decimal amount)
{
account.Balance -= amount;
_repository.Update(account);
return Result<Account>.Ok(account);
}
private Result CreditAccount(string accountId, decimal amount)
{
return GetAccount(accountId)
.Bind(account =>
{
account.Balance += amount;
_repository.Update(account);
return Result.Ok();
});
}
}
Як це працює:
GetAccount повертає Result<Account>)Магія: Якщо будь-який крок повертає Failure, всі наступні операції пропускаються, і ми одразу повертаємо помилку.
Замість написання власної імплементації Result, можна використати перевірені бібліотеки.
FluentResults — популярна бібліотека для роботи з Result Pattern у .NET.
dotnet add package FluentResults
using FluentResults;
public class UserService
{
public Result<User> CreateUser(string email, string password)
{
// Simple failure
if (string.IsNullOrEmpty(email))
return Result.Fail("Email is required");
// Failure with typed error
if (!IsValidEmail(email))
return Result.Fail(new InvalidEmailError(email));
// Multiple errors
var errors = new List<IError>();
if (password.Length < 8)
errors.Add(new Error("Password too short"));
if (!HasSpecialChar(password))
errors.Add(new Error("Password must contain special character"));
if (errors.Any())
return Result.Fail(errors);
var user = new User { Email = email };
_repository.Add(user);
// Success with value
return Result.Ok(user);
}
}
// Custom Error Type
public class InvalidEmailError : Error
{
public InvalidEmailError(string email)
: base($"Email '{email}' is invalid")
{
Metadata.Add("EmailValue", email);
}
}
Переваги FluentResults:
var result = userService.CreateUser("invalid-email", "weak");
// Перевірка статусу
if (result.IsFailed)
{
// Доступ до всіх помилок
foreach (var error in result.Errors)
{
Console.WriteLine(error.Message);
// Metadata
if (error.Metadata.TryGetValue("EmailValue", out var email))
Console.WriteLine($"Invalid email was: {email}");
}
// Перевірка на specific error type
if (result.HasError<InvalidEmailError>())
{
var emailError = result.Errors.OfType<InvalidEmailError>().First();
Console.WriteLine($"Email error: {emailError.Message}");
}
}
// Pattern matching
var response = result.Match(
onSuccess: user => $"User created: {user.Email}",
onFailure: errors => $"Failed: {string.Join(", ", errors.Select(e => e.Message))}"
);
public Result ProcessOrder(int orderId)
{
return GetOrder(orderId)
.Bind(order => ValidateOrder(order))
.Bind(order => ChargePayment(order))
.Bind(order => SendConfirmation(order))
.Log("Order processing"); // Built-in logging
}
private Result<Order> GetOrder(int id)
{
var order = _repository.Find(id);
return order != null
? Result.Ok(order)
: Result.Fail($"Order {id} not found");
}
private Result<Order> ValidateOrder(Order order)
{
if (order.Items.Count == 0)
return Result.Fail("Order has no items");
if (order.Total <= 0)
return Result.Fail("Invalid order total");
return Result.Ok(order);
}
private Result<Order> ChargePayment(Order order)
{
try
{
_paymentGateway.Charge(order.Total);
return Result.Ok(order);
}
catch (PaymentException ex)
{
// Wrapping exception as error
return Result.Fail(new ExceptionalError(ex));
}
}
private Result<Order> SendConfirmation(Order order)
{
_emailService.SendOrderConfirmation(order);
return Result.Ok(order);
}
Result в IActionResult:dotnet add package FluentResults.Extensions.AspNetCore
[HttpPost]
public ActionResult<User> CreateUser(CreateUserDto dto)
{
var result = _userService.CreateUser(dto.Email, dto.Password);
return result.ToActionResult(); // Auto-converts to Ok() or BadRequest()
}
ErrorOr — альтернативна бібліотека, інспірована функціональним програмуванням.
dotnet add package ErrorOr
using ErrorOr;
public class ProductService
{
public ErrorOr<Product> GetProduct(int id)
{
var product = _repository.Find(id);
if (product is null)
return Error.NotFound(
code: "Product.NotFound",
description: $"Product with id {id} was not found");
if (product.IsDiscontinued)
return Error.Validation(
code: "Product.Discontinued",
description: "Product is no longer available");
return product; // Implicit conversion to ErrorOr<Product>
}
}
Особливості ErrorOr:
ErrorOr<T> — це union типу "або значення, або помилка"return product; автоматично конвертується в ErrorOr<Product>public ErrorOr<User> AuthenticateUser(string email, string password)
{
var user = _repository.FindByEmail(email);
// NotFound - ресурс не знайдено
if (user is null)
return Error.NotFound(
code: "User.NotFound",
description: "User not found");
// Unauthorized - невірні credentials
if (!_passwordHasher.Verify(password, user.PasswordHash))
return Error.Unauthorized(
code: "User.InvalidCredentials",
description: "Invalid email or password");
// Forbidden - доступ заборонений
if (user.IsBanned)
return Error.Forbidden(
code: "User.Banned",
description: "Your account has been banned");
// Conflict - конфлікт стану
if (user.IsAlreadyLoggedIn)
return Error.Conflict(
code: "User.AlreadyLoggedIn",
description: "User is already logged in from another device");
return user;
}
Mapping error types до HTTP status codes:
| ErrorType | HTTP Status | Використання |
|---|---|---|
| NotFound | 404 Not Found | Ресурс не існує |
| Validation | 400 Bad Request | Некоректні дані |
| Conflict | 409 Conflict | Конфлікт стану (дублікат, locked) |
| Unauthorized | 401 Unauthorized | Невірні credentials |
| Forbidden | 403 Forbidden | Немає прав доступу |
| Failure | 400 Bad Request | Бізнес-логічна помилка |
| Unexpected | 500 Internal Error | Непередбачена помилка |
public IActionResult GetProduct(int id)
{
ErrorOr<Product> result = _productService.GetProduct(id);
// Match with value and errors
return result.Match(
value => Ok(value),
errors => Problem(errors));
// MatchFirst with value and first error only
return result.MatchFirst(
value => Ok(value),
firstError => firstError.Type switch
{
ErrorType.NotFound => NotFound(firstError.Description),
ErrorType.Validation => BadRequest(firstError.Description),
_ => Problem(firstError.Description)
});
}
// Helper для конвертації помилок в ProblemDetails
private IActionResult Problem(List<Error> errors)
{
var statusCode = errors[0].Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError
};
return Problem(
statusCode: statusCode,
title: "One or more errors occurred",
detail: string.Join("; ", errors.Select(e => e.Description)));
}
public ErrorOr<OrderConfirmation> PlaceOrder(PlaceOrderRequest request)
{
return ValidateRequest(request)
.Then(req => CreateOrder(req))
.Then(order => ReserveInventory(order))
.Then(order => ProcessPayment(order))
.Then(order => SendNotification(order))
.Then(order => new OrderConfirmation(order.Id, order.Total));
}
private ErrorOr<PlaceOrderRequest> ValidateRequest(PlaceOrderRequest request)
{
if (request.Items.Count == 0)
return Error.Validation(
code: "Order.NoItems",
description: "Order must contain at least one item");
return request;
}
private ErrorOr<Order> CreateOrder(PlaceOrderRequest request)
{
var order = new Order
{
Items = request.Items,
CustomerId = request.CustomerId
};
_repository.Add(order);
return order;
}
private ErrorOr<Order> ReserveInventory(Order order)
{
foreach (var item in order.Items)
{
var available = _inventory.GetAvailableQuantity(item.ProductId);
if (available < item.Quantity)
return Error.Conflict(
code: "Inventory.Insufficient",
description: $"Insufficient inventory for product {item.ProductId}");
_inventory.Reserve(item.ProductId, item.Quantity);
}
return order;
}
private ErrorOr<Order> ProcessPayment(Order order)
{
var paymentResult = _paymentGateway.Charge(order.CustomerId, order.Total);
if (!paymentResult.IsSuccess)
return Error.Failure(
code: "Payment.Failed",
description: paymentResult.ErrorMessage);
order.PaymentId = paymentResult.TransactionId;
return order;
}
private ErrorOr<Order> SendNotification(Order order)
{
_emailService.SendOrderConfirmation(order);
return order;
}
Пояснення:
.Then() — це ErrorOr еквівалент Bind. Кожен крок виконується тільки якщо попередній успішнийOrderConfirmation| Критерій | FluentResults | ErrorOr |
|---|---|---|
| Філософія | Pragmatic, feature-rich | Functional, minimalist |
| Помилки | Multiple errors per result | Може бути список, але зазвичай одна |
| Типізовані помилки | Custom classes inheriting from Error | Enum-based ErrorType + metadata |
| Success metadata | Підтримує Success messages | Тільки value або errors |
| Composability | Bind, Map, Tap, Check | Then, Else, Match, Switch |
| Implicit conversions | Ні | Так (return value; → ErrorOr<T>) |
| Integration | Extensions для ASP.NET, Logging | Minimal, DIY |
| Коли використовувати | Складні scenarios, багато metadata | Чистий Result Pattern, простота |
using ErrorOr;
public class CheckoutService
{
private readonly ICartRepository _cartRepository;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
private readonly IValidator<CheckoutRequest> _validator;
public async Task<ErrorOr<OrderConfirmation>> CheckoutAsync(
int userId,
CheckoutRequest request,
CancellationToken ct)
{
// 1. Валідація вхідних даних (FluentValidation)
var validationResult = await _validator.ValidateAsync(request, ct);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.Select(e => Error.Validation(
code: $"Checkout.{e.PropertyName}",
description: e.ErrorMessage))
.ToList();
return errors;
}
// 2. Railway Oriented Programming flow
return await GetCart(userId)
.Bind(cart => ValidateCart(cart))
.Bind(cart => ReserveInventory(cart))
.Bind(cart => ApplyDiscountCode(cart, request.DiscountCode))
.Bind(cart => ProcessPayment(cart, request.PaymentDetails))
.Bind(payment => CreateOrder(userId, payment))
.Bind(order => SendConfirmation(order));
}
private ErrorOr<Cart> GetCart(int userId)
{
var cart = _cartRepository.GetByUserId(userId);
if (cart is null)
return Error.NotFound(
code: "Cart.NotFound",
description: "Shopping cart not found");
return cart;
}
private ErrorOr<Cart> ValidateCart(Cart cart)
{
if (cart.Items.Count == 0)
return Error.Validation(
code: "Cart.Empty",
description: "Cannot checkout with empty cart");
// Перевірка, чи всі товари досі доступні
foreach (var item in cart.Items)
{
if (item.Product.IsDiscontinued)
return Error.Validation(
code: "Cart.DiscontinuedProduct",
description: $"Product '{item.Product.Name}' is no longer available");
}
return cart;
}
private async Task<ErrorOr<Cart>> ReserveInventory(Cart cart)
{
foreach (var item in cart.Items)
{
var reservationResult = await _inventoryService
.ReserveAsync(item.ProductId, item.Quantity);
if (reservationResult.IsFailed)
return Error.Conflict(
code: "Inventory.InsufficientStock",
description: $"Not enough stock for '{item.Product.Name}'");
}
return cart;
}
private ErrorOr<Cart> ApplyDiscountCode(Cart cart, string? discountCode)
{
if (string.IsNullOrEmpty(discountCode))
return cart; // No discount - OK
var discount = _discountRepository.FindByCode(discountCode);
if (discount is null)
return Error.NotFound(
code: "Discount.InvalidCode",
description: "Invalid discount code");
if (discount.IsExpired)
return Error.Validation(
code: "Discount.Expired",
description: "Discount code has expired");
cart.ApplyDiscount(discount);
return cart;
}
private async Task<ErrorOr<PaymentResult>> ProcessPayment(
Cart cart,
PaymentDetails paymentDetails)
{
try
{
var paymentResult = await _paymentService
.ChargeAsync(cart.Total, paymentDetails);
if (!paymentResult.IsSuccess)
return Error.Failure(
code: "Payment.Failed",
description: paymentResult.ErrorMessage);
return paymentResult;
}
catch (PaymentGatewayException ex)
{
// Exception для справді виняткових ситуацій (мережа)
_logger.LogError(ex, "Payment gateway error");
return Error.Unexpected(
code: "Payment.GatewayError",
description: "Payment service is temporarily unavailable");
}
}
private ErrorOr<Order> CreateOrder(int userId, PaymentResult payment)
{
var order = new Order
{
UserId = userId,
PaymentId = payment.TransactionId,
Total = payment.Amount,
Status = OrderStatus.Confirmed
};
_orderRepository.Add(order);
return order;
}
private async Task<ErrorOr<OrderConfirmation>> SendConfirmation(Order order)
{
await _emailService.SendOrderConfirmationAsync(order);
return new OrderConfirmation
{
OrderId = order.Id,
Total = order.Total,
EstimatedDelivery = DateTime.Now.AddDays(3)
};
}
}
// FluentValidation для CheckoutRequest
public class CheckoutRequestValidator : AbstractValidator<CheckoutRequest>
{
public CheckoutRequestValidator()
{
RuleFor(x => x.PaymentDetails.CardNumber)
.NotEmpty()
.CreditCard()
.WithMessage("Invalid credit card number");
RuleFor(x => x.PaymentDetails.ExpiryDate)
.GreaterThan(DateTime.Now)
.WithMessage("Card has expired");
RuleFor(x => x.ShippingAddress.PostalCode)
.NotEmpty()
.Matches(@"^\d{5}(-\d{4})?$")
.WithMessage("Invalid postal code format");
When(x => !string.IsNullOrEmpty(x.DiscountCode), () =>
{
RuleFor(x => x.DiscountCode)
.Length(6, 12)
.WithMessage("Discount code must be 6-12 characters");
});
}
}
Ключові моменти цього прикладу:
ErrorOr<T>NotFound, Validation, Conflict, Failure, Unexpected — кожен має свій сенс[ApiController]
[Route("api/checkout")]
public class CheckoutController : ControllerBase
{
private readonly CheckoutService _checkoutService;
[HttpPost]
public async Task<IActionResult> Checkout(
[FromBody] CheckoutRequest request,
CancellationToken ct)
{
var userId = GetCurrentUserId();
var result = await _checkoutService.CheckoutAsync(userId, request, ct);
// Pattern matching на ErrorType
return result.MatchFirst(
value => Ok(new CheckoutResponse
{
OrderId = value.OrderId,
Total = value.Total,
EstimatedDelivery = value.EstimatedDelivery
}),
error => error.Type switch
{
ErrorType.NotFound => NotFound(new ProblemDetails
{
Status = 404,
Title = "Resource Not Found",
Detail = error.Description
}),
ErrorType.Validation => BadRequest(new ValidationProblemDetails
{
Status = 400,
Title = "Validation Error",
Detail = error.Description
}),
ErrorType.Conflict => Conflict(new ProblemDetails
{
Status = 409,
Title = "Conflict",
Detail = error.Description
}),
ErrorType.Failure => BadRequest(new ProblemDetails
{
Status = 400,
Title = "Operation Failed",
Detail = error.Description
}),
ErrorType.Unexpected => StatusCode(500, new ProblemDetails
{
Status = 500,
Title = "Internal Server Error",
Detail = "An unexpected error occurred"
}),
_ => StatusCode(500, new ProblemDetails
{
Status = 500,
Title = "Unknown Error",
Detail = error.Description
})
});
}
private int GetCurrentUserId()
{
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier);
return int.Parse(userIdClaim?.Value ?? "0");
}
}
Best Practices для Controllers:
MatchFirst для конвертації ErrorOr<T> в IActionResultВалідація
Ключові концепції:
IValidatableObject для cross-field валідаціїПринципи:
Result Pattern
Переваги:
Коли використовувати:
Коли НЕ використовувати:
Бібліотеки
FluentResults:
ErrorOr:
Вибір:
Best Practices
Bind/ThenВідрефакторіть метод, замінивши вкладені if на guard clauses:
public void ProcessPayment(Payment payment, User user)
{
if (payment != null)
{
if (user != null)
{
if (payment.Amount > 0)
{
if (user.Balance >= payment.Amount)
{
user.Balance -= payment.Amount;
_repository.SavePayment(payment);
}
else
{
throw new InsufficientFundsException();
}
}
else
{
throw new ArgumentException("Amount must be positive");
}
}
else
{
throw new ArgumentNullException(nameof(user));
}
}
else
{
throw new ArgumentNullException(nameof(payment));
}
}
Створіть валідатор для RegisterUserDto:
public class RegisterUserDto
{
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public int Age { get; set; }
public string PhoneNumber { get; set; }
}
Вимоги:
+380XXXXXXXXXПерепишіть метод з використанням Result Pattern (ErrorOr):
public Order PlaceOrder(int userId, List<OrderItem> items)
{
var user = _userRepository.Find(userId);
if (user == null)
throw new UserNotFoundException();
if (items.Count == 0)
throw new ArgumentException("Order must contain items");
var total = items.Sum(i => i.Price * i.Quantity);
if (user.Balance < total)
throw new InsufficientFundsException();
foreach (var item in items)
{
var product = _productRepository.Find(item.ProductId);
if (product == null)
throw new ProductNotFoundException(item.ProductId);
if (product.Stock < item.Quantity)
throw new InsufficientStockException(item.ProductId);
product.Stock -= item.Quantity;
}
user.Balance -= total;
var order = new Order { UserId = userId, Items = items, Total = total };
_orderRepository.Add(order);
return order;
}
Реалізуйте процес реєстрації користувача з валідацією, створенням акаунту, відправкою email та логуванням через ROP:
public Task<ErrorOr<UserRegistrationResult>> RegisterUserAsync(
RegisterUserDto dto,
CancellationToken ct);
Етапи:
Використайте ErrorOr та методи Then/Bind для ланцюжка.
Закріпіть отримані знання, пройшовши короткий тест:
Building Professional CLIs
Глибоке вивчення створення професійних консольних інтерфейсів в C# з використанням System.CommandLine та Spectre.Console
The Modern .NET Host (Microsoft.Extensions)
Комплексне вивчення сучасного .NET Host: Dependency Injection, Configuration, Logging, Resilience та Background Services для production-ready додатків.