Главная » Хабрахабр » Как правильно работать с исключениями в DDD

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

image

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

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

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

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

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

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

NET MVC+WebAPI. Речь пойдет об enterprise-приложении, построенном на базе ASP. Используется структурированное логирование в 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-ом или ручным откатом данных. Например, используемый в проекте брокер сообщений бросает исключения при попытке отправить сообщение, когда брокер недоступен.

А слой Infrastructure реализует данный порт, преобразуя исключение от брокера в Result. В таком случае слой Application Services декларирует контракт, возвращающий 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

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

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

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

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

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

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

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

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

Domain core

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Напишите о нас в своей газете: как IT-компании используют Pressfeed

Во-первых, это запросы, связанные с IT-тематикой, к примеру, запросы от площадки Tproger, пишущей для разработчиков. Для себя мы определили несколько интересных нам форматов запросов на Pressfeed. Такие запросы идут от отраслевых HR-сайтов: Officemaps.ru, HR-tv, Rjob.ru. Во-вторых, мы отвечаем на запросы, ...

Dell выходит на биржу и берет курс на гибридное облако

В одном из наших материалов мы говорили, что Dell планирует стать публичной компанией и впервые за пять лет разместить акции на бирже. В начале этой недели стала известна дата, когда ценные бумаги Dell выйдут в оборот, — 28 декабря. При ...