ErrorOr для C# (.NET): полное руководство по обработке ошибок без исключений
В современной разработке на C# (.NET) обработка ошибок традиционно строится на механизме исключений (exceptions). Однако у этого подхода есть ряд недостатков: исключения нарушают поток выполнения, создают накладные расходы на стек, и, что самое важное, не всегда явно сигнализируют разработчику о том, что метод может завершиться ошибкой. Библиотека ErrorOr предлагает альтернативу — функциональный подход, при котором результат операции возвращается в виде специального объекта, который может содержать либо успешное значение, либо список ошибок.
Основная идея ErrorOr заключается в том, чтобы сделать обработку ошибок явной и типобезопасной. Вместо того чтобы бросать исключение, метод возвращает объект типа ErrorOr<TValue>. Вызывающий код обязан проверить, успешно ли завершилась операция, прежде чем использовать результат. Это предотвращает множество багов, связанных с необработанными исключениями, и делает код более предсказуемым и легким для понимания.
Библиотека вдохновлена языками с развитой системой типов, такими как Rust (тип Result<T, E>) и F# (тип Result<'T, 'TError>). ErrorOr идеально подходит для Clean Architecture, Domain-Driven Design (DDD) и CQRS-проектов, где важно четко разделять успешные и ошибочные сценарии. Она также отлично работает с паттерном «Railway Oriented Programming», позволяя строить цепочки операций с автоматической передачей ошибок.
Установка
Установка библиотеки ErrorOr в проект .NET производится через менеджер пакетов NuGet. Выполните одну из следующих команд в терминале или консоли диспетчера пакетов:
dotnet add package ErrorOr
Или, если вы используете Package Manager Console в Visual Studio:
Install-Package ErrorOr
После установки подключите пространство имен в вашем файле с кодом:
using ErrorOr;
Быстрый старт: минимальный рабочий пример
Давайте напишем простую функцию, которая делит одно число на другое. Вместо того чтобы бросать исключение при делении на ноль, мы вернем ErrorOr<double>.
using ErrorOr;
public static class Calculator
{
public static ErrorOr<double> Divide(double dividend, double divisor)
{
if (divisor == 0)
{
// Возвращаем ошибку, если делитель равен нулю
return Error.Validation("Calculator.DivisionByZero", "Divisor cannot be zero.");
}
// Возвращаем успешный результат
return dividend / divisor;
}
}
// Использование
public class Program
{
public static void Main()
{
ErrorOr<double> result = Calculator.Divide(10, 2);
if (result.IsError)
{
Console.WriteLine($"Operation failed: {result.FirstError.Description}");
}
else
{
Console.WriteLine($"Result: {result.Value}");
}
// Пример с ошибкой
ErrorOr<double> errorResult = Calculator.Divide(10, 0);
if (errorResult.IsError)
{
Console.WriteLine($"Operation failed: {errorResult.FirstError.Description}");
}
}
}
В этом примере мы видим ключевые элементы: создание ошибки через Error.Validation(), возврат успешного значения (неявное преобразование), и проверку результата через свойство IsError.
Основные методы, функции и классы
Ниже приведен подробный разбор ключевых компонентов библиотеки ErrorOr. Для каждого элемента указана сигнатура, описание и рабочий пример кода.
1. Класс ErrorOr<TValue>
Сигнатура: public readonly struct ErrorOr<TValue>
Описание: Это основной тип, который представляет результат операции. Он может содержать либо успешное значение типа TValue, либо список ошибок типа List<Error>. Является структурой (struct), что минимизирует выделение памяти в куче.
Пример:
ErrorOr<string> successResult = "Hello, World!"; // Неявное преобразование
ErrorOr<string> errorResult = Error.Failure("MyError", "Something went wrong.");
2. Свойство IsError
Сигнатура: public bool IsError { get; }
Описание: Возвращает true, если объект содержит ошибки (т.е. операция завершилась неудачей). Это основной способ проверки состояния результата.
Пример:
ErrorOr<int> result = GetData();
if (result.IsError)
{
// Обработка ошибки
Console.WriteLine("Error occurred.");
}
3. Свойство Value
Сигнатура: public TValue Value { get; }
Описание: Возвращает успешное значение. Важно: Доступ к этому свойству безопасен только если IsError равно false. В противном случае будет выброшено исключение InvalidOperationException. Это сделано для того, чтобы вы явно обрабатывали ошибки.
Пример:
ErrorOr<User> userResult = FindUser(id);
if (!userResult.IsError)
{
User user = userResult.Value; // Безопасно, так как мы проверили IsError
Console.WriteLine(user.Name);
}
4. Свойство Errors
Сигнатура: public List<Error> Errors { get; }
Описание: Возвращает список всех ошибок, если операция завершилась неудачей. Если результат успешен, список будет пуст. Позволяет получить доступ ко всем ошибкам, если их несколько.
Пример:
ErrorOr<string> result = ValidateInput(data);
if (result.IsError)
{
foreach (var error in result.Errors)
{
Console.WriteLine($"Code: {error.Code}, Description: {error.Description}");
}
}
5. Свойство FirstError
Сигнатура: public Error FirstError { get; }
Описание: Возвращает первую ошибку из списка. Это удобно, когда вам нужно быстро получить основную причину сбоя, не перебирая весь список. Если ошибок нет, выбрасывает исключение.
Пример:
ErrorOr<int> result = ProcessOrder(order);
if (result.IsError)
{
// Быстрый доступ к первой ошибке
Console.WriteLine($"First error: {result.FirstError.Description}");
}
6. Метод Match
Сигнатура: public TOut Match<TOut>(Func<TValue, TOut> onValue, Func<List<Error>, TOut> onError)
Описание: Функциональный метод для обработки результата. Вы принимаете два делегата: один для случая успеха, другой для случая ошибки. Метод гарантирует, что будет вызван ровно один из них. Это элегантная альтернатива конструкции if-else.
Пример:
ErrorOr<string> result = FetchData();
string message = result.Match(
onValue: value => $"Success: {value}",
onError: errors => $"Error: {string.Join(", ", errors.Select(e => e.Description))}"
);
Console.WriteLine(message);
7. Метод Then (или Bind)
Сигнатура: public ErrorOr<TNext> Then<TNext>(Func<TValue, ErrorOr<TNext>> binder)
Описание: Позволяет строить цепочки операций. Если текущий результат успешен, вызывается функция binder, которая принимает значение и возвращает новый ErrorOr<TNext>. Если текущий результат содержит ошибку, binder не вызывается, и ошибка передается дальше по цепочке.
Пример:
ErrorOr<int> GetNumber() => 5;
ErrorOr<string> NumberToString(int num) => num.ToString();
ErrorOr<int> ParseString(string str) => int.Parse(str); // Может выбросить исключение, но для примера
ErrorOr<string> result = GetNumber()
.Then(NumberToString); // result будет содержать "5"
// Цепочка с ошибкой
ErrorOr<int> failingGetNumber() => Error.Failure("NoNumber", "Could not get number");
ErrorOr<string> chainResult = failingGetNumber()
.Then(NumberToString); // chainResult будет содержать ошибку от failingGetNumber
8. Класс Error и статические фабрики
Сигнатура: public sealed record Error с методами Error.Failure(...), Error.Unexpected(...), Error.Validation(...), Error.NotFound(...), Error.Forbidden(...), Error.Unauthorized(...), Error.Conflict(...), Error.Custom(...).
Описание: Класс Error представляет собой единичную ошибку. Он содержит свойства Code (строка-идентификатор), Description (человекочитаемое описание) и Type (тип ошибки, например, Failure, Validation). Статические фабрики предоставляют удобный способ создания ошибок разных типов.
Пример:
// Разные способы создания ошибок
Error failure = Error.Failure("User.SaveFailed", "Could not save user to database.");
Error validation = Error.Validation("User.NameEmpty", "User name cannot be empty.");
Error notFound = Error.NotFound("User.NotFound", $"User with ID {id} not found.");
Error custom = Error.Custom(42, "MyCustomType", "My.Custom.Code", "A custom error.");
9. Неявное преобразование (Implicit Conversion)
Сигнатура: (Неявно определено для типа ErrorOr<TValue>)
Описание: ErrorOr<TValue> поддерживает неявное преобразование из TValue и из Error. Это значит, что вы можете просто вернуть значение или ошибку из метода, не оборачивая их явно в ErrorOr.
Пример:
public ErrorOr<string> GetGreeting(bool isMorning)
{
if (isMorning)
{
return "Good morning!"; // Неявное преобразование из string в ErrorOr<string>
}
return Error.NotFound("Greeting.NotFound", "No greeting for this time."); // Неявное преобразование из Error
}
10. Метод Switch
Сигнатура: public void Switch(Action<TValue> onValue, Action<List<Error>> onError)
Описание: Аналог Match, но не возвращает значение, а выполняет действия (void-методы). Подходит для сценариев, где нужно выполнить побочные эффекты (логирование, запись в БД).
Пример:
ErrorOr<string> result = GetData();
result.Switch(
onValue: value => Console.WriteLine($"Processing: {value}"),
onError: errors => Console.WriteLine($"Errors: {errors.Count}")
);
Реальный практический пример (100-200 строк кода)
Рассмотрим пример из реального приложения — сервис для регистрации пользователя. Мы создадим доменный сервис, который проверяет входные данные, проверяет уникальность email и сохраняет пользователя. Все ошибки будут возвращаться через ErrorOr.
using ErrorOr;
// --- Доменные модели ---
public class User
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
public User(string name, string email)
{
Id = Guid.NewGuid();
Name = name;
Email = email;
}
}
// --- Репозиторий (имитация) ---
public interface IUserRepository
{
bool IsEmailUnique(string email);
void Add(User user);
}
public class UserRepository : IUserRepository
{
private readonly List<User> _users = new();
public bool IsEmailUnique(string email)
{
return !_users.Any(u => u.Email.Equals(email, StringComparison.OrdinalIgnoreCase));
}
public void Add(User user)
{
_users.Add(user);
Console.WriteLine($"User {user.Name} added to database.");
}
}
// --- Сервис ---
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
public ErrorOr<User> RegisterUser(string name, string email)
{
// 1. Валидация входных данных
if (string.IsNullOrWhiteSpace(name))
{
return Error.Validation("User.NameRequired", "Name is required.");
}
if (string.IsNullOrWhiteSpace(email))
{
return Error.Validation("User.EmailRequired", "Email is required.");
}
if (!email.Contains('@'))
{
return Error.Validation("User.InvalidEmail", "Email must contain '@'.");
}
// 2. Проверка уникальности email
if (!_repository.IsEmailUnique(email))
{
return Error.Conflict("User.EmailNotUnique",