Где в обработке исключений ставить 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 сбоя. Так ожидаемый сбой вызывающий код может разобрать обычным ветвлением

Содержание

  1. Сначала выводы
  2. catch, логирование и обработка ошибок — разные вещи
    • 2.1. Перехват (catch)
    • 2.2. Логирование
    • 2.3. Обработка ошибок
    • 2.4. Трансляция исключений
  3. Таблица, с которой стоит начать
  4. Что делать на каждом уровне цепочки вызовов
    • 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. Взгляд на одну цепочку вызовов
  5. Отделять ожидаемые сбои от непредвиденных исключений
  6. Где и сколько раз писать лог
  7. Типичные ошибки
  8. Чек-лист для ревью
  9. Краткая сводка
  10. Итог
  11. Справочные материалы
  12. Похожие статьи

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 25, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

1. Сначала выводы

  • Принцип: на глубоких слоях широко не ловить. catch сдвигают к границе, где можно определить единицу сбоя.
  • Логирование по умолчанию: один основной лог на один сбой. Если каждый слой продолжает писать одно и то же исключение как Error, читать журнал тяжело.
  • Ответственность самого глубокого слоя — очистка, локальный откат, трансляция исключения и, если нужно, ограниченный retry. Если исключение пробрасывается дальше, основной лог там обычно не пишут.
  • Границы обработки — одно действие на экране, один HTTP-запрос, одно задание, обработка одного сообщения — чаще всего оказываются самым естественным местом для основного лога.
  • Ожидаемые сбои превращают в результат в рамках соответствующего use case. Не обязательно пробрасывать всё до самого верха исключением.
  • AppDomain.UnhandledException, DispatcherUnhandledException в WPF, ThreadException в WinForms, обработчик исключений ASP.NET Core, финальная обработка на уровне хоста — это скорее последняя точка записи, чем точка восстановления.
  • OperationCanceledException из-за отмены пользователем или завершения работы обычно не считают Error.
  • Если сомневаетесь, проверяйте в таком порядке:
    1. Можно ли здесь действительно принять решение?
    2. Видна ли здесь единица сбоя?
    3. Можно ли здесь откатить состояние или собрать его заново?
    4. Если залогировать здесь, не залогирует ли то же исключение ещё и слой выше?

Иначе говоря, основа такая: ловить не там, где это возможно, а там, где можно ответственно принять решение.

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:

  • HttpRequestException
  • IOException
  • JsonException
  • исключения, специфичные для драйвера БД
  • исключения, специфичные для 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, путь завершения и перезапуска

Схематично это выглядит примерно так.

НетДаНетДаНетДаПроизошло исключениеМожно ли здесь решить: retry / превратить в результат / продолжать ли?По умолчанию не ловить, отдать вышеЭто граница слоёв?Только локальная очисткаПри необходимости транслировать в осмысленное исключениеВидны ли здесь единица сбоя и операционный контекст?Основной лог не писать, отдать вышеОдин раз написать основной лог и решить, каким будет ответПри необходимости: завершение / повторная инициализация / переход к следующей записи

У этой схемы два ключевых момента.

  1. Первая причина для catch — восстановление или очистка, а не логирование.
  2. Первая причина для лога — собранный операционный контекст, а не сам факт, что исключение нашли.

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 вендора
  • Исключения библиотек разбора и сериализаторов

На этом слое делают, по сути, четыре вещи.

  1. Ловить конкретные исключения Не широкий Exception, а конкретные, осмысленные типы.

  2. Транслировать в осмысленный сбой Чтобы верхним слоям не приходилось знать внутренние детали нижних напрямую.

  3. Если нужен локальный retry — делать его здесь Но условия строгие:
    • известно, что сбой временный;
    • операция идемпотентна;
    • заданы предел попыток и стратегия ожидания;
    • итоговое поведение при провале ясно. Только когда выполняются все четыре условия.
  4. Отбрасывать повреждённые соединения и хендлы Часто безопаснее «пересоздать соединение», чем «продолжать с тем же объектом».

Политика логирования на этом слое меньше плавает, если смотреть так:

  • при повторном 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 — это событие для уведомления и записи необработанных исключений. Закладывать в него слишком много логики восстановления опасно.
  • В DispatcherUnhandledException WPF есть путь установить Handled = true и внешне продолжить работу, но сначала нужно определить, возможно ли восстановление.
  • ThreadException в WinForms тоже может оставить приложение в неизвестном состоянии после обработки.
  • Middleware обработки исключений ASP.NET Core нужно размещать ближе к началу конвейера, чтобы оно могло перехватывать исключения из последующих этапов.
  • Необработанное исключение в BackgroundService, начиная с .NET 6, логируется и по умолчанию приводит к остановке хоста. Иногда безопаснее остановиться и положиться на стратегию перезапуска, чем глушить всё в родительском цикле.

Особенно в настольных приложениях существует путь «поймать необработанное исключение и продолжить работу». Но возможность продолжить и правильность продолжения — разные вещи.

4.6. Взгляд на одну цепочку вызовов

Рассмотрим, например, такой поток.

Граница UI / Controller / JobApplication Service / UseCaseDomain / бизнес-логикаRepository / Gateway / SDK wrapperDB / HTTP / File / Vendor SDK

Роли при этом распределяются примерно так.

Кнопка «Сохранить» → SaveOrderUseCasePaymentGateway → 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.

Базовых правил шесть.

  1. На один сбой — один основной лог Error / Critical
  2. Нижние слои при необходимости занимаются трансляцией и добавлением контекста
  3. Верхняя граница пишет основной лог с единицей сбоя и операционным контекстом
  4. Ответственность за запись поглощённого сбоя несёт только тот слой, который его поглотил
  5. Ожидаемые сбои не логируются как Error каждый раз
  6. 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 Финальная запись, путь завершения

Если сомневаетесь, достаточно этих пяти пунктов.

  1. На глубоких слоях широко не ловить
  2. Перехватывать на границах
  3. Основной лог — один раз
  4. Ответственность несёт слой, поглотивший исключение
  5. Последнее необработанное исключение — это запись и путь завершения

10. Итог

Обработка исключений — это не история про «раз перехватить можно везде, значит и перехватываем везде».

Порядок проверки в целом такой, и его достаточно.

  1. Можно ли действительно принять решение в этом месте?
  2. Известна ли здесь единица сбоя?
  3. Можно ли здесь откатить или заново собрать состояние?
  4. Не приведёт ли логирование здесь к дублированию?
  5. Это точка восстановления или последняя точка записи?

При такой последовательности проверки цепочку вызовов становится заметно легче упорядочить.

Особенно важны три вещи.

  • Глубокие слои — в основном трансляция и очистка
  • Границы — в основном решения и основной лог
  • Финальный обработчик необработанных исключений — в основном запись и путь завершения

Иначе говоря, базовое правило таково: исключения принимают на границе, снабжают контекстом и обрабатывают только там, где возможно восстановление.

Как только это определено, и код-ревью, и расследование инцидентов становятся заметно более предсказуемыми.

11. Справочные материалы

12. Похожие статьи

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

На каком слое следует ловить исключения?
Принцип такой: на глубоких слоях широко не ловить, а сдвигать 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог