Идиоматика 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 и как не словить
дедлок в проде.