Где в обработке исключений ставить catch и логирование
· Обновлено: · Го Комура · Обработка исключений, Логирование, Обработка ошибок, Проектирование, C# / .NET
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619836)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Где в обработке исключений ставить catch и логирование. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/04/15/000-exception-catching-logging-error-handling/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619836
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619837
На ревью обработки исключений одни и те же замечания всплывают снова и снова. Обычно они сводятся к трём.
- Самая глубокая общая функция делает
catch (Exception), и вызывающий код уже не отличит «данных не было» от «по дороге что-то сломалось» - На один сбой подряд выстраиваются четыре одинаковых стека: Repository, Service, Controller и обработчик необработанных исключений
- Пользователь просто отменил операцию, а в лог уходит
Error, и по-настоящему опасный инцидент тонет в этом шуме
Дело не в том, что try / catch написаны «криво». Не распределены роли: где ловить, кто пишет лог и где решается, как выглядит сбой. Пока этого нет, на каждом слое кто-нибудь «на всякий случай» добавляет catch и лог — и в итоге причину уже не видно.
Статья для тех, кто пишет на C# / .NET бизнес-приложения и Web API, и для тех, кто ревьюит такую архитектуру. Разбираем, где ловить исключения, где писать основной лог и кто отвечает за решение о восстановлении. Если заранее решить, что делает каждый уровень цепочки вызовов, и на ревью, и при разборе инцидента решения меньше плавают.
Термины, которые используются в статье
Три слова ниже будут встречаться постоянно — определим их сразу. Это не общепринятые термины, а значения внутри этой статьи.
| Термин | Смысл в этой статье |
|---|---|
| Единица сбоя | Обработка, которая с точки зрения бизнеса составляет «один сбой». Одно действие на экране, один HTTP-запрос, одно задание, одно сообщение, одна строка CSV. И в логе, и в ответе видна именно эта единица |
| Основной лог | Запись уровня Error или Critical, которую на один сбой делают один раз. Вместе с ней идут единица сбоя и операционный контекст (requestId, userId, идентификатор объекта). Всё остальное — вспомогательные логи Debug / Information / Warning |
| Превращение в результат | Прекратить пробрасывать исключение дальше и вернуть значение: тип Result или DTO сбоя. Так ожидаемый сбой вызывающий код может разобрать обычным ветвлением |
Содержание
- Сначала выводы
catch, логирование и обработка ошибок — разные вещи- 2.1. Перехват (
catch) - 2.2. Логирование
- 2.3. Обработка ошибок
- 2.4. Трансляция исключений
- 2.1. Перехват (
- Таблица, с которой стоит начать
- Что делать на каждом уровне цепочки вызовов
- 4.1. Самый глубокий helper / utility / private method
- 4.2. Граница внешнего I/O: Repository / Gateway / обёртка SDK
- 4.3. Application Service / UseCase
- 4.4. Граница UI / HTTP / Job / Message
- 4.5. Последний обработчик необработанных исключений
- 4.6. Взгляд на одну цепочку вызовов
- Отделять ожидаемые сбои от непредвиденных исключений
- Где и сколько раз писать лог
- Типичные ошибки
- Чек-лист для ревью
- Краткая сводка
- Итог
- Справочные материалы
- Похожие статьи
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 25, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
1. Сначала выводы
- Принцип: на глубоких слоях широко не ловить.
catchсдвигают к границе, где можно определить единицу сбоя. - Логирование по умолчанию: один основной лог на один сбой. Если каждый слой продолжает писать одно и то же исключение как
Error, читать журнал тяжело. - Ответственность самого глубокого слоя — очистка, локальный откат, трансляция исключения и, если нужно, ограниченный retry. Если исключение пробрасывается дальше, основной лог там обычно не пишут.
- Границы обработки — одно действие на экране, один HTTP-запрос, одно задание, обработка одного сообщения — чаще всего оказываются самым естественным местом для основного лога.
- Ожидаемые сбои превращают в результат в рамках соответствующего use case. Не обязательно пробрасывать всё до самого верха исключением.
AppDomain.UnhandledException,DispatcherUnhandledExceptionв WPF,ThreadExceptionв WinForms, обработчик исключений ASP.NET Core, финальная обработка на уровне хоста — это скорее последняя точка записи, чем точка восстановления.OperationCanceledExceptionиз-за отмены пользователем или завершения работы обычно не считают Error.- Если сомневаетесь, проверяйте в таком порядке:
- Можно ли здесь действительно принять решение?
- Видна ли здесь единица сбоя?
- Можно ли здесь откатить состояние или собрать его заново?
- Если залогировать здесь, не залогирует ли то же исключение ещё и слой выше?
Иначе говоря, основа такая: ловить не там, где это возможно, а там, где можно ответственно принять решение.
2. catch, логирование и обработка ошибок — разные вещи
2.1. Перехват (catch)
catch — это принять исключение и изменить дальнейший ход обработки.
Само по себе это ещё не восстановление.
Например, даже если исключение поймали в нижележащем методе, но при этом:
- непонятно, что показать пользователю;
- непонятно, останавливать ли весь экран или достаточно провалить только текущую операцию;
- непонятно, можно ли продолжать этот request или job,
то, скорее всего, это место не подходит для catch.
2.2. Логирование
Лог — это запись не только факта «произошло исключение», но и того, какая именно работа не удалась, чтобы потом это можно было проследить.
Поэтому у хорошей точки логирования обычно есть что-то из этого списка:
- requestId / traceId
- userId
- orderId / fileId / batchId
- номер обрабатываемой записи
- какое именно действие на экране
- какая очередь, какое сообщение
Глубокие helper-ы и общие функции часто знают технические детали, но этого контекста у них нет. Поэтому место, которое знает технические детали, и место, которое знает операционный контекст, часто оказываются разными.
2.3. Обработка ошибок
Под обработкой ошибок здесь имеется в виду следующее.
- Показать сообщение об ошибке на экране
- Вернуть 4xx / 5xx в HTTP
- Провалить только одну запись и перейти к следующей
- Заново инициализировать подсистему
- Завершить процесс и положиться на перезапуск
- Освободить ресурсы и выйти безопасно
Иначе говоря, это решение, как сбой выглядит со стороны вызывающего кода или пользователя.
2.4. Трансляция исключений
На практике между catch и «обработать» есть ещё одна важная работа.
Это трансляция.
Например, если пропустить напрямую в UI или Controller:
HttpRequestExceptionIOExceptionJsonException- исключения, специфичные для драйвера БД
- исключения, специфичные для SDK вендора,
верхние слои начинают знать детали реализации нижних.
Поэтому на границе их превращают в сбои, осмысленные для этого слоя, например:
- «не удалось подключиться к платёжному сервису»;
- «формат CSV повреждён»;
- «не удалось записать в место назначения»;
- «ответ устройства некорректен».
Важно, что трансляция и логирование — не одно и то же. Если исключение только транслировали и пробросили дальше, основной лог обычно не пишут.
3. Таблица, с которой стоит начать
В статье три похожие таблицы. Роли у них разные, поэтому сначала — когда какую смотреть.
| Таблица | Когда смотреть | Что внутри |
|---|---|---|
| Глава 3. Таблица, с которой стоит начать | На этапе проектирования, когда делите ответственность по слоям | Базовая политика по месту, писать ли основной лог, главная ответственность |
| Таблица точек логирования в главе 6 | Во время реализации, когда рука зависла над строкой лога | По типу сбоя: где и на каком уровне писать |
| Краткая сводка в главе 9 | На ревью, для финальной проверки | Главы 3 и 6, свёрнутые в три колонки: catch / лог / обработка ошибок |
Проще всего сразу зафиксировать общую политику по этой таблице.
| Место | Базовая политика | Основной лог | Главная ответственность |
|---|---|---|---|
| helper / utility / private method | По умолчанию широко не ловить | Не пишется | Очистка в finally, локальный откат, минимальное добавление контекста |
| Repository / Gateway / обёртка SDK | Ловить только конкретные исключения | Обычно не пишется | Трансляция исключений, ограниченный retry, отбрасывание соединений и хендлов |
| Application Service / UseCase | Превращать ожидаемые сбои в результат | Если поглощаете здесь — по необходимости | Определение единицы сбоя, частичный сбой, решения уровня use case |
| Граница UI / Controller / API / Job / Message | Основная точка приёма непредвиденных исключений | Чаще всего основной лог именно здесь | Ответ пользователю, HTTP-ответ, переход к следующей записи, решение об abort |
| Обработчик необработанных исключений / финальная граница хоста | Последний рубеж против утечек | Critical |
Финальная запись, flush, dump, путь завершения и перезапуска |
Схематично это выглядит примерно так.
flowchart TD
A["Произошло исключение"] --> B{"Можно ли здесь решить: retry / превратить в результат / продолжать ли?"}
B -- "Нет" --> C["По умолчанию не ловить, отдать выше"]
B -- "Да" --> D{"Это граница слоёв?"}
D -- "Нет" --> E["Только локальная очистка"]
D -- "Да" --> F["При необходимости транслировать в осмысленное исключение"]
E --> G{"Видны ли здесь единица сбоя и операционный контекст?"}
F --> G
G -- "Нет" --> H["Основной лог не писать, отдать выше"]
G -- "Да" --> I["Один раз написать основной лог и решить, каким будет ответ"]
I --> J["При необходимости: завершение / повторная инициализация / переход к следующей записи"]
У этой схемы два ключевых момента.
- Первая причина для
catch— восстановление или очистка, а не логирование. - Первая причина для лога — собранный операционный контекст, а не сам факт, что исключение нашли.
4. Что делать на каждом уровне цепочки вызовов
4.1. Самый глубокий helper / utility / private method
Здесь базовое правило — широко не ловить.
Такие места, как преобразование строк, разбор, вычисления, внутреннее форматирование, общие helper-ы, не могут решить:
- какое это было действие на экране;
- какой это был request;
- допустимо ли провалить только эту попытку;
- нужно ли закрывать весь экран целиком.
На этом слое в основном допустимо следующее.
- Освобождение ресурсов в
finally - Откат локального состояния, изменённого наполовину
- Минимальное добавление контекста к сообщению исключения
- Замена на более подходящий тип исключения
- Уничтожение объектов, которые больше нельзя переиспользовать
Общее у этого списка одно: это очистка, которую можно сделать правильно, не зная, кто вызывающий. Сюда кладут только то, что не требует решения; всё, что требует решения, отдают выше — так границу провести проще.
И наоборот, такого стиля лучше избегать.
catch (Exception)с возвратомnull/false/ пустого массива- Показ
MessageBoxв этом месте - Запись
Errorздесь и последующий повторный throw - «Просто продолжать», когда состояние нельзя восстановить
Особенно опасен паттерн, при котором сбой происходит после того, как собственное состояние уже частично изменено, а объект продолжают использовать как ни в чём не бывало. В этом случае либо откатывают состояние на месте, если это возможно, либо считают объект одноразовым и подлежащим уничтожению.
4.2. Граница внешнего I/O: Repository / Gateway / обёртка SDK
Здесь причина для catch очевидна.
Именно тут наружу выходят детали реализации нижележащего слоя.
- Исключения драйвера БД
- Исключения HTTP-обмена
- Исключения файлового I/O
- Специфичные исключения COM / P/Invoke / SDK вендора
- Исключения библиотек разбора и сериализаторов
На этом слое делают, по сути, четыре вещи.
-
Ловить конкретные исключения Не широкий
Exception, а конкретные, осмысленные типы. -
Транслировать в осмысленный сбой Чтобы верхним слоям не приходилось знать внутренние детали нижних напрямую.
- Если нужен локальный retry — делать его здесь
Но условия строгие:
- известно, что сбой временный;
- операция идемпотентна;
- заданы предел попыток и стратегия ожидания;
- итоговое поведение при провале ясно. Только когда выполняются все четыре условия.
- Отбрасывать повреждённые соединения и хендлы Часто безопаснее «пересоздать соединение», чем «продолжать с тем же объектом».
Политика логирования на этом слое меньше плавает, если смотреть так:
- при повторном throw выше основной лог обычно не пишут;
- если исключение поглощают здесь и превращают в результат, в этот момент пишут нужные логи и метрики;
- каждую попытку retry держат в диапазоне
Debug/Information/Warning, а фиксируют весомее только финальный сбой.
Этот слой — место трансляции, а не, как правило, место финального решения.
4.3. Application Service / UseCase
Это слой, который решает, «как именно провалится эта конкретная работа».
Например:
- операция сохранения;
- подтверждение заказа;
- импорт CSV;
- обработка одной записи пакета;
- применение одного сообщения —
это единицы, целостные как use case, и они находятся здесь.
На этом слое можно принимать такие решения.
- Ошибка валидации — провал только этой попытки
NotFoundсоответствует 404- Нарушение бизнес-правила — ждать, пока пользователь исправит данные
- Некорректная строка CSV логируется как
Warning, обработка продолжается - Временный сбой внешнего сервиса приводит к провалу всей операции
- Промежуточный результат отбрасывается, обработка начинается заново
Иначе говоря, это место, где можно определить единицу сбоя.
Этому слою подходит такая работа:
- превращение ожидаемых сбоев в
Resultили DTO сбоя; - агрегация частичных сбоев;
- решение, сколько сбоев допустимо, прежде чем остановиться;
- преобразование в коды ошибок или ключи сообщений для пользователя.
И наоборот, этому слою не стоит тащить на себя слишком много отображения UI или сборки тела HTTP-ответа. Здесь легче разделить ответственность, если слой определяет только смысл на уровне use case, а окончательное представление отдаёт границе.
4.4. Граница UI / HTTP / Job / Message
Именно здесь в большинстве приложений чаще всего оказывается точка основного лога.
Например, такие единицы:
- одно нажатие кнопки «Сохранить» в WinForms / WPF;
- один HTTP-запрос в ASP.NET Core;
- одно сообщение worker-а;
- одна запись входных данных в пакете;
- один запуск запланированного задания.
Это место знает:
- какая была операция;
- чья это была операция;
- какая это по счёту запись;
- какой request / batch / message;
- что вернуть пользователю или вызывающей стороне при сбое.
Эти пять пунктов одновременно есть, как правило, только на этом слое. Ниже знают технические детали, но не операционный контекст; выше, у обработчика необработанных исключений, уже не видно единицу сбоя. Поэтому здесь естественно:
- принимать непредвиденные исключения в одном месте;
- писать основной лог один раз, с контекстом;
- преобразовывать в диалог с ошибкой, HTTP 500, Problem Details, провал задания, переход к следующей записи и т. п.
Важно на этом слое не само по себе широкое перехватывание, а то, определено ли, что вернуть после такого перехвата.
Например, для пакетов и очередей полезно мыслить в два этапа.
- Перехват на границе одной записи Решение, допустимо ли провалить только эту запись и перейти к следующей
- Родительский цикл не должен глушить всё подряд Если родительский цикл падает, лучше склоняться к перезапуску всего процесса
«Проваливать записи по одной и продолжать» и «родительский цикл молча живёт дальше после непредвиденного исключения» — совершенно разные вещи.
4.5. Последний обработчик необработанных исключений
Это последний рубеж. Не волшебная точка восстановления.
Типичные представители:
AppDomain.UnhandledException;Application.DispatcherUnhandledExceptionв WPF;Application.ThreadExceptionв WinForms;- middleware и обработчики исключений ASP.NET Core;
- финальная обработка исключений в Generic Host / worker /
BackgroundService.
Основная ответственность этого слоя — самое большее следующее.
- Финальный лог
- Flush
- Путь снятия дампа
- Сохранение информации о сессии и недавнего контекста
- Подготовка кода завершения и пути перезапуска
С другой стороны, не стоит возлагать на него слишком много ожиданий.
- К моменту, когда исключение дошло сюда, это чаще всего пробел в проекте выше по цепочке;
- состояние уже могло быть повреждено;
- возможно удержание блокировок, из-за чего тяжёлая работа здесь опасна;
- даже если внешне можно продолжить, это не значит, что продолжать безопасно.
Есть и практические нюансы, важные именно для .NET.
AppDomain.UnhandledException— это событие для уведомления и записи необработанных исключений. Закладывать в него слишком много логики восстановления опасно.- В
DispatcherUnhandledExceptionWPF есть путь установитьHandled = trueи внешне продолжить работу, но сначала нужно определить, возможно ли восстановление. ThreadExceptionв WinForms тоже может оставить приложение в неизвестном состоянии после обработки.- Middleware обработки исключений ASP.NET Core нужно размещать ближе к началу конвейера, чтобы оно могло перехватывать исключения из последующих этапов.
- Необработанное исключение в
BackgroundService, начиная с .NET 6, логируется и по умолчанию приводит к остановке хоста. Иногда безопаснее остановиться и положиться на стратегию перезапуска, чем глушить всё в родительском цикле.
Особенно в настольных приложениях существует путь «поймать необработанное исключение и продолжить работу». Но возможность продолжить и правильность продолжения — разные вещи.
4.6. Взгляд на одну цепочку вызовов
Рассмотрим, например, такой поток.
flowchart LR
A["Граница UI / Controller / Job"] --> B["Application Service / UseCase"]
B --> C["Domain / бизнес-логика"]
C --> D["Repository / Gateway / SDK wrapper"]
D --> E["DB / HTTP / File / Vendor SDK"]
Роли при этом распределяются примерно так.
Кнопка «Сохранить» → SaveOrderUseCase → PaymentGateway → HTTP
PaymentGateway- принимает сбои соединения и некорректный формат ответа;
- транслирует их в «не удалось подключиться к платёжному сервису» / «некорректный ответ платёжного сервиса»;
- если нужен retry, выполняет его здесь, при определённых условиях;
- при повторном throw обычно не пишет основной лог.
SaveOrderUseCase- превращает ожидаемые сбои вроде отказа в платеже в результат;
- трактует это как «провал только текущего подтверждения заказа»;
- оформляет результат сбоя так, чтобы его удобно было вернуть в UI или API.
- Обработчик кнопки UI / Controller
- принимает непредвиденные исключения в одном месте;
- пишет основной лог с
orderId,userId,requestId; - преобразует его в диалоговое окно или ответ 500 / 503.
- Обработчик необработанных исключений
- фиксирует только то, что дошло до этой точки;
- выполняет dump и финальный flush;
- ставит в приоритет путь завершения, а не восстановление.
При таком разделении получается форма, при которой технические детали закрываются внизу, операционный контекст добавляется наверху, а решения принимаются на границе.
Какие логи реально пишет каждый слой
Если довести до конкретных строк, которые каждый слой пишет на один и тот же сбой, распределение ролей становится ещё яснее. Сценарий одной операции заказа: «платёжный сервис два раза ушёл в timeout, на третьей попытке ответил успешно, а затем при резервировании остатка нарушился инвариант».
| Слой | Что пишет | Уровень | Пример сообщения |
|---|---|---|---|
PaymentGateway |
каждая попытка retry | Warning |
Повторное подключение к платёжному сервису. attempt={Attempt}/{MaxAttempts}, orderId={OrderId} |
PaymentGateway |
трансляция и повторный throw | не пишется | — (основной лог — зона ответственности границы) |
SaveOrderUseCase |
ожидаемый сбой превращён в результат | Information |
Оплата заказа отклонена. orderId={OrderId}, reason={DeclineReason} |
SaveOrderUseCase |
непредвиденное исключение | не пишется | — (отправляем на границу как есть) |
| Обработчик кнопки UI / Controller | основной лог непредвиденного исключения | Error |
Не удалось подтвердить заказ. orderId={OrderId}, userId={UserId} + объект исключения |
| Обработчик необработанных исключений | финальная запись | Critical |
Процесс завершается из-за необработанного исключения + объект исключения |
Главное: Error появляется ровно в одной строке. Попытки retry остаются Warning, ожидаемый сбой опускается до Information, поэтому поиск по Error находит этот инцидент один раз.
// Основной лог. Объект исключения — первый аргумент, контекст единицы сбоя — именованные поля
_logger.LogError(ex, "Не удалось подтвердить заказ. orderId={OrderId}, userId={UserId}",
orderId, userId);
Если забыть передать объект исключения первым аргументом, стек вызовов в лог не попадёт. Вызов вроде _logger.LogError(ex.Message), где уходит только строка, потом не даст проследить причину.
5. Отделять ожидаемые сбои от непредвиденных исключений
Самое важное в этой теме — не относиться ко всему как к одному и тому же «исключению».
Сначала разделим так.
| Вид сбоя | Первое место обработки | Типичная трактовка |
|---|---|---|
| Ошибка валидации | Граница UseCase / request | Возвращается как ошибка ввода |
NotFound / Conflict |
UseCase / Controller | 404 / 409 или сообщение на экране |
| Отмена пользователем / завершение работы | Граница операции | Трактуется как отмена. Обычно не Error |
| Некорректная строка CSV | Граница записи | Фиксируется как Warning, обработка продолжается |
| Временный timeout, в итоге приводящий к провалу | Граница I/O — граница request | Возвращается как сбой после retry |
NullReferenceException, нарушение инварианта |
Граница request / job | Основной лог и ответ с ошибкой |
AccessViolationException, тяжёлый OutOfMemoryException, признаки повреждения на native-границе |
Финальная граница | Critical, склонность к завершению |
Ожидаемые сбои — это сбои, которые можно заранее заложить в проект. Непредвиденные исключения — это сбои, после которых сомнительно, можно ли дальше доверять состоянию.
Уже это разделение снижает число таких инцидентов:
NotFoundкаждый раз пишется какError;- отмена пользователем трактуется как авария;
- по-настоящему опасное нарушение инварианта проскальзывает как «провал только на этот раз».
6. Где и сколько раз писать лог
В проектировании логирования важнее заранее определить, кто пишет основной лог, чем то, где именно стоит catch.
Базовых правил шесть.
- На один сбой — один основной лог
Error/Critical - Нижние слои при необходимости занимаются трансляцией и добавлением контекста
- Верхняя граница пишет основной лог с единицей сбоя и операционным контекстом
- Ответственность за запись поглощённого сбоя несёт только тот слой, который его поглотил
- Ожидаемые сбои не логируются как
Errorкаждый раз OperationCanceledExceptionотделяется от обычных логов сбоев
Ниже — ориентировочная таблица точек логирования. Таблица главы 3 отвечает на вопрос «какую ответственность класть на слой»; эта — на вопрос «по типу сбоя: где и на каком уровне оставить запись». Если во время реализации неясно, писать ли лог в этом catch, смотрите сюда.
| Ситуация | Основное место логирования | Ориентировочный уровень | Примечание |
|---|---|---|---|
| Ошибка валидации | Граница request / use case | Information или без лога |
Это сбой по контракту, а не авария |
| Отмена пользователем / shutdown | Граница операции | Debug / Information |
Обычно не Error |
| Временный сбой во время retry | Слой, владеющий retry | Debug / Warning |
Не шуметь до финального сбоя |
| Retry исчерпан, провал | Граница request / job или слой, поглощающий исключение | Warning / Error |
Фиксировать с единицей сбоя |
| Одна некорректная строка, обработка продолжается | Граница записи | Warning |
Добавить fileId, rowNumber |
| Непредвиденное исключение, роняющее весь request | Граница request / UI / job | Error |
Добавить requestId, userId, entityId |
| Класс завершения процесса | Граница необработанных исключений | Critical |
flush, dump, путь перезапуска |
На практике довольно часто встречается такое дублирование логов:
- Repository логирует
Error; - Service логирует то же исключение как
Error; - Controller снова логирует
Error; - финальный обработчик необработанных исключений логирует ещё и
Critical.
При таком подходе один сбой порождает несколько одинаковых стеков подряд. Читателю нужны не четыре копии одного и того же стека, а один основной лог и, при необходимости, немного вспомогательных.
Иначе говоря, базовое правило: логировать один раз, с ровно тем контекстом, который нужен.
7. Типичные ошибки
Дальше — формулировки, которые на ревью встречаются особенно часто. К трём типичным добавлены минимальные примеры NG и OK. Код рассчитан на C# 10 / .NET 6 и новее с включёнными nullable reference types и использует System.Text.Json и Microsoft.Extensions.Logging.
7.1. catch (Exception) глубоко в коде с возвратом null / false
Это легко теряет информацию о причине. Более того, вызывающая сторона перестаёт различать: «данных действительно не было» или «что-то сломалось по пути».
// NG: глубокий слой широко ловит и возвращает null
private static Order? LoadOrder(string path)
{
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Order>(json);
}
catch (Exception)
{
// Вызывающий код уже не отличит: файла не было, JSON повреждён
// или диск не читается
return null;
}
}
Этот слой не может решить, как обрабатывать сбой. Решение отдаём границе, а здесь останавливаемся на трансляции в осмысленный сбой.
// Тип исключения, которым этот слой обозначает осмысленный сбой
public sealed class OrderFileFormatException : Exception
{
public OrderFileFormatException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
// OK: только трансляция, решение — на верхней границе
private static Order LoadOrder(string path)
{
string json = File.ReadAllText(path);
try
{
return JsonSerializer.Deserialize<Order>(json)
?? throw new OrderFileFormatException($"Файл заказа пуст: {path}");
}
catch (JsonException ex)
{
// JsonException — деталь нижней реализации; здесь превращаем её в осмысленный сбой
throw new OrderFileFormatException($"Некорректный формат файла заказа: {path}", ex);
}
// IOException и UnauthorizedAccessException не транслируем, пропускаем выше как есть.
// «Файл нельзя прочитать» — не тот сбой, к которому этот слой может добавить смысл
}
7.2. Логирование Error на каждом слое перед повторным throw
Самая частая причина дублирующихся логов.
- нижние слои занимаются только трансляцией;
- верхняя граница пишет основной лог.
При таком разделении дублирование заметно снижается.
// NG: нижний слой пишет лог и пробрасывает дальше. Верх сделает то же — получатся две записи
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Оплата не удалась");
throw;
}
}
Нижний слой только транслирует и пропускает дальше.
// PaymentGatewayException — собственный тип той же формы, что и OrderFileFormatException:
// «обмен с платёжным сервисом не удался»
// OK: нижний слой (PaymentGateway) только транслирует. Лог не пишет
public async Task<Receipt> ChargeAsync(Payment payment, CancellationToken ct)
{
try
{
return await _gateway.ChargeAsync(payment, ct);
}
catch (HttpRequestException ex)
{
throw new PaymentGatewayException(
$"Не удалось подключиться к платёжному сервису. orderId={payment.OrderId}", ex);
}
}
А основной лог один раз пишут на границе, где уже есть единица сбоя и операционный контекст.
// OK: на границе один основной лог и решение, что вернуть вызывающей стороне
[ApiController]
public sealed class PaymentController : ControllerBase
{
private readonly ILogger<PaymentController> _logger;
private readonly SaveOrderUseCase _useCase;
public PaymentController(ILogger<PaymentController> logger, SaveOrderUseCase useCase)
{
_logger = logger;
_useCase = useCase;
}
[HttpPost("orders/{orderId}/pay")]
public async Task<IActionResult> PayAsync(string orderId, CancellationToken ct)
{
try
{
Receipt receipt = await _useCase.ExecuteAsync(orderId, ct);
return Ok(receipt);
}
catch (PaymentGatewayException ex)
{
// Единица сбоя (одна оплата этого заказа) и операционный контекст сходятся только здесь
_logger.LogError(ex, "Оплата заказа {OrderId} не удалась", orderId);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
}
Если в C# пробрасываете дальше, базовое правило — throw;, чтобы не сломать стек вызовов. Запись throw ex; перезаписывает стек в этой строке, и настоящее место возникновения теряется.
7.3. Библиотечный слой или общий компонент напрямую показывает UI
Если общий компонент показывает MessageBox или напрямую формирует тело HTTP-ответа, страдают и повторное использование, и разделение ответственности.
Нижние слои безопаснее ограничить задачей вернуть осмысленный сбой.
7.4. Логирование OperationCanceledException как сбоя на уровне Error
Отмена — часть управления потоком выполнения.
Если логировать её как Error каждый раз, реальные сбои теряются в шуме.
// NG: широкий catch захватывает и отмену, и пишет Error
try
{
await _useCase.ImportAsync(file, ct);
}
catch (Exception ex)
{
// Сюда попадает и простое нажатие «Отмена» пользователем — и уходит Error
_logger.LogError(ex, "Импорт не удался");
throw;
}
Секции catch проверяются сверху вниз, поэтому отмену ловят раньше — более конкретным типом. Предложение when помогает не спутать отмену по переданному вами токену с OperationCanceledException по другой причине, например по внутреннему timeout.
// OK: отмену ловим раньше и отделяем от логов сбоя
try
{
await _useCase.ImportAsync(file, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Прерывание пользователем или shutdown. Это часть потока управления, не Error
_logger.LogInformation("Импорт прерван. fileId={FileId}", file.Id);
}
catch (Exception ex)
{
// Сюда доходит только непредвиденный сбой. Контекст единицы сбоя — и один основной лог
_logger.LogError(ex, "Импорт не удался. fileId={FileId}", file.Id);
throw;
}
7.5. Легкомысленный retry при наличии внешних побочных эффектов
Отправка почты, списание платежа, команды устройству, перемещение файлов — многие операции опасно выполнять повторно. Retry уместен только тогда, когда видны сразу оба свойства: временность сбоя и идемпотентность.
7.6. Попытка восстановить всё в финальном обработчике необработанных исключений
Это последняя страховка. Не то место, вокруг которого стоит строить архитектуру.
Стратегию восстановления безопаснее располагать раньше — на границе request / job / подсистемы.
8. Чек-лист для ревью
При ревью обработки исключений полезно проверять в таком порядке — так меньше шансов что-то упустить.
- Можно ли одной фразой сказать, какое решение призван принять этот
catch? - Действительно ли в этом месте можно решить вопрос retry / превращения в результат / возможности продолжения / ответа пользователю?
- Если залогировать здесь, не залогируется ли тот же сбой как
Errorвыше? - Транслируются ли специфичные для нижней реализации исключения в осмысленный сбой на границе?
- Можно ли здесь откатить состояние, повреждённое на середине? Если нет — трактуется ли объект как одноразовый?
- Отделён ли
OperationCanceledExceptionот обычных сбоев? - Ясно ли, идёт ли речь о продолжении по записям, провале на уровне request или завершении процесса?
- Ожидается ли от финального обработчика необработанных исключений запись, а не восстановление?
- Несёт ли лог контекст единицы сбоя — requestId / userId / batchId / fileId / rowNumber?
- Не трактуются ли «ожидаемый сбой» и «нарушение инварианта» одинаково?
Особенно эффективно в этом чек-листе каждый раз формулировать словами, что именно решает данный catch.
Если на этот вопрос нет ответа, catch, как правило, либо не нужен, либо расположен слишком глубоко.
9. Краткая сводка
В конце — таблица, в которую свёрнуты главы 3 и 6. На ревью или при повторном взгляде на уже написанный код достаточно этой одной страницы.
| Ситуация | catch |
Лог | Обработка ошибок |
|---|---|---|---|
| helper / utility | По умолчанию нет | Нет | Нет |
| Repository / Gateway / обёртка SDK | Только конкретные исключения | Обычно без основного лога | Трансляция, локальный retry, отбрасывание соединений |
| UseCase / Application Service | Принимает ожидаемые сбои | При поглощении — по необходимости | Превращение в результат, частичный сбой |
| Граница UI / Controller / request / item / job | Широко принимает непредвиденные исключения | Основной лог | Ответ, сообщение, продолжение / abort |
| Обработчик необработанных исключений | Только то, что дошло сюда | Critical |
Финальная запись, путь завершения |
Если сомневаетесь, достаточно этих пяти пунктов.
- На глубоких слоях широко не ловить
- Перехватывать на границах
- Основной лог — один раз
- Ответственность несёт слой, поглотивший исключение
- Последнее необработанное исключение — это запись и путь завершения
10. Итог
Обработка исключений — это не история про «раз перехватить можно везде, значит и перехватываем везде».
Порядок проверки в целом такой, и его достаточно.
- Можно ли действительно принять решение в этом месте?
- Известна ли здесь единица сбоя?
- Можно ли здесь откатить или заново собрать состояние?
- Не приведёт ли логирование здесь к дублированию?
- Это точка восстановления или последняя точка записи?
При такой последовательности проверки цепочку вызовов становится заметно легче упорядочить.
Особенно важны три вещи.
- Глубокие слои — в основном трансляция и очистка
- Границы — в основном решения и основной лог
- Финальный обработчик необработанных исключений — в основном запись и путь завершения
Иначе говоря, базовое правило таково: исключения принимают на границе, снабжают контекстом и обрабатывают только там, где возможно восстановление.
Как только это определено, и код-ревью, и расследование инцидентов становятся заметно более предсказуемыми.
11. Справочные материалы
- .NET: рекомендации по работе с исключениями
- .NET: событие System.AppDomain.UnhandledException
- WPF: событие Application.DispatcherUnhandledException
- Windows Forms: событие Application.ThreadException
- Обработка ошибок в ASP.NET Core
- Middleware в ASP.NET Core
- Службы Windows на основе BackgroundService
12. Похожие статьи
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Минимальный чек-лист безопасности при разработке Windows-приложений
Чек-лист базовых мер безопасности для бизнес-приложений на WPF / WinForms / WinUI / C++ / C#: права, подпись кода, обновления, секреты, H...
Практики многопоточности на Java: что считать нормой в эпоху виртуальных потоков
В Java потоки не создают вручную: задачи отдают ExecutorService и виртуальным потокам. Разбираем, когда брать synchronized, а когда Reent...
Многопоточность на C: практические рекомендации — безопасно по правилам Win32 API
На C с Win32 потоки создают через _beginthreadex, внутри процесса берут SRW-блокировку и условные переменные, одиночные переменные обновл...
Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции
В C++ гонка данных — это неопределённое поведение. Разбираем ловушку деструктора std::thread, остановку через jthread и stop_token, предо...
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- На каком слое следует ловить исключения?
- Принцип такой: на глубоких слоях широко не ловить, а сдвигать catch к границе, где можно определить единицу сбоя. Естественные точки приёма — границы обработки: одно действие на экране, один HTTP-запрос, одно задание, одно сообщение. Ловить нужно не там, где это технически возможно, а там, где можно ответственно решить: делать retry, превратить сбой в результат, продолжать ли работу. В глубоких helper и utility ограничиваются очисткой в finally, локальным откатом и трансляцией исключения.
- Нужно ли логировать исключение на каждом слое?
- На один сбой основной лог уровня Error / Critical пишут один раз. Если Repository пишет Error, Service пишет Error по тому же исключению, а Controller делает это ещё раз, на один инцидент подряд выстраивается несколько одинаковых стеков, и читать такой журнал тяжело. Нижние слои ограничиваются трансляцией и добавлением контекста; основной лог пишут на верхней границе, где уже есть операционный контекст вроде requestId и userId. Ответственность за запись сбоя несёт только тот слой, который поглотил исключение и превратил его в результат.
- Как отличить ожидаемый сбой от непредвиденного исключения?
- Ожидаемый сбой можно заранее заложить в проект: ошибки валидации и NotFound превращают в результат на уровне use case и обычно не пишут как Error каждый раз. OperationCanceledException из-за отмены пользователем тоже, как правило, не считают Error. А вот нарушение инварианта вроде NullReferenceException на границе request / job фиксируют основным логом и отвечают отказом, а AccessViolationException или тяжёлый OutOfMemoryException обрабатывают как Critical и скорее завершают процесс. Уже это разделение заметно снижает риск, что по-настоящему опасный сбой останется незамеченным.
- Что должен делать обработчик необработанных исключений?
- AppDomain.UnhandledException, DispatcherUnhandledException в WPF, ThreadException в WinForms — это не точка восстановления, а последняя точка записи. Главные задачи здесь — финальный лог, flush, путь снятия дампа, код завершения и сценарий перезапуска. К этому моменту состояние уже могло быть повреждено, поэтому даже если внешне можно продолжить, это ещё не значит, что продолжать безопасно. Стратегию восстановления лучше ставить раньше — на границе предыдущего request или job.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.