Skip to content

Instantly share code, notes, and snippets.

@davydovanton
Last active June 25, 2020 09:31
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save davydovanton/ec99546c3eb512599b65e5b27fe78bb0 to your computer and use it in GitHub Desktop.
Save davydovanton/ec99546c3eb512599b65e5b27fe78bb0 to your computer and use it in GitHub Desktop.
Operations

Пример из pepegramming канала

Задача

Есть экшен в рельсе, который создает инвойс:

def create
  invoice = Invoice.new(params[:invoice])

  if invoice.save
    bank_response = Bank::Client.new.register(invoice.id, invoice.paid)

    if bank_response.succeed?
      invoice.attach_payment! bank_response.order_id
      render json: bank_response
    end
  end

  render json: { failed: 'failed' }, status: :internal_server_error
end

Какие проблемы в этом коде. Во первых, бизнес логика в экшене. Во вторых, тут 4 разных действия в одном месте:

  1. Валидация данных
  2. Сохранение инвойса в БД
  3. Вызов банковского клиента и если все хорошо - обнавление инвойса
  4. Отдача ответа в json

Что делать и как упростить нам жизнь с таким кодом. Можно сделать 3 интерактора и как-то их склеить. Можно вынести все в один интерактор, но тогда возникнет проблема с тем, как все это покрыть юнит тестированием. Сегодня я предлагаю воспользоваться бизнес транзакциями. Для этого сделаем оперейшен класс:

# in app/operations/invoice/create.rb

require 'dry/transaction'

module Operations::Invoice
  class Create
    include Dry::Transaction
    include Dry::Monads::List::Mixin
    
    step :validate
    try :persist
    step :attach_payment
  end
end

Теперь у нас есть базовый класс с 3мя шагами. Валидацией, сохранением и шагом attach_payment.

Немного правил, используемых в dry-transaction:

  1. Каждый шаг обязательно должен возвращать Either монаду
  2. Каждый шаг принимает аргументы с последующего
  3. Шаг try нужен для того, что бы выполнить какой-то код. Если бросается ошибка, она обернется в Left монаду, а если нет - результат метода автоматически обернется в Right монаду.

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

require 'dry/transaction'

module Operations::Invoice
  class Create
    include Dry::Transaction
    include Dry::Monads::List::Mixin
    
    step :validate
    try :persist
    step :attach_payment
    
    def validate(payload)
      # ...
    end
    
    def persist(payload)
      Invoice.create(payload)
    end
    
    def attach_payment(invoice)
      bank_response = Bank::Client.new.register(invoice.id, invoice.paid)
      
      if bank_response.succeed?
        invoice.attach_payment! bank_response.order_id
        Right(bank_response)
      else
        Left(:error)
      end
    end
  end
end

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

require 'dry/transaction'

module Operations::Invoice
  class Create
    include Dry::Transaction
    include Dry::Monads::List::Mixin
    
    VALIDATOR = Dry::Validation.Schema do
      # ...
    end
    
    step :validate
    try :persist
    step :attach_payment
    
    def validate(payload)
      VALIDATOR.call(payload).success? ? Right(payload) : Left(:invalid_data)
    end
    
    def persist(payload)
      Invoice.create(payload)
    end
    
    def attach_payment
      bank_response = Bank::Client.new.register(invoice.id, invoice.paid)
      
      if bank_response.succeed?
        invoice.attach_payment! bank_response.order_id
        Right(bank_response)
      else
        Left(:error)
      end
    end
  end
end

Наш оперейшен почти готов. Смущает только validate код и мы можем его испрвить! Для этого нам нужно загрузить экстеншен в dry-v и все:

# config/initialize/validation.rb
Dry::Validation.load_extensions(:monads)

# app/operations/invoice/create.rb

def validate(payload)
  VALIDATOR.call(payload).to_either
end

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

def create
  Operations::Invoice::Create.new.call(params[:invoice]) do |m|
    m.success do |bank_response|
      render json: bank_response
    end

    m.failure do
      render json: { failed: 'failed' }, status: :internal_server_error
    end
  end
end

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

Плюсы

  1. Вся бизнес логика в одном месте
  2. Явно указан порядок выполнения кода
  3. Оперейшен проще тестировать, чем тестировать экшен

Минусы

  1. Новая абстракция (даже две)
  2. Есть кейсы, когда код действительно сложный и нужно потратить больше времени на то, что бы понять как его разбить

Бонус

У таких оперейшенов есть отличный плюс. Его можно легко покрыть тестом. Для этого вам надо:

  1. Написать 1 интеграционный тест. Проверить, что #call возвращает Right когда все хорошо
  2. Написать юнит тесты для каждого из шагов. Можно легко вызывать Operations::Invoice::Create.new.validate(payload) и проверять что вернулось.
@dmitry-matveyev
Copy link

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

@inpego
Copy link

inpego commented Aug 15, 2017

Код из примеров хорошо бы запускать перед выкладыванием (bank_response != bank_responce)

@aishek
Copy link

aishek commented Aug 24, 2017

Антон, спасибо, полезно и хорошо написано.

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

@artofhuman
Copy link

artofhuman commented Sep 4, 2017

Вроде как try :persist должен быть с catch иначе будет ошибка
+try+ steps require one or more exception classes provided via +catch:+

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment