Как правильно работать с исключениями в DDD +31


image

В рамках недавно прошедшей конференции DotNext 2018 состоялся BoF по Domain Driven Design. На нем был затронут вопрос работы с исключениями, который вызвал жаркий спор, но не получил развернутой дискуссии, поскольку не являлся основной темой.

Также, изучая множество ресурсов, начиная от вопросов на stackoverflow и заканчивая платными курсами по архитектуре, можно наблюдать, что в IT-сообществе сложилось неоднозначное отношение к исключениям и к тому, как их использовать.

Наиболее часто упоминается, что с использованием исключений легко построить поток исполнения, имеющий семантику оператора goto, что плохо сказывается на читабельности кода.

Есть разные мнения о том, стоит ли создавать собственные типы исключений или использовать стандартные, поставляемые в .NET.

Кто-то делает валидацию на исключениях, а кто-то повсеместно использует монаду Result. Справедливо, что Result позволяет по сигнатуре метода понять, возможно ли не только успешное выполнение. Но не менее справедливо, что в императивных языках (к которым относится C#) повсеместное использование Result приводит к плохо читаемому коду, засыпанному конструкциями языка настолько, что с трудом можно разглядеть исходный сценарий.

В данной статье я расскажу о практиках, принятых в нашей команде (если кратко — мы используем все подходы и ни один из них не является догмой).

Речь пойдет об enterprise-приложении, построенном на базе ASP.NET MVC+WebAPI. Приложение построено по луковой архитектуре, общается с базой данных и брокером сообщений. Используется структурированное логирование в ELK-стек и настроен мониторинг при помощи Grafana.

На работу с исключениями мы посмотрим с трех ракурсов:

  1. Общие правила работы с исключениями
  2. Исключения, ошибки и луковая архитектура
  3. Частные случаи для Web-приложений

Общие правила работы с исключениями


  1. Исключения и ошибки — не одно и то же. Для исключений используем exceptions, для ошибок — Result.
  2. Исключения только для исключительных ситуаций, которых по определению не может быть много. Значит и исключений чем меньше — тем лучше.
  3. Обработка исключений должна быть максимально гранулированной. Как писал еще Рихтер в своем монументальном труде.
  4. Если ошибка должна быть доставлена пользователю в исходном виде — используем Result.
  5. Исключение не должно покидать границы системы в исходном виде. Это не user friendly и дает злоумышленнику способ дополнительно изучить возможные слабые места системы.
  6. Если брошенное исключение обрабатывается нашим же приложением — используем не exception, а Result. Реализация на исключениях будет скрытым оператором goto и будет тем хуже, чем дальше код обработки от кода выброса исключения. Result же явно декларирует возможность ошибки и допускает только “линейную” ее обработку.

Исключения, ошибки и луковая архитектура


В последующих разделах рассмотрим ответственности и правила выброса/обработки исключений/ошибок для следующих слоев:

  • Application Hosts
  • Infrastructure
  • Application Services
  • Domain core

Application Host


За что отвечает

  • Composition root, настраивающий работу всего приложения.
  • Граница взаимодействия с внешним миром — пользователи, другие сервиса, запуск по расписанию.

Поскольку это достаточно сложные ответственности, стоит ими и ограничиться. Остальные ответственности отдаем внутренним слоям.

Как обрабатывает ошибки из Result

Транслирует во внешний мир, преобразуя в соответствующий формат (например в http response).

Как генерирует Result

Никак. Данный слой не содержит логики, значит и ошибки генерировать негде.

Как обрабатывает исключения

  1. Скрывает детали и преобразует в формат, пригодный для отправки во внешний мир
  2. Логирует.

Как выбрасывает исключения

Никак, данный слой самый внешний и не содержит логики — ему некому отдать исключение.

Infrastructure


За что отвечает

  1. Адаптеры к портам, или попросту реализации Domain-интерфейсов, дающие доступ к инфраструктуре — сторонним сервисам, базам данных, active directory и пр. Данный слой должен быть по возможности “глупым” и содержать как можно меньше логики.
  2. При необходимости может выступать как Anti-corruption layer.

Как обрабатывает ошибки из Result

Мне неизвестны провайдеры к базам данных и прочим сервисам, работающие на монаде Result. Однако, некоторые сервисы работают на кодах возврата. В таком случае преобразуем их в формат Result, требуемый портом.

Как генерирует Result

В общем случае данный слой не содержит логики, значит, и ошибки не генерирует. Но в случае использования в качестве anti corruption layer возможны самые разные варианты. Например, разбор исключений от legacy-сервиса и преобразование в Result тех исключений, которые являются простыми сообщениями валидации.

Как обрабатывает исключения

В общем случае выбрасывает дальше, при необходимости залогировав детали. Если реализуемый порт допускает в контракте возврат Result, то инфраструктура преобразует в Result те типы исключений, которые могут быть обработаны.

Например, используемый в проекте брокер сообщений бросает исключения при попытке отправить сообщение, когда брокер недоступен. Слой Application Services готов к такой ситуации и в состоянии обработать ее политикой Retry, Circuit Breaker-ом или ручным откатом данных.

В таком случае слой Application Services декларирует контракт, возвращающий Result в случае ошибки. А слой Infrastructure реализует данный порт, преобразуя исключение от брокера в Result. Естественно, преобразует только конкретные типы исключений, а не все подряд.

Используя такой подход, мы получаем два преимущества:

  1. Явно декларируем возможность ошибки в контракте.
  2. Избавляемся от ситуации, когда Application Service знает, как обработать ошибку, но не знает тип исключения, поскольку абстрагирован от конкретного брокера сообщений. При этом строить блок catch на базовый System.Exception означает захватить все типы исключений, а не только те, с которыми может справиться Application Service.

Как выбрасывает исключения

Зависит от специфики системы.

Например, LINQ-операторы Single и First при запросе несуществующих данных выбрасывают исключение InvalidOperationException. Но этот тип исключения используется в .NET повсеместно, что лишает возможности выполнять его обработку гранулированно.

Мы в команде приняли практику создавать кастомный ItemNotFoundException и выбрасывать со слоя инфраструктуры его, если запрошенные данные не найдены и так не должно быть по правилам бизнеса.

Если же запрошенные данные не найдены и это допустимо — это стоит явно декларировать в контракте порта. Например, с использованием монады Maybe.

Application Services


За что отвечает

  1. Валидация входных данных.
  2. Оркестрация и координация сервисов — старт и завершение транзакций, реализация распределенных сценариев и т.д.
  3. Загрузка domain-объектов и внешних данных через порты к Infrastructure, последующий вызов команд в Domain Core.

Как обрабатывает ошибки из Result

Ошибки от domain core транслирует во внешний мир без изменений. Ошибки от Infrastructure может обрабатывать посредством политик Retry, Circuit Breaker или транслировать наружу.

Как генерирует Result

Может реализовать валидацию в виде Result.

Может генерировать уведомления о частичном успехе выполнения операции. Например, сообщения пользователю вида “Ваш заказ успешно размещен, но при верификации адреса доставки возникла ошибка. В ближайшее время с вами свяжется специалист для уточнения деталей доставки.”

Как обрабатывает исключения

Предполагая, что исключения инфраструктуры, которые приложение в состоянии обработать, уже преобразованы слоем Infrastructure в Result — никак не обрабатывает.

Как выбрасывает исключения

В общем случае — никак. Но есть пограничные варианты, описанные в финальном разделе статьи.

Domain core


За что отвечает

Реализация бизнес-логики, “ядро” системы и основной смысл ее существования.

Как обрабатывает ошибки из Result

Поскольку слой внутренний и ошибки возможны только от объектов в том же домене, обработка сводится либо к бизнес-правилам, либо к трансляции ошибки наверх в исходном виде.

Как генерирует Result

При нарушении бизнес-правил, которые инкапсулированы в Domain Core и не покрываются валидацией входных данных на уровне Application Services. Вообще в данном слое Result используется наиболее часто.

Как обрабатывает исключения

Никак. Исключения из инфраструктуры уже обработаны слоем Infrastructure, данные уже пришли структурированные, полные и проверенные благодаря слою Application Services. Соответственно, все исключения, которые могут вылететь, будут действительно исключениями.

Как выбрасывает исключения

Обычно здесь работает общее правило: чем меньше исключений — тем лучше.

Но бывали ли у вас ситуации, когда вы пишете код и понимаете, что в определенных условиях он может натворить страшных делов? Например, дважды списать деньги или настолько испортить данные, что потом костей не соберем.

Как правило, речь идет о выполнении команд, недопустимых для текущего состояния объекта.

Конечно, соответствующая кнопка на UI не должна быть видна в данном состоянии. Мы не должны получить команду из шины в данном состоянии. Все это верно при условии, что внешние слои и системы выполнили свою функцию нормально. Но в Domain Core мы не должны знать о существовании внешних слоев и верить в корректность их работы, мы должны защищать инварианты системы.

Какие-то из проверок можно разместить в Application Services на уровне валидации. Но это может превратиться в defensive programming, который в крайних проявлениях приводит к следующему:

  1. Ослабляется инкапсуляция, поскольку определенные инварианты должны быть проверены на внешнем слое.
  2. В наружный слой “протекают” знания о предметной области, проверки могут дублироваться обоими слоями.
  3. Проверка допустимости выполнения команды из внешнего слоя может быть более сложной и менее надежной, чем проверка доменным объектом невозможности выполнить команду в текущем состоянии.

Также, если мы размещаем подобные проверки в слое валидации, то мы должны сообщить пользователю причину ошибки. Учитывая, что речь про операцию, которую вообще нельзя выполнить в текущих условиях, мы рискуем оказаться в одной из двух ситуаций ситуации:

  • Мы выдали рядовому пользователю сообщение, которое он вообще не понял и все равно пойдет в саппорт, как и при сообщении «Произошла непредвиденная ошибка».
  • Мы в достаточно внятной форме сообщили злодею, почему он не может выполнить операцию, которую он выполнить хочет и он может поискать другие обходные пути.

Но вернемся к основной теме статьи. По всем признакам, обсуждаемая ситуация является исключительной. Она никогда не должна произойти, но если произойдет, то будет плохо.

Наиболее логично в данной ситуации выбросить исключение, залогировать необходимые детали, вернуть пользователю ошибку общего вида “Операция невыполнима”, настроить мониторинг на данный тип ошибок и рассчитывать, что мы никогда их не увидим.

Какой тип или типы исключений использовать в данном случае? По логике, это должен быть отдельный тип исключения, чтобы мы смогли выделить его среди других и чтобы его случайно не зацепило обработками исключений из внешнего слоя. Иерархия или множество исключений нам тоже не нужны, суть одна — произошло нечто недопустимое. Мы в своих проектах создаем для этого тип CorruptedInvariantException, и используем его в соответствующих ситуациях.

Частные случаи для Web-приложений


Существенным отличием web-приложений от других (desktop, демоны и windows сервиса и т.д.) является взаимодействие с внешним миром в форме краткосрочных операций (обработки HTTP-запросов), по выполнении которых приложение тут же “забывает” о произошедшем.

Также после завершения обработки запроса всегда формируется ответ. Если выполняемая нашим кодом операция не возвращает данные, платформа все равно вернет response, содержащий status code. Если операция была прервана исключением, то платформа все равно вернет ответ, содержащий соответствующий status code.

Чтобы реализовать подобное поведение, обработка запросов в Web-платформах построена в виде конвейеров (pipe). Сначала выполняется последовательная обработка запроса (request), а затем подготовка ответа (response).

Мы можем использовать middleware, action filter, http handler или ISAPI filter (в зависимости от платформы) и встроиться в этот конвейер на любом этапе. И на любом этапе обработки запроса мы можем прервать обработку и конвейер перейдет к формированию ответа.

Бизнес-часть приложения мы, как правило, реализуем уже не в архитектуре конвейера, а пишем код, выполняющий операции последовательно. И при таком подходе несколько сложнее реализовать сценарий, когда мы обрываем исполнение запроса и сразу переходим к формированию ответа.

Какое все это имеет отношение к работе с исключениями, спросите вы?

Дело в том, что описанные в предыдущих частях статьи правила работы с исключениями плохо вписываются в данный сценарий.

Исключения использовать плохо, потому что это семантика goto.

Повсеместное использовании Result приводит к тому, что мы таскаем его (Result) по всем слоям приложения, а при формирования ответа нужно как-то Result разобрать, чтобы понять, какой вернуть status code. Еще этот код разбора желательно обобщить и затолкать в Middleware или ActionFilter, что становится отдельным приключением. То есть Result мало чем лучше исключений.

Что делать в такой ситуации?

Не возводить абсолют. Мы устанавливаем правила себе на благо, а не во вред.

Если требуется прервать операцию, потому что ее продолжение невозможно, то выброс исключения не будет иметь семантики goto. Мы направляем исполнение на выход, а не к другому блоку бизнес-кода.

Если причина прерывания важна, чтобы определить нужный status code, то можно использовать кастомные типы исключений.

Ранее мы упомянули два кастомных типа, которые используем: ItemNotFoundException (трансформируем в 404) и CorruptedInvariant (преобразуется в 500).

Если вы проверяете права пользователей, потому что они не ложатся на модель ролей или claim-ов, то допустимо создать кастомный ForbiddenException (Status code 403).

И, наконец, валидация. Мы все равно не можем ничего сделать, пока пользователь не модифицирует свой запрос, эта семантика описана кодом 422. Значит мы прерываем операцию и отправляем запрос прямиком на выход. Это также допустимо сделать, используя exception. Например, в библиотеке FluentValidation уже есть встроенный тип исключения, который передает на клиент все детали, необходимые, чтобы внятно отобразить пользователю, что не так с запросом.

На этом все. А как вы работаете с исключениями?




К сожалению, не доступен сервер mySQL