Поваренная книга разработчика: DDD-рецепты (5-я часть, Процессы)

    Введение


    В рамках предыдущих статей мы описали: область применения, методологические основы, пример архитектуры и структуры. В данной статье, я хотел бы рассказать как описывать процессы, о принципах сбора требований, чем отличаются бизнес требования от функциональных, как перейти от требований — к коду. Рассказать о принципах применения Вариантов Использования (Use Case) и как они нам могут помочь. Разобрать на примерах варианты реализации шаблонов проектирования Interactor и Service Layer.


    likeyourgrandmom


    Примеры приведенные в статье даны с использованием нашего решения LunaPark, оно поможет вам с первыми шагами в описанных подходах.


    Отделяем функциональные требования от бизнес требований.


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


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


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


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


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


    Тут можно выделить ряд характерных признаков, которые будут указывать, что это требования от бизнеса:


    • бизнес-требования всегда написаны с точки зрения клиента;
    • это широкие требования высокого уровня, но все же ориентированные на детали;
    • они не являются целями компании, но помогают компании достичь целей;
    • отвечают на вопросы «почему» и «что». Что хочет компания получить? И почему ей это нужно.

    Функциональные требования — это Действия, которые система должна выполнить, для реализации бизнес-требований. Таким образом, функциональные требования связаны с разрабатываемым решением или программным обеспечением. Сформулируем функциональные требования для вышеуказанного примера:


    • система должна отображать долготу и широту сотрудника через GPS/ГЛОНАСС;
    • система должна отображать позиции сотрудников на карте;
    • система должна позволять менеджерам отправлять уведомления своим подчиненным на местах.

    Выделим следующие особенности:


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

    Следует сказать пару слов о нефункциональных требованиях (также известных как «требования к качеству»), которые накладывают ограничения на дизайн или реализацию (например, требования к производительности, безопасности, доступности, надежности). Такие требования отвечают на вопрос «какой» должна быть система.


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


    Варианты использования (Use cases)


    Реализация функциональных требований является, зачастую, самой сложной в коммерческих системах. В чистой архитектуре функциональные требования реализуются через слой Use Case.


    Но для начала, я хочу обратится к первоисточнику. Ивар Якобсон — автор определения Use Case, один из авторов UML, и методологии RUP, в своей статье Use-Case 2.0 The Hub of Software Development выделяет 6 принципов применения Вариантов использования:


    1. сделайте их простыми через повествование;
    2. имейте стратегический план, осознайте картину целиком;
    3. сфокусируйтесь на значении;
    4. выстраивайте систему по слоям;
    5. поставляйте систему пошагово;
    6. удовлетворяйте потребности команды.

    Кратко рассмотрим каждый из этих принципов, нам они пригодятся для дальнейшего понимания. Ниже идет мой вольный перевод, с сокращениями и вставками, настоятельно рекомендую ознакомиться и с оригиналом.


    Простота через повествование


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


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


    Данный принцип коррелирует с партерном Общий язык (Ubiques language) из DDD подхода.


    Понимание картины в целом


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


    Ивар Якобсон предлагает задействовать диаграмму вариантов использования, что очень удобно для сбора требований. Если требования собраны и ясны, то лучшим вариантом будет Контекстная карта (Context map) Эрика Эванса. Зачастую, Scrum подход интерпретируют так, что люди не тратят время на стратегический план, считая планирование, дальше чем на две недели, пережитком прошлого. Пропаганда Джеффа Сазерленда обрушилась на waterflow, а люди закончившие двухнедельные курсы подготовки скрам-мастеров, допущенные к управлению проектами, сделали свое дело. Но здравый смысл, осознает важность стратегического планирования. Не нужно делать детальный стратегический план, но он должен быть.


    Фокус на значении


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


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


    Далее автор приводит замечательную схему, на которую следует обратить самое пристальное внимание:



    На схеме показан вариант использования, «Снятие наличных в банкомате». Самый простой способ достижения цели описывается в Основном Направлении (Basic flow). Другие случаи описываются как Альтернативные Направления (alternative flow). Эти направления помогают с повествованием, структурируют систему и помогают с написанием тестов.


    Послойное построение


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


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


    Эти идеи перекликаются с подходами гибкой разработки и с идеями Доменов (Domain).


    Пошаговый вывод продукта на рынок


    Большинство программных систем развиваются на протяжении многих поколений. Они не производятся за один раз; они построены в виде серии выпусков, каждое из которых построена на предыдущем выпуске. Даже сами релизы часто не выходят за раз, а развиваются через серию промежуточных версий. Каждый шаг предоставляет наглядную, пригодную для использования версию системы. Это тот способ, которым должны быть созданы все системы.


    Удовлетворять потребности команды


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


    Эрик Эванс в своей книге призывает не тратить много времени на описания всех процессов через UML. Достаточно использовать любые наглядные схемы. Разным командам, разным проектам требуется разная степень детализации, об это говорит и сам автор UML.


    Реализация


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


    These use cases orchestrate the flow of data to and from the entities, and direct those entities to use their Critical Business Rules to achieve the goals of the use case.

    Попробуем воплотить эти идеи в код. Давайте вспомним схему из третьего принципа применения Вариантов использования и возьмем ее за основу. Рассмотрим действительно сложный бизнес-процесс: «Приготовление пирога с капустой».


    Давайте попробуем его декомпозировать:


    • проверить наличие продуктов;
    • взять их со склада;
    • замесить тесто;
    • дать тесту подняться;
    • подготовить начинку;
    • сделать пирог;
    • испечь пирог.

    Всю эту последовательность мы реализуем через Интерактор (Interactor), а каждый шаг будет реализован через функцию или Функциональный объект (Functional Object) на Сервисном слое (Service Layer).


    Последовательность действий (Interactor)


    step-by-step


    Я очень рекомендую начинать разработку сложного бизнес-процесса именно с Последовательности действий. Точнее не так, вы должны определить Доменную область, к которой относится бизнес-процесс. Уточнить все требования бизнеса. Определить все Сущности, которые задействованы в процессе. Задокументировать требования и определения каждой Сущности в базе знаний.


    Расписать все на бумаге по шагам. Иногда потребуется Диаграмма последовательности (sequence diagram). Ее автор тот же, кто придумал Варианты использования (Use Case) — Ивар Якобсон. Диаграмма была придумана им, когда он разрабатывал систему обслуживания телефонных сетей для компании Эриксон, взяв за основу схему реле. Мне очень нравится эта диаграмма, и термин Sequence, на мой взгляд более выразителен, чем термин Interactor. Но ввиду большей распространенности последнего, будем использовать привычный термин — Interactor.


    Небольшая подсказка, когда вы описывайте бизнес-процесс хорошим подспорьем для вас, может стать, основное правило документооборота: «В результате любой хозяйственной деятельности, должен быть составлен документ». К примеру, мы разрабатываем систему скидок. Предоставляя скидку, мы по факту, с точки зрения бизнеса, заключаем договор между компанией и клиентом. В этом договоре должны быть прописаны все условия. То есть в домене DiscountSystem, у вас будет Entites::Contract. Не привязывайте скидку к клиенту, а создайте Сущность Контракт, где описываются правила ее предоставления.


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


    Шаблон проектирования Последовательности действий отвечает за:


    • последовательность выполнения Действий;
    • координацию передаваемых данных между Действиями;
    • обработку ошибок совершаемых Действиями во время их выполнения;
    • возвращение результата совокупности совершенных Действий;
    • ВАЖНО: самая главная ответственность этого шаблона проектирования — реализация бизнес логики.

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


    Вернемся к пирогу: попробуем описать процесс его приготовления через Interactor.


    Реализация


    Привожу пример реализации, с нашим решением LunaPark, которое мы представили в предыдущей статье.


    module Kitchen
      module Sequences
        class CookingPieWithСabbage < LunaPark::Interactors::Sequence
          TEMPERATURE = Values::Temperature.new(180, unit: :cel)
    
          def call!
            Services::CheckProductsAvailability.call   list: ingredients
            dough   = Services::BeatDough.call         from: Repository::Products.get(beat_ingredients)
            filler  = Services::MakeСabbageFiller.call from: Repository::Products.get(filler_ingredients)
            pie     = Services::MakePie.call           dough, with: filler
            bake    = Services::BakePie.new            pie,   temp: TEMPERATURE
            sleep 5.min until bake.call
            pie  
          end
    
          private
    
          attr_accessor :beat_ingredients, :filler_ingredients
          attr_accessor :pie
    
          def ingredients_list
            beat_ingredients_list + filler_ingredients_list
          end
        end
      end  
    end

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


    Также, мы легко можем описать процесс выпечки рыбного пирога, заменив MakeСabbageFiller на MakeFishFiller. Тем самым, мы очень быстро меняем бизнес-процесс, без существенных доработок кода. И также, мы можем оставить обе Последовательности одновременно, масштабируя бизнес-кейсы.


    Договоренности


    • Метод call! является обязательным методом, он описывает порядок Действий.
    • Каждый параметр инициализации может описываться через сеттер или attr_acessor:

    class Foo < LunaPark::Interactors::Sequence
      # ...
      private
      attr_accessor :bar 
    end
    
    Foo.call(bar: 42)

    • Остальные методы должны быть приватными.

    Пример использования


    beat_ingredients = [
      Entity::Product.new :flour,   500, :gr,
      Entity::Product.new :oil,     50,  :gr,
      Entity::Product.new :salt,    1,   :spoon,
      Entity::Product.new :milk,    150, :ml,
      Entity::Product.new :egg,     1,   :unit,
      Entity::Product.new :yeast,   1,   :spoon
    ]
    
    filler_ingredients = [
      Entity::Product.new :cabbage, 500, :gr,
      Entity::Product.new :salt,    1,   :spoon,
      Entity::Product.new :pepper,  1,   :spoon
    ]
    
    cooking = CookingPieWithСabbage.call(
      beat_ingredients:   beat_ingredients, 
      filler_ingredients: filler_ingredients
    )
    
    # В случае успеха:
    cooking.success?     # => true
    cooking.fail         # => false
    cooking.fail_message # => ''
    cooking.data         # => Entity::Pie
    
    # Если пирог сгорел:
    cooking.success?     # => false
    cooking.fail         # => true
    cooking.fail_message # => 'The pie burned out'
    cooking.data         # => nil

    Процесс представлен через объект и мы имеем все необходимые методы для его вызова — прошел ли вызов успешно, возникла ли какая-то ошибка в процессе вызова, и если произошла, то какая?


    Обработка ошибок


    Если сейчас вспомнить третий принцип применения Use Case, обратим внимание на то, что кроме Основного направления, у нас были еще и Альтернативные направления. Это ошибки, которые мы должны обработать. Рассмотрим пример: мы конечно не хотим чтобы события пошли подобным образом, но ничего не можем с этим поделать, суровая реальность такова, что пироги периодически сгорают.


    Interactor перехватывает все ошибки унаследованные от класса LunaPark::Errors::Processing.


    Как нам уследить за пирогом? Для этого определим ошибку Burned в Действие BakePie.


    module Kitchen
      module Errors
        class Burned < LunaPark::Errors::Processing; end
      end
    end

    И во время выпечки, проверим, что наш пирог не сгорел:


    module Kitchen
      module Services 
        class BakePie < LunaPark::Callable   
          def call
              # ...
              rescue Errors::Burned, 'The pie burned out' if pie.burned?
              # ...
          end    
        end
      end
    end

    В этом случае сработает перехватчик ошибок, и мы сможем разобраться с ними в Эндпоинтах.
    Ошибки, не унаследованные от Processing, воспринимаются как системные и будут перехвачены на уровне сервера. Если не обозначенные другие условия, то пользователь получит 500 ServerError.


    Практика использования


    1. Старайтесь описывать все вызовы в методе call!


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


    module Service
      class CookingPieWithСabbage < LunaPark::Interactors::Sequence
        def call!
          check_products_availability
          make_cabbage_filler
          make_pie
          bake
        end
    
        private  
    
        def check_products_availability
          Services::CheckProductsAvailability.call list: ingredients  
        end
    
        # ...
      end
    end

    Используйте вызов действий прямо в классе. Такой подход с точки зрения ruby может показаться непривычным, так он выглядит более читабельным:


    class DrivingStart < LunaPark::Interactors::Sequence
      def call!
        Service::CheckEngine.call
        Service::StartUpTheIgnition.call car, with: key
        Service::ChangeGear.call         car.gear_box, to: :drive
        Service::StepOnTheGas.call       car.pedals[:right]
      end
    end

    2. По возможности используйте метод класса call


    # good - Обычно, экземпляр класса Действия, редко используется. 
    #        Логично использовать сокращенную запись.
    Sequence::RingingToPerson.call(params)
    
    # good - Тем не менее, есть возможность создавать экземпляр объекта Действиe,
    #        что может быть полезно, когда нам нужно переиспользовать его, 
    #        с учетом внутреннего состояния.
    ring = Sequence::RingingToPerson.new(person)
    
    unless ring.success?
      ring.call
      sleep 5.min
    end
    

    3. Не создавайте Функциональные объекты ради типизации кода, смотрите по ситуации


    # bad - мы решили делать всю логику в Функциональных объектах, чтобы 
    #       сделать более легкой Последовательность действий.
    
    module Services
      class BuildUser < LunaPark::Callable
        def initialize(first_name:, last_name:, phone:)
          @first_name = first_name
          @last_name = last_name
          @phone = phone
        end
    
        def call
          Entity::User.new(
            first_name: first_name, 
            last_name: last_name,
            phone: phone 
          )
        end
    
        private
        attr_reader :first_name, :last_name, :phone
      end
    end
    
    module Sequences
      class RegisteringUser < LunaPark::Interactors::Sequence
        attr_accessor :first_name, :last_name, :phone
    
        def call!
          user = Service::BuildUser.call(first_name: first_name, last_name: last_name, phone: phone)
        end
      end
    end
    
    # good - не следует писать отдельный класс, действуем практичнее. 
    #        Хотя при такой реализации следует задуматься о  тестировании, 
    #        возможно необходимо вынести метод в Сервисный слой.
    
    module Sequences
      class RegisteringUser < LunaPark::Interactors::Sequence
        attr_accessor :first_name, :last_name, :phone
    
        def call!
          user #...
        end
    
        private 
        def user
          @user = Entity::User.new(
            first_name: first_name, 
            last_name: last_name,
            phone: phone 
          )
        end
      end
    end

    Сервисный слой (Service Layer)


    lsd


    Порядок действий (Interactor), как мы говорили, описывает бизнес-логику на самом верхнем уровне. Сервисный слой (Service layer) уже раскрывает детали реализации функциональных требований. Если мы говорим о приготовлении пирога, то на уровне Порядка действий (Interactor) мы говорим просто "замешиваем тесто", не вдаваясь в детали как его замесить. Процесс замеса описывается на Сервисном уровне. Вернемся к первоисточнику, большой синей книге:


    В прикладной предметной области бывают такие операции, которым нельзя найти естественное место в объекте типа Сущности (Entity) или Объекта-Значения (Value object). Они по своей сути являются не предметами, а видами деятельности. Но поскольку в основе нашей парадигмы моделирования лежит объектный подход, мы попробуем превратить их в объекты.


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


    При выборе места реализации того или иного функционала, всегда пользуйтесь здравым смыслом. Ваша задача — сделать модель более выразительной. Разберем пример, "Нам нужно нарубить дрова" :


    module Entities
      class Wood
        def chop
          # ...
        end
      end
    end

    Такой метод будет являться ошибкой. Дрова сами себя не нарубят, нам потребуется топор:


    module Entities
      class Axe
        def chop(sacrifice)
          # ...
        end
      end
    end

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


    module Entities
      class Human
        def chop_firewood(wood, axe, chock)
          # ...
        end
      end
    end
    

    Как вы уже наверное догадались, это не самая удачная идея. Не все из нас занимаются рубкой дров, это не прямая обязанность человека. Мы часто видим насколько перегружены модели в Ruby on Rails, хранящие в себе подобную логику: получение скидки, добавление товара в корзину, снятие денег к балансу. Эта логика относится не к сущности, а к процессу в котором задействована эта сущность.


    module Services
      class ChopFirewood
        # ...
      end
    end

    После того, как мы разобрались, какую логику мы храним в Службах попробуем реализовать один из них. Чаще всего службы реализуются через методы или функциональные объекты.


    Функциональные объекты


    Функциональный объект выполняет одно функциональное требование. В самом примитивном виде функциональный объект имеет один единственный публичный метод — call.


    module Serivices
      class Sum
        def initialize(x, y)
          @x = x
          @y = y
        end
    
        def call
          x + y
        end  
    
        def self.call(x,y)
          new(x,y).call
        end
    
        private
        attr_reader :x, :y
      end
    end

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


    • Сервисный объект (Service) — объект, создает новый объект;
    • Команда (Command) — изменяет текущий объект;
    • Вахтер (Guard) — возвращает ошибку если, что-то пошло не так.

    Сервисный Объект (Service)


    В нашей реализации Service — реализует функциональное требование и всегда возвращает значение.


    module KorovaMilkBar
      module Services
        class FindMilk < LunaPark::Callable
          GLASS_SIZE = Values::Unit.wrap '200g'
    
          def initialize(fridge:)
            @fridge = fridge
            end
    
          def call
              fridge.shelfs.find { |shelf| shelf.has?(GLASS_SIZE, of: :milk) }
            end
    
            private
            attr_reader :fridge
        end
      end
    end 
    
    FindMilk.call(fridge: the_red_one) # => #<Glass: ... >

    Команда (Command)


    В нашей реализации Command — выполняет одно Действие, изменяет объект, в случае успеха возвращает true. По факту, Команда не создает объект, а изменяет существующий.


    module KorovaMilkBar
      module Commands
        class FillGlass < LunaPark::Callable    
          def initialize(glass, with:)
            @glass   = glass
            @content = with
          end
    
          def call
            glass << content
            true
          end
    
          private
          attr_reader :fridge
        end
      end
    end 
    
    glass  = Glass.empty
    milk   = Milk.new(200, :gr)
    glass.empty?                     # => true
    FillGlass.call glass, with: milk # => true
    glass.empty?                     # => false

    Вахтер (Guard)


    Вахтер, выполняет логическую проверку и в случае провала выдает ошибку обработки. Такой тип объекта никак не влияет на Основное направление, но переключает нас на Альтернативное направление, если что-то пошло не так.


    При подаче молока важно убедится, что оно свежее:


    module KorovaMilkBar
      module Guards
        class IsFresh < LunaPark::Callable  
          def initialize(product)
            @products = products
          end
    
          def call
            products.each do |product|
              raise Errors::Rotten, "#{product.title} is not fresh" if product.expiration_date > Date.today
            end
    
            nil
          end
    
          private
          attr_reader :products
        end
      end
    end 

    Возможно вам покажется удобным разделять функциональные объекты по типу. Вы можете добавить свои, например, Builder — создает объект на основе параметров.


    Договоренности


    • Метод call является единственным обязательным публичным методом.
    • Метод initialize является единственным опциональным публичным методом.
    • Остальные методы должны быть приватными.
    • Логические ошибки должны наследоваться от класса LunaPark::Errors::Processing .

    Обработка ошибок


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


    Ошибки процесса выполнения

    Такие ошибки могут возникать в результате нарушения логики обработки.


    Например:


    • при создании пользователя email зарезервирован;
    • при попытке выпить молоко, оно закончилось;
    • другой микросервис отклонил действие (по логической причине, а не потому, что сервис недоступен).

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


    Такие ошибки должны наследоваться от LunaPark::Errors::Processing


    Системный ошибки

    Ошибки, которые произошли в результате сбоя системы.


    Например:


    • не работает БД;
    • что-то поделилось на ноль.

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


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


    Практика использования


    1. Используйте переменные, чтобы повысить читаемость


    module Fishing
      # bad - не информативно
      Serivices::Catch.call(fish, rod)
    
      # bad - избыточно
      Serivices::Catch.call(fish: fish, rod: rod)
    
      # good - более  выразительно
      Serivices::Catch.call(fish, with: rod)
    
      module Serivices
            class Catch
          def initialize(fish, with:)
            @fish = fish
            @rod  = with # внутри класса мы используем переменную
                         # указывающую на объект.
          end
          # ...
          private
          attr_reader :fish, :rod
        end
      end 
    end   

    2. Передавайте объекты, а не параметры


    Старайтесь делать инициализатор простым, если обработка параметров не является его целью.
    Передавайте объекты, а не параметры.


    module Service
      # bad - на сервисном уровне мы работаем только с бизнес-логикой. Преобразование 
      #       типов следует вынести на уровень выше, например в форму.
      class Foo 
        def initialize(foo_params:, bar_params:)
          @foo = Values::Foo.new(*foo_params)
          @bar = Values::Bar.new(*bar_params)
        end
        # ...
      end
      Services::Foo.call(foo: {a: 1, b: 2}, bar: 34)
    
      # good - реализуем только бизнес-логику.
      class Bar 
        def initialize(foo:, bar:)
          @foo = foo
          @bar = bar  
          # ...
        end
      end
    
      foo = Values::Foo.new(a: 1, b: 2)
      bar = Values::Bar.new(34)
      Services::Bar.call(foo: foo, bar: bar)
    
      # good - логичным исключением является реализация шаблона проектирования - Builder.
      class BuildFoo 
        def initialize(param_1:, param_2:)
          @param_1 = param_1
          @param_1 = param_1
        end
    
        def call
          Foo.new(
            param_1: param_1.foo,
            param_2: param_2.bar,
            param_3: some_magick
          ) 
        end
    
        # ...
      end
    end

    3. Используйте в название Действия — глагол действия и объект воздействия.


    # bad
    module Services
      class Milk; end 
      class Work; end 
      class FooBuild; end
      class PasswordGenerator; end
    end
    
    # good
    module Services
      class GetMilk; end 
      class WorkOnTable; end 
      class BuildFoo; end
      class GeneratePassword; end
    end

    4. По возможности используйте метод класса call


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


    # good - Логично использовать сокращенную запись.
    Services::BuildFoo.call(params)
    
    # good - Или еще более сокращенную
    Services::BuildFoo.(params)
    
    # good - Тем не менее, есть возможность создавать экземпляр объекта Действия, 
    #        что может быть полезно, когда нам нужно переиспользовать его, с учетом 
    #        внутреннего состояния.
    
    ring = Services::RingToPhone.new(phone: neighbour)
    10.times do
      ring.call
    end

    5. Обработка ошибок не является задачей сервиса


    # bad - оставьте обработку ошибку интеракторам, а сервис легким.
    def call
    
      #...
    rescue SystemError => e
      return false
    end

    Модули


    До этого момента мы рассматривали имплементацию Сервисного слоя как набора функциональных объектов. Но мы легко можем разместить на этом слое методы:


    module Services
      def sum(a, b)
        a + b
      end
    end

    Другая проблема, которая встает перед нами — большое количество сервисных объектов. Вместо, набивших оскомину «rails fat model», мы получим «services fat folder». Есть несколько способов организовать структуру, чтобы уменьшить масштаб трагедии. Эрик Эванс решает это за счет того, что объединяет ряд функций в один класс. Представим, что нам нужно смоделировать бизнес-процессы няни, Арины Родионовны, она может кормить Пушкина и укладывать его спать:


    class NoonService
      def initialize(arina_radionovna, pushkin)
        # ...
      end
    
      def to_feed
        # ...
      end
    
      def to_sleep
        # ...
      end
    end

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


    module Services
      module Noon
        class ToFeed
          def call!
            # ...
          end
        end
    
        class << self
          # При этом сложные процессы, можно вынести 
          # в функциональные объекты
          def to_feed(arina_radionovna, pushkin)
            ToFeed.new(arina_radionovna, pushkin).call
          end
    
          # А простые оставить методами, реализованными в модуле
          def to_sleep(arina_radionovna, pushkin)
            arina_radionovna.tell_story pushkin
            pushkin.state = :sleep
          end
        end
      end
    end

    При делении на модули должна соблюдаться низкая внешняя зависимость (low coupling) при высокой внутренней связности (high cohesion), мы же используем такие модули как Services, или Interactors, это также идет в разрез с идеями чистой архитектуры. Это осознанный выбор, который облегчает восприятие. По имени файла мы понимаем какой шаблон проектирования реализует тот или иной класс, если для опытного программиста это очевидно, то для новичка это не всегда так. После того как ваша команда будет готова, откажитесь от этого излишества.


    Процитирую еще небольшой отрывок из большой синей книги:


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


    Дайте модулям такие имена, которые войдут в ЕДИНЫЙ ЯЗЫК. Как сами МОДУЛИ, так и их имена должны отражать знание и понимание предметной области.


    Тема модулей большая и интересная, но в полном объеме явно выходит за тему данной статьи. В следующий раз мы поговорим с вами о Репозиториях и Адаптерах. Мы открыли уютный телеграмм канал, где хотелось бы делиться материалами на тему DDD. Будем рады вашим вопросам и обратной связи.




    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      +2
      Не следует реализовывать каждое Действие отдельным методом, это делает код более раздутым.

      Справедливо, если действия состоят из одной строки. Иначе отдельные методы с хорошими названиями позволяют более легко читать основной метод
        0

        Тут важно достичь выразительности, чтобы читая код был понятен процесс, который он выражает. Степень выразительности лучше определить внутри команды. Но я бы тоже вынес многострочку в отдельный метод -) Возможно в Сервисный слой.

          +1

          Для меня это противоположность Ubiquotous Language в DDD
          class DrivingStart < LunaPark::Interactors::Sequence
          def call!
          Service::CheckEngine.call
          Service::StartUpTheIgnition.call car, with: key
          Service::ChangeGear.call car.gear_box, to: :drive
          Service::StepOnTheGas.call car.pedals[:right]
          end
          end


          В моей мысленной картине мира двигатель часть машины и никаких сервисов нет и я думаю как-то так:


          car.engine.check
          car.ignintion.startUp with:key
          car.gear.change to: :drive
          car.pedals.gas.press

          Вопрос — зачем эти прослойки?


          Вернее


          сервис ответов на вопросы:: задать вопрос.сказать
          сервис ответов на вопросы:: зачем.сказать
          сервис ответов на вопросы:: эти.сказать
          сервис ответов на вопросы:: прослойки.сказать

            0

            Services:: и Interactors:: — это лишняя абстракция, опытным разработчикам она не нужна. Я использую ее, здесь, в статьях и мы используем в части проектов, для наглядности, чтобы показать к какому шаблону проектирования относится CheckEngine. Об этом я упомянул в последней части статьи.
            .
            pedals.gas.press — это один из возможных вариантов если он удовлетворяет вашу команду, используйте его. Мне, лично, не нравится в этом подходе, что метод относится к субъекту, а не к объекту. Дрова — нарубитесь, хлеб нарежься, масло намажься.


            Если у нас подходящий домен, можно сделать:


              racer.press car.pedals.gas

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

              0
              что метод относится к субъекту, а не к объекту.

              Субъект — это тот, кто делает, объект — это тот над чем делают. Так как pedals это то, чем делают и объект на на нем, то метод относится у меня к объекту, а у вас к субъекту.


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

              Interface segregation principle?

                0

                Да, извиняюсь, перепутал значения терминов субъект и объект.


                Да, Interface segregation principle, была статья как-раз неплохая на эту тему
                https://medium.com/roonyx/solid-ruby-ad046727ec26


                И там как раз пример приводится:


                class Driver
                  def drive
                    @car.open
                    @car.start_engine
                  end
                end

                Но если бы у нас была сущность Human, то я бы не стал вносить метод drive в нее:


                
                module Entities
                  class Human
                    # ...
                  end
                end
                
                module Services
                  #не все мы водим машины, и лучше это вынести в отдельный сервис
                  module HandlingVehicle
                     def self.drive(racer, car)
                       # ...
                     end
                  end
                end

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

          Самое читаемое