Pull to refresh

Comments 15

Что делать, если Domain Core используется и в веб-приложении, и в прочих?
Согласно первой части статьи, для ошибок бизнес-логики («нет средств для списания, например») нужно использовать Result, но если код подключается в web-сервер, то лучше бы он бросал исключения?
В случае бизнес-ошибок web-серверу тоже лучше работать с Result.

CorruptedInvariant и код 500, описанные в статье, это кейс для ситуаций, которые мы надеемся не увидеть вообще никогда. То есть как раз таки исключительные ситуации.

Для ошибки «нет средств для списания» больше походит Result, который в Web будет превращен в код 400 или 409, в зависимости от того, какой выглядит осмысленнее для клиента приложения.

Вообще, если мы принимаем правила статьи и 403, 404, 422 и 500 обрабатываем исключениями, то мы можем быть уверены на стороне Web, что Result это сугубо бизнесовая ошибка (и к этому и стоит стремиться, на мой взгляд) и его можно преобразовывать в 200 на случай Success и в 400 или 409 на случай Failure.

Я абсолютно согласен с вами касательно семантики исключений, goto и читабельности.
Раз уж вы решили использовать монады Result & Maybe, и вас при этом беспокоит невысокая читаемость конструкций работы с ними, в силу особенностей языка C#, у меня возникает вопрос: рассматривали вы для себя возможность перехода на F#, и если да, то почему воздержались?


Работать с монадами там гораздо проще, как и определить конструкцию Result (которая уже есть в стандартной библиотеке начиная с версии 4.1):


type Result<'Ok, 'Error> =
      | Ok of 'Ok
      | Error of 'Error

и благодаря Computation Expressions "зараженность" резалтами больше не выглядит страшно:


let createUser userDto =
    result {
         let! validatedDto = validate userDto //в случае Error возвращается ошибка, в случае Ok исполняется дальше
         let! userId = create validatedDto
         return userId
    }

то же самое с Maybe.


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


На эту тему есть отличная книга Скотта Влашина

Вопрос не в бровь, а в глаз.
Для F#, скажем так, зреем и для этого есть куда более весомый довод — упомянутый в статье проект это лишь front-часть, высунутая наружу. А дальше куча микросервисов, представляющих из себя машины состояний на Mass Transit.
И на императивном языке описывать машины состояний то еще удовольствие. Отлично ощущаешь почему в мире C# и Java так прижился XML :)
В обозримом будущем (2-3 месяца) планируем аккуратно попробовать F# и начать как раз с машин состояний. Если заведется — тоже будет статья на Хабр.
Хорошая статья. Пара комментариев/дополнений с моей стороны:

1. Очень хороший поинт насчет того, что с конвейерами подход с резалтами не работает — слишком много нагромождений (at least in C#), и это явно не то как авторы asp.net видели для себя обработку ошибок из middleware. Поэтому сценарий с декораторами — исключение из общего правила. При этом единственное, я не встречал других сценариев где такая работа с exceptions была бы оправдана.

2. По поводу типов исключений. Я не думаю что стоит создавать более 1 кастомного исключения (если только это не special case scenario as in #1 above). В статье говорится о преоразовании ItemNotFoundException в 404. Это как раз классический случай, где надо преобразовывать отсутствие объекта (Single/First) в return value и работать дальше уже с ним.

К примеру, может быть use case когда запись по данному Id обязана находиться в базе и если ее нет — это является исключительной ситуацией. В других кейсах это может не быть исключительной ситуацией. И если всегда бросать ItemNotFound, то нельзя будет отличить исключительную ситуацию (500) от не-исключительной (404). Здесь немного более подробно на эту тему: enterprisecraftsmanship.com/2017/01/31/rest-api-response-codes-400-vs-500

Рекомендую всегда возвращать Maybe из репозиториев/гейтвеев и потом уже решать кидать исключение или возвращать 404.

3.
Но не менее справедливо, что в императивных языках (к которым относится C#) повсеместное использование Result приводит к плохо читаемому коду, засыпанному конструкциями языка настолько, что с трудом можно разглядеть исходный сценарий.
Код получается более verbose, да, но он при этом становится наоборот, более читаемым благодаря явной логике ветвений. Опять же, по аналогии с goto: можно переписать метод с кучей if-ов на использование goto и тогда код будет плоский, без indentations. Только читать его станет намного сложнее.

4. И еще один отличный поинт про Application Service, валидацию, и инкапсуляцию в доменный слой. Я обычно делегирую все (возможнные) проверки слою домена через паттерн Do/CanDo. Получается примерно так:

class DomainClass
{
  public Result CanTransfer()
  {
     return Result.Ok();
  }
  
  public void Transfer()
  {
    Guard.Require(CanTransfer().IsSuccess); // кидает исключение в случае false
    
    /* ... */
  }
}
Спасибо за развернутый ответ.

Это как раз классический случай, где надо преобразовывать отсутствие объекта (Single/First) в return value и работать дальше уже с ним.

Полагаю, данный сценарий можно однозначно разделить на два, и ваша статья про response codes помогла мне это понять, за что дополнительное спасибо.

Предполагая, что мы остаемся в контексте DDD и Web:

1. Требуется загрузить aggregate root по идентификатору, пришедшему в web-запросе. Если aggregate root не найден, то бизнес-логика вряд ли сможет делать какие-либо предположения кроме того, что пользователь прислал неверный идентификатор, что превращается в код 404. Если мы используем специфичный репозиторий, а не формируем запросы через IQueryable прямо из бизнес-логики, то данный сценарий можно обработать единожды на уровне Data Access вместо повторяющейся обработки во всех местах вызова из бизнес-логики.

2. После загрузки aggregate root «Заказ» мы обнаруживаем, что он находится в статусе «Оплачен», но данные об оплате при этом отсутствуют. Для работы с такими случаями идеально подходит Maybe и проверки результата уже на уровне бизнес-логики и дальнейшую трансформацию в код 500, если проверка не пройдена.

Код получается более verbose, да, но он при этом становится наоборот, более читаемым благодаря явной логике ветвений.

Абсолютно согласен, что логика становится более явной и Result является верным выбором при написании Domain-логики. Но verbosity может перевесить когда мы работаем над «глупыми» слоями, например при построении Anti Corruption Layer.

Данную ситуацию трудно пояснить на каком-то специфичном сценарии, поэтому приведу пример общеизвестный, но несколько утрированный — Entity Framework.

В определенных условиях DbContext выбрасывает исключение ObjectDisposedException. И выброс исключения это оптимальный вариант в данном случае.

Было бы хорошо, если бы методы First и Single возвращали Maybe вместо выброса InvalidOperationException.

Было бы хорошо, если бы SaveChanges возвращал Result вместо выброса EntityValidationException.

Но было бы плохо, если бы Result возвращался вместо выброса ObjectDisposedException, поскольку это бы засорило все контракты IQueryable и даже не могу представить, как бы в таком случае выглядел Lazy Loading. Такая загрузка контракта не стоит того, чтобы обрабатывать ошибку, которая при правильном использовании вообще никогда не произойдет.

Повторюсь, пример утрированный. Но попытки выстроить Anti Corruption Layer вокруг legacy систем могут ставить в ситуации, которые и в кошмарном сне не приснятся.

Я обычно делегирую все (возможнные) проверки слою домена через паттерн Do/CanDo.

Так просто и элегантно, но почему-то никогда не приходило в голову. Спасибо за совет :)

Про Do/CanDo в своё писал Фаулер.

Правильно ли я понял, вы делаете валидацию в нескольких местах (при получении запроса и при работе сервиса)? То, что может проверить доменный слой проверяет он, всё остальное на уровне Application.
Валидация в одном месте — application services layer. Но она либо делегируется доменным объектам (через вызов их CanDo методов), либо нет (к примеру проверка имейла юзера на уникальность идет напрямую к базе или к репозиторию).
Повсеместное использовании Result приводит к тому, что мы таскаем его (Result) по всем слоям приложения

интересен пример где и как
Привел пример в комментарии выше.
Sign up to leave a comment.