Введение


В рамках предыдущих статей мы описали: область применения, методологические основы, пример архитектуры и структуры. В данной статье, я хотел бы рассказать как описывать процессы, о принципах сбора требований, чем отличаются бизнес требования от функциональных, как перейти от требований — к коду. Рассказать о принципах применения Вариантов Использования (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. Будем рады вашим вопросам и обратной связи.




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


  1. oxidmod
    04.06.2019 17:20

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

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


  1. g6uru Автор
    04.06.2019 17:32

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


  1. ApeCoder
    05.06.2019 08:41

    Для меня это противоположность 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

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


    Вернее


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


    1. g6uru Автор
      05.06.2019 10:06

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


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


        racer.press car.pedals.gas

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


      1. ApeCoder
        05.06.2019 13:52

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

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


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

        Interface segregation principle?


        1. g6uru Автор
          05.06.2019 14:19

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


          Да, 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