Сервис-классы это счастье. Перепили жирный метод на сервис-класс. Часики то тикают. Дал бог сервисник — даст и декомпозицию

Сервис классы это действительно охуенно

Давайте для начала определимся с понятиями. Я называю сервис-классом любую хуйню, у которой нет четкого стейта. Например, какойнибудь Struct(:name, :type, :content) это определенно не сервисник. А вот

module Renderer
  def self.call(something)
    RequestContext[:view_context].render(somethin)
  end
end

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

Начнем с простых примеров

class Sum
  attr_accessor :args

  def self.call(*args)
    new(*args).call # Тащемто абсолютно дефолтный API для сервисников
  end

  def initialize(*args)
    self.args = args
  end

  def call
    args.reduce(:+)
  end
end

Нахуя так делать? Ну в данном нахуй не надо, но пример в целом хороший. Давайте представим, что кто то умный написал DecimalSum.call("123", %w[456]).

class DecimalSum
  attr_accessor :args

  def self.call(*args)
    new(*args).call
  end

  def initialize(*args)
    self.args = args.flat_map { |arg| normalize_arg(arg) }
  end

  def call
    args.reduce(:+)
  end

  private

  def normalize_arg(arg)
    case arg
    when Array
      arg.map { |x| normalize_arg(x) }
    when String
      Integer(arg.sub(/\A0+/, ''))
    else
      Integer(arg)
    end
  end
end

Теперь у нас есть два полноценных метода, объедененных единой логикой

Но нахуя их в сервисник то пихать?

Можно же просто запилить модуль и заинклюдить его вызвать .call на нем

module DecimalSum
  extend self

  def call(*args)
    args.flat_map { |arg| normalize_arg(arg) }.reduce(:+)
  end

  private

  def normalize_arg(arg)
    case arg
    when Array
      arg.map { |x| normalize_arg(x) }
    when String
      Integer(arg.sub(/\A0+/, ''))
    else
      Integer(arg)
    end
  end
end

Ну вообще можно, но тут в игру вступает наш главный козырь -

мемоизация

class DecimalSumAndProduct
  attr_accessor :args

  def self.call(*args)
    new(*args).call
  end

  def initialize(*args)
    self.args = args.flat_map { |arg| normalize_arg(arg) }
  end

  def call
    [sum, product]
  end

  def sum
    args.reduce(:+)
  end

  def product
    args.reduce(:*)
  end

  private

  def normalize_arg(arg)
    case arg
    when Array
      arg.map { |x| normalize_arg(x) }
    when String
      Integer(arg.sub(/\A0+/, ''))
    else
      Integer(arg)
    end
  end
end

Вот сейчас это уже точно удобнее в виде класса, а не модуля. Тут правда возникает уже другой вопрос: почему sum, product = DecimalSumAndProduct.call(params), а не sum_and_product = DecimalSumAndProduct.new(params); sum_and_prod.sum. Ответ очень прост: это учебный пример, который не ставил своей целью вернуть два результата.

А вот этот уже ставит

class DecimalSumAndProduct
  SumAndPRoduct = Struct.new(:sum, :product)

  attr_accessor :args

  def self.call(*args)
    new(*args).call
  end

  def initialize(*args)
    self.args = args.flat_map { |arg| normalize_arg(arg) }
  end

  def call
    SumAndPRoduct.new(sum, product)
  end

  private

  def sum
    args.reduce(:+)
  end

  def product
    args.reduce(:*)
  end

  def normalize_arg(arg)
    case arg
    when Array
      arg.map { |x| normalize_arg(x) }
    when String
      Integer(arg.sub(/\A0+/, ''))
    else
      Integer(arg)
    end
  end
end

Это один из самых охуенных API, который можно представить. Но не самый

Генерализация ошибок

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

  1. DecimalSumAndProduct#sum наебнулся при попытке сложить 2 и 2, будучи вызванным из метода #hui
  2. DecimalSumAndProduct#sum наебнулся при попытке сложить 2 и 2, во время вызова DecimalSumAndProduct.call из метода #pizda

Второй кажется гораздо более информативным в том случае, если [2, 2] это не особо валидное значение для @args и эксепшен надо бы бросить тому, кто придумал это в наш сервисник сувать. Потому что накосячил именно метод #pizda В сервисниках, косячит либо сервисник, либо тот кто передал ему параметры. Благоразумно выполнять всю работу при первом вызове, если ленивость не входит в изначальную задумку

Обобщайте ошибки

Все ошибки, вызванные херовыми параетрами / кривым стейтом / чем то другим, не зависящим от корректности работы модуля (в нашем случае, просто одинокого сервисника), принято заворачивать в его родные ошибки. Это просто полезно, если подходить к вопросу без фанатизма

class DecimalSumAndProduct
  SumAndPRoduct = Struct.new(:sum, :product)
  Error = Class.new(StandardError)

  attr_accessor :args

  def self.call(*args)
    new(*args).call
  end

  def initialize(*args)
    self.args = []
    self.invalid_args = []

    args.each { |arg| process_arg(arg) }
    raise Error, "Invalid args: #{invalid_args}" unless invalid_args.empty?
  end

  def call
    SumAndPRoduct.new(sum, product)
  end

  private

  def sum
    args.reduce(:+)
  end

  def product
    args.reduce(:*)
  end

  def process_arg(arg)
    case arg
    when Array
      arg.each { |x| process_arg(x) }
    when String
      args << Integer(arg.sub(/\A0+/, ''))
    else
      args << Integer(arg)
    end
  rescue ArgumentError => e
    invalid_args << e
  end
end

Технически, нормализацию переменных тоже уже можно вынести отдельно.

Так и сделаем

class DecimalSumAndProduct
  SumAndPRoduct = Struct.new(:sum, :product)
  Error = Class.new(StandardError)

  class Form
    attr_accessor :args, :invalid_args

    def initialize(*args)
      self.args = []
      self.invalid_args = []

      args.each { |arg| process_arg(arg) }
    end

    def valid?
      invalid_args.empty?
    end

    private

    def process_arg(arg)
      case arg
      when Array
        arg.each { |x| process_arg(x) }
      when String
        args << Integer(arg.sub(/\A0+/, ''))
      else
        args << Integer(arg)
      end
    rescue ArgumentError => e
      invalid_args << e
    end
  end

  attr_accessor :form

  def self.call(*args)
    new(*args).call
  end

  def initialize(*args)
    self.form = Form.new(*args)
    raise Error, "Invalid args: #{form.invalid_args}" unless form.valid?
  end

  def call
    SumAndPRoduct.new(sum, product)
  end

  private

  def sum
    form.args.reduce(:+)
  end

  def product
    form.args.reduce(:*)
  end
end

Очень выразительно, не правда ли?

Ну и наконец

Я покажу пример с использованием гема polist, который реализует все эти паттерны

class DecimalSumAndProduct < Polist::Service
  SumAndPRoduct = Struct.new(:sum, :product)

  class Form
    attr_accessor :args, :invalid_args

    def initialize(*args)
      self.args = []
      self.invalid_args = []

      args.each { |arg| process_arg(arg) }
    end

    def valid?
      invalid_args.empty?
    end

    private

    def process_arg(arg)
      case arg
      when Array
        arg.each { |x| process_arg(x) }
      when String
        args << Integer(arg.sub(/\A0+/, ''))
      else
        args << Integer(arg)
      end
    rescue ArgumentError => e
      invalid_args << e
    end
  end

  def initialize(*args)
    self.form = Form.new(*args)
    fail!(invalid_args: form.invalid_args) unless form.valid?
  end

  def call
    success!(SumAndPRoduct.new(sum, product))
  end

  private

  def sum
    form.args.reduce(:+)
  end

  def product
    form.args.reduce(:*)
  end
end

Наш API стал еще удобнее в использовании

Все это не только про руби

Берете любой другой языке и хуячите.

module DecimalSumAndPRoduct (
  SumAndPRoduct, sum, product
, Error(Error)
, call
) where

data SumAndProduct = SumAndProduct { sum :: Integer, product :: Integer }
data Error = Error [String]
data Form = Form { args :: [Integer], invalidArgs :: [String] }

instance Monoid Form where
  (Form a1 i1) `mconcat` (Form a2 i2) = Form (a1 `mconcat` a2) (i1 `mconcat` i2)

normalizeArgs :: [String] -> Form
normalizeArgs [] = mempty
normalizeArgs x:xs =
  let result = case (runExcept $ readExcept x) of Left _ -> Form ([], [x])
                                                  Right x -> Form ([x], [])
  in result `mconcat` normalizeArgs xs

validateForm :: Form -> Bool
validateForm = empty . invalidArgs

call :: [String] -> Except Error SumAndProduct
call args = do
  let form = normalizeArgs args
  unless (validateForm form) $ throwError Error $ invalidArgs form
  return $ SumAndProduct (sum' form) (product' form)

sum' :: Form -> Integer
sum' = foldl (+) 0 . args

product' :: Form -> Integer
product' = foldl (*) 1 . args