Rails で ActiveInteraction を使ってModelのビジネスロジックを切り出す


経緯

Worker から動かすためのビジネスロジックが、Worker(task) にもあるし、Model の中にもある状態でした。

少なくともModel からは切り離したかったのですが、Workerが肥大化すると視認性が落ちるため、役割を分割できるようにしておきたいと思いました。記述は他のModelが見える has_many の近く(つまりModel)に置く方が関係性を認識しやすいので、まず共通部分は Concern に切り出してみました。

その後、Concern にも切り出せないコマンド系の処理が Model に残りました。
例えば #run, #execute, #start, #stop などです。

明らかに Model に存在するのは違和感があると思い、Service 層へ切り出す方向で検討を開始しました。

ちなみに Controller と Worker では、同じモデルを別のロジックで動かします。
Worker は一連の処理を連続してバックグラウンドで行いますが、 Controller は ブラウザからの設定・管理・レポート表示を担います。

アプリケーション全体でみると、Controller と Worker に書かれているロジックが異なるため、Controller / Worker / Model の3ヵ所にビジネスロジックを書いている状態となっていました。
しかしここでは、一連のバックグラウンド処理を担うWorkerのビジネスロジックに焦点をあてています。( Controllerは、まだMVCで収まる規模であるため。)

検討

app/services 配下に Service 層を置く

まずはService層を検討しました。ビジネスロジックを一ヵ所に集められないか?と思ってのことです。
しかし、アンチパターンでもある、という見解も目にしたため見合わせました。

Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)
https://techracho.bpsinc.jp/hachi8833/2018_04_16/55130

何が良いのか、設計としてどうあるべきかは、常に賛否あるみたいですが。

app/commands 配下に 独自の Command パターンを置く

Gemがなければ自分で実装していたと思います。Commandパターンを考えたのは Model に残った処理がCommand系だったからです。それ以上の意味は特にありませんが、拡張性に耐えられるかどうかは不安でした。

Active Decorator みたいなGemを探していれる

過去、Active Decoratorを入れたときに、Modelに持たせたくない責務を、View側から見るとModelの一部のように使える点に利便性を感じました。

同じように扱いやすいGemはないか?と思い、探して見つけたなかで、
Active Interaction が Command/Service層に近かったので検討しました。

なお、名前は Interaction ですが、実装は Interaction か?と言われると違い、Command だと思う(公式にもそう書いてある)ので、Command の gem だと思って入れました。

Active Interaction の導入

GitHub
https://github.com/AaronLasseigne/active_interaction

基本的にはREADMEを読むことになります。

Gemfile
gem 'active_interaction' 

配置

app/interactions が推奨されているので、そこに置きました。
さらに Model ごとに directory を分けて、Model 別の interaction を置くことにしました。

- app/
  - interactions/
    - order/
        - processor.rb
        - processor/
            - open.rb
            - submit.rb
            - send_keys.rb
              :

ルール

interractions/[model名]のdirectory 配下は、
- Model の外部化である
- 処理系は Processor である
- Model はProcessorの内部構造を知らない
- ProcessorはModelを把握している
というルールにしました。

実現したかったのは、様々な実行を集めることではなく、Model自身が簡素化されることです。
Commandをここに置くと決めると、各所の処理が集まってしまいます。
手段の目的化を避けるため、「あらゆるCommandを集める」ことはしないように決めました。いまControllerの処理を持ち込まないことにしたのも、このためです。

Model からの呼び出し

Order は type を持っているため、子クラスが存在します。
子クラスは run を持たず、typeの違いは Order::Processor が処理することにしました。

models/order.rb
class Order < ApplicationRecord
  def run
    Order::Processor.run(order: self)
  end
end

class Order::Open < Order
end

class Order::Submit < Order
end

interactions/order/processor.rb
class Order::Processor < ActiveInteraction::Base
  object :order # class チェック. Order を継承している子クラスもOK

  def execute
    order.class ... クラス別の呼び出し
  end
end
interactions/order/processor/open.rb
class Order::Processor::Open < Order::Processor
  def execute
    begin
     if order.path.present? then
     :
    rescue => e
     :
    end
  end
end

これによりモデルから Order::Open のインスタンスを run すると、 Order::Processor::Open.run を呼び出して、ActiveInteraction によって #execute が呼ばれる、という流れになりました。

呼び出し

今回、Worker (いまはtask)からは、Modelの一部として扱える状態を保ちました。
たとえば

task
steps.each do  |order|
   order.run 
end

という構造はキープしました。ここをCommand呼び出しにすると、

task
steps.each do  |o|
   OrderProcessor.run( order: o )
end

となりますが、これではWorkerがビジネスロジックごとに最適なコマンドを把握する必要があります。ビジネスロジックを外部化した意味が薄れるためCommandの直接呼び出しは避けました。

まとめ

ActiveInteraction を使うことで、Model のなかに冗長化していたビジネスロジックをスッキリさせることができました。
とくにエラーハンドリングや条件に応じた切り分けは、単純な処理にも関わらず、丁寧に書くと冗長になります。この部分が外部化されたことは視認性を高めました。

また、ActiveInteraction にルールを設けたことで、Modelとの役割分担が分かりやすくなりました。Model には状態と簡素化された振る舞いだけを書くことができました。
また、Worker(ここではtask)からみるとModelの一部に見えるため、Rails の Model から外れることもありませんでした。

ただし、ルール無しに利用するとリスクがあるとも感じました。
直接Commandを呼び出すだけの、Railsから外れた書き方になりやすそうです。特にWorkerやControllerから呼び出す場合は、単純なコマンド呼び出しの連鎖になるリスクも感じています。

ControllerやWorker内のビジネスロジックを分けていくときには、別の分かりやすいのルールが必要かもしれません。