Пример из 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 разных действия в одном месте:
- Валидация данных
- Сохранение инвойса в БД
- Вызов банковского клиента и если все хорошо - обнавление инвойса
- Отдача ответа в 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:
- Каждый шаг обязательно должен возвращать Either монаду
- Каждый шаг принимает аргументы с последующего
- Шаг
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 интеграционный тест. Проверить, что
#call
возвращаетRight
когда все хорошо - Написать юнит тесты для каждого из шагов. Можно легко вызывать
Operations::Invoice::Create.new.validate(payload)
и проверять что вернулось.
Так это же и есть "вынести все в один интерактор", только "интерактор" более хорошо структурированный.
Имхо шаги должны быть приватные, что разрушает концепцию "легкого тестирования".