C# / .NET Идиоматика C# и обработка ошибок: исключения, Result, IDisposable, паттерны
0%

Идиоматика C# и обработка ошибок: исключения, Result, IDisposable, паттерны

Идиоматика C# и обработка ошибок

Синтаксис вы уже знаете. Теперь научимся писать код, который «выглядит как C#» — идиоматично, понятно другим разработчикам и надёжно в проде. Главная тема статьи — как правильно моделировать и обрабатывать ошибки, потому что именно здесь ломаются большинство систем.

Две модели ошибок: исключения и возвращаемые значения

В C# есть два подхода, и зрелый инженер выбирает осознанно.

Исключения (exceptions) — идиоматичный путь по умолчанию

.NET построен на исключениях. Стандартная библиотека бросает их повсеместно. Ключевой принцип: исключения — для исключительных, непредвиденных ситуаций, а не для управления обычным потоком.

public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
    var order = await _repository.FindAsync(id, ct);
    if (order is null)
        throw new OrderNotFoundException(id);   // отсутствие — исключение? см. ниже

    return order;
}

Правила хорошего тона с исключениями:

  • Бросайте специфичные типы. ArgumentNullException, InvalidOperationException, собственные доменные (OrderNotFoundException). Не бросайте Exception или ApplicationException — по ним невозможно поймать нужное.
  • Не глотайте. catch (Exception) { } — антипаттерн, скрывающий баги. Ловите конкретное или не ловите вовсе.
  • Ловите то, что можете обработать. Если вы не знаете, что делать с исключением, пусть оно летит выше — к глобальному обработчику.
  • Сохраняйте стектрейс при проброске. Используйте throw; (без аргумента), а не throw ex; — последнее затирает исходный стектрейс.
  • Не используйте исключения для контроля потока — они дороги (сбор стектрейса).
try
{
    await ProcessAsync(ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    // exception filter (when): ловим только rate-limit, остальное летит выше
    await Task.Delay(RetryDelay, ct);
}
catch (OperationCanceledException)
{
    throw;   // отмену пробрасываем как есть
}

Обратите внимание на exception filters (when) — они позволяют фильтровать без повторного throw и не разматывают стек, если условие ложно.

Result-паттерн — для ожидаемых, доменных ошибок

Когда «ошибка» — это нормальная, ожидаемая часть бизнес-логики (валидация не прошла, товара нет на складе, пользователь не найден), исключения становятся дорогими и скрывают контракт метода. Тогда лучше сделать ошибку явной в сигнатуре — вернуть Result.

// Простейший обобщённый Result
public readonly record struct Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool ok, T? value, string? error) =>
        (IsSuccess, Value, Error) = (ok, value, error);

    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

public Result<Order> PlaceOrder(Cart cart)
{
    if (cart.IsEmpty)
        return Result<Order>.Failure("Корзина пуста");
    if (!_inventory.HasStock(cart))
        return Result<Order>.Failure("Недостаточно товара на складе");

    return Result<Order>.Success(CreateOrder(cart));
}

На практике вместо самописного берут зрелые библиотеки: [FluentResults] или [ErrorOr] (github.com/amantinband/error-or). Многие проекты используют OneOf<...> для дискриминированных объединений. Обрабатывать удобно через pattern matching:

var result = PlaceOrder(cart);
return result.IsSuccess
    ? Results.Ok(result.Value)
    : Results.BadRequest(result.Error);

Как выбрать? Эвристика: ошибка ожидаема и является частью бизнес-контракта → Result. Ошибка означает баг или сбой инфраструктуры (сеть упала, диск полон, нарушен инвариант) → исключение. Не превращайте всё в Result — это делает код шумным и борется с языком. Хороший разбор темы — Vladimir Khorikov (enterprisecraftsmanship.com).

Валидация аргументов

Современный C# даёт лаконичные guard-хелперы:

public void Configure(string name, int retries)
{
    ArgumentNullException.ThrowIfNull(name);
    ArgumentException.ThrowIfNullOrWhiteSpace(name);
    ArgumentOutOfRangeException.ThrowIfNegative(retries);
    ArgumentOutOfRangeException.ThrowIfGreaterThan(retries, 10);
    // ...
}

Для валидации входных DTO в веб-слое используют FluentValidation — декларативные правила, отделённые от моделей.

Детерминированное освобождение ресурсов: IDisposable и using

GC не знает, когда закрыть файл или соединение с БД — он освобождает только память. Для неуправляемых ресурсов существует IDisposable и оператор using, который гарантирует вызов Dispose() при выходе из области видимости, даже при исключении.

// using-declaration (C# 8): ресурс освобождается в конце блока метода
public async Task<string> ReadFileAsync(string path, CancellationToken ct)
{
    using var reader = new StreamReader(path);   // Dispose при выходе
    return await reader.ReadToEndAsync(ct);
}

// Для асинхронного освобождения — IAsyncDisposable + await using
public async Task CopyAsync(CancellationToken ct)
{
    await using var conn = new SqlConnection(_connectionString);
    await conn.OpenAsync(ct);
    // ... await using вызовет DisposeAsync
}

Правило: если тип реализует IDisposable/IAsyncDisposable — оборачивайте в using. Если ваш класс владеет disposable-полями, он тоже должен реализовать IDisposable и освобождать их. Не реализуйте финализаторы (~Class), если не работаете напрямую с неуправляемой памятью — это редкий случай.

Иммутабельность и выразительность

Идиоматичный C# тяготеет к неизменяемости там, где это уместно:

// records + init-only свойства = неизменяемые данные
public record Customer
{
    public required string Name { get; init; }   // required: обязательно при создании
    public string? Email { get; init; }
}

var c = new Customer { Name = "Ann" };
// c.Name = "Bob"; // ошибка компиляции: init-only

// Primary constructors (C# 12) для классов
public class OrderService(IOrderRepository repository, ILogger<OrderService> logger)
{
    public async Task Handle(int id) => logger.LogInformation("Order {Id}", id);
}

required заставляет инициализировать свойство, init запрещает менять после создания. Вместе с nullable это делает «наполовину сконструированные» объекты невозможными на уровне компилятора.

Полезные паттерны

Null Object вместо разбросанных null-проверок; Options через записи; фабричные методы для сложного создания. Но два паттерна встречаются постоянно:

// 1. Guard clauses (ранний выход) вместо вложенных if
public decimal ApplyDiscount(Order order, Customer customer)
{
    if (order.Total <= 0) return 0;
    if (!customer.IsActive) return order.Total;
    if (customer.Tier != Tier.Gold) return order.Total * 0.95m;
    return order.Total * 0.9m;
}

// 2. Extension methods — расширяем типы, не наследуясь
public static class StringExtensions
{
    public static bool IsValidEmail(this string s) =>
        s.Contains('@') && s.Contains('.');
}
// использование: if (email.IsValidEmail()) ...

Антипаттерны, которых избегают в проде

  • Пустой catch — проглатывает баги, превращает отладку в кошмар.
  • throw ex; вместо throw; — теряет стектрейс.
  • Исключения для потока управления — дорого и нечитаемо.
  • God-классы и статические синглтоны с состоянием — мешают тестам и DI.
  • async void (кроме обработчиков событий) — исключения нельзя поймать, см. статью про конкурентность.
  • Возврат null вместо пустой коллекции — вынуждает вызывающего проверять null. Возвращайте []/Array.Empty<T>().
  • Магические строки и числа — выносите в константы/enum.
  • Мутабельные публичные поля — прячьте за свойствами, стремитесь к immutability.

Стиль кода

Следуйте официальным C# coding conventions и .NET runtime coding guidelines. Ключевое:

  • PascalCase для типов, методов, свойств; camelCase для локальных переменных и параметров; _camelCase для приватных полей.
  • Интерфейсы с префиксом I: IRepository.
  • Асинхронные методы с суффиксом Async: GetOrderAsync.
  • File-scoped namespaces, var где тип очевиден из правой части.
  • Максимально узкие модификаторы доступа (private/internal по умолчанию).

Не спорьте о стиле на ревью — зафиксируйте его в .editorconfig и пусть dotnet format и анализаторы следят автоматически. Экономьте энергию код-ревью на логику и дизайн, а не на скобки.

Что дальше

Конкурентность — самая важная и коварная тема: async/await, Task vs ValueTask, отмена, Channels, IAsyncEnumerable и как не словить дедлок в проде.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов