Rails アプリケーションから API を介して外部システムと連携することがよくある。(例: CRM、決済プラットフォームなど) API から取得した外部サービスのデータに対して、何らかのビジネスロジックを記述する機会も少なくない。DBのテーブルを前提としないこともあり、ビジネスロジックの配置場所に困る。 このような場合に、ServiceやUsecaseなどと呼ばれる独自の層が設けられることが多いが、そういった層を設けずになるべくRailsのModel層を活用することができないか検討してみた。
まずは、具体例を通じて前提の整理をする。
架空の散髪チェーン「マッハカット社」の業務システム開発しているとする。
マッハカットでは
また、今回は新たに、以下のような施策が企画されている。
このような仕様を満たす定期バッチ作成して、 1 日一回実行することにした。
まずは、CRM ツールから今回の施策の条件に一致する顧客を取得する必要がある。 CRM ツールからは API が提供されているので、API Clientを実装して、データアクセスを容易にするところからはじめることに。
APIクライアントの実装
class CrmClient
def fetch_records(query)
request(...)
end
private
def rquest
...
end
end
定期バッチ(Rakeタスクの実装)
namespace hoge
task :fuga do
crm_client = CrmClient.new
# 散髪してから1ヶ月以上経過している顧客レコードを取得する
query = { filter: "last_cut_at <= #{1.month.ago.in_time_zone('Tokyo')}" }
users = crm_client.fetch_records('user', query: query)
next if users.zero?
users.each do |user|
# 催促メッセージを送信する
message = "そろそろ髪切りませんか??"
# 来店履歴が3回以上あるユーザーにはクーポン付きメッセージを送信する
if user['number_of_visits'] >= 3
message += "クーポン付き!!"
end
UserMailer.with(email: user['email'], message:)
end
end
end
この程度のコード量であれば、これでもなんとかやっていけそうだが、追加の開発があってもtaskブロックの見通しがよくなるように、API Clientに対してラッパーを作成して処理を切り出すことにした。
*Wrapperの実装
class CrmHelper
def fetch_cut_month_ago_users
crm_client = CrmClient.new
# 散髪してから1ヶ月以上経過している顧客レコードを取得する
query = { filter: "last_cut_at <= #{1.month.ago.in_time_zone('Tokyo')}" }
crm_client.fetch_records('user', query: query)
end
end
定期バッチ(Rakeタスクの実装)
# Rake Taskの実装
namespace hoge
task :fuga do
users = CrmHelper.fetch_cut_month_ago_users
next if users.zero?
users.each do |user|
# 催促メッセージを送信する
message = "そろそろ髪切りませんか??"
# 来店履歴が3回以上あるユーザーにはクーポン付きメッセージを送信する
if user['number_of_visits'] >= 3
message += "クーポン付き!!"
end
UserMailer.with(email: user['email'], message:)
end
end
end
対象となる顧客レコードを取得するロジックは多少整理された。ただし、taskブロックには外部APIから取得したそのままのデータ構造を使っていたり、ビジネスロジックの一部が残ったままになっている。今後の機能開発によっては taskブロック内 のコードが肥大化し、Fat Controller のような課題にぶつかる可能性もある。
このような課題について
をうまくカプセル化する設計と実装方針を考えてみることにする。
Railsが使われている著名なOSSが外部APIを使ったビジネスロジックをどのように扱っているのかをみていくことにする。 今回は以下のアプリケーションを参考にさせてもらった。
Jira 連携の例
Stripe を使用した決済処理の例
を設けて、外部 API との連携を行っている。
Mailchimp と同期する例
Service 層を設けて、各オブジェクトのロジックの呼び出しを取りまとめる。外部 API の呼び出しとビジネスロジックの置き場所は他につくる(もしくは Service 層で兼ねる)構成が多いことがわかった。
Railsを使っている以上、独自の層を設ける前にModel層を最大限に活用してビジネスロジックを組み立てたい。Railsの機能にのっかれるだけのっかりたい。 みたいなことを、いろいろ考えていたところ、まさにやりたいことを実現している事例を見つけた。
もうこれでよいのだが、RailsのModelの役割や設計の思想を整理して、自分なりにも設計・実装を考えてみることにする。
Railsのモデルとは何か?について見ていくことにする。
Rails の Active Record が Active Model とどこが違うかというと、Active Model は背後にデータベースが「なくてもよい」Ruby オブジェクトを用いてデータをモデル化するときに主に用いられます。Active Record と Active Model は、どちらも MVC の M の一部ですが、Active Model は独自のプレーンな Ruby オブジェクト(PORO)としても利用できます。
https://railsguides.jp/active_record_basics.html
Active Record は「データベースのテーブルに対応するモデル」を定義するインターフェイスを提供するものであり、Active Model は「必ずしもデータベースを必要としない、モデル風の Ruby クラス」を構築するための機能を提供するものです。
https://railsguides.jp/active_model_basics.html
Rails のモデルはDBのテーブルと対応させることが基本として設計されているし、そういった使い方をすることが多い。 ただ、公式ドキュメントをみると
の2種類が想定されていることが分かる。 また、ビジネスロジックをモデルとして実装する上で必要な機能が ActiveModel モジュールとして切り出されているとのこと。
上記より、RailsのModel層が必ずしもDBのテーブルと対応したものだけを扱うためのものではないことがわかった。
続いて、RailsのModelが担う役割を確認して、今回扱っているテーマにおける「ビジネスロジック」を配置することが可能そうかをみていくことにする。
を参考に Model の役割を挙げると以下
これらは
をカプセル化したいという今回のニーズを満たすことができそうである。
また、ActiveModelに実装されている機能を確認して、「ビジネスロジック」を配置することが可能そうかをみていくことにする。
ActiveModel モジュールに切り出されている機能としては
などがある。(詳しくはこちらを参照)
今回の要件に合わせ大まかに分類すると以下のようになる。
いずれの機能もActive Model モジュールを活用することで実現できそうである。
ことがわかった。 ActiveModelモジュールを使用して、前述した例について Model を実装してみる。
app/model/crm/user.rb
module Crm
class User
include ActiveModel::Model
include ActiveModel::Attributes
extend ActiveModel::Callbacks
attribute :id, :string
attribute :email, :string
attribute :first_name, :string
attribute :last_name, :string
attribute :last_cut_at, :date
attribute :訪問回数, :integer
attr_accessor :id, :email, :first_name, :last_name, :last_cut_at, :訪問回数
# バリデーション
validates :first_name, presence: true
validates :last_name, presence: false
class << self
def fetch_records
# API Client or Wrapperからデータの取得
crm_client = CrmClient.new
# 散髪してから1ヶ月以上経過している顧客レコードを取得する
query = { filter: "last_cut_at <= #{1.month.ago.in_time_zone('Tokyo')}" }
records = crm_client.fetch_records('user', query: query)
# データの変換
records.map do |record|
new(id: record[:id], email: record[:email], 訪問回数: record[:訪問回数])
end
end
end
def remind_message
message = "そろそろ髪切りませんか??"
if royal?
return "クーポン付き!!#{message}"
end
message
end
private
def royal?
訪問回数 > 2
end
end
end
lib/tasks/campaign.rake
namespace campaign
task :remind do
users = Crm::User.fetch_cut_month_ago_users
next if users.size.zero?
users.each do |user|
UserMailer.with(email: user.email, message: user.remind_message)
end
end
end
POROをActiveModelモジュールで拡張することで、ドメインロジックをActiveRecordを継承したモデルに近い使用感にすることができた。
外部APIのレスポンスすべてを属性として定義するのは大変であったりする可能性もある。そのような場合には、まずClass Methodだけを定義したPOROを作成しておく。 コードが冗長になってきたり、上記のようにオブジェクトとしての状態や振る舞いが必要になった際にActiveModelモジュールを使用して拡張していくのもあり。
主にDDD文脈でサービスレイヤーやアプリケーションサービスとして扱われるものをどうするか?例えば以下のようなもの
Railsでは、このような処理にサービスオブジェクトパターンを導入することが多い。
ここまで整理してきた、外部APIに対応するModelオブジェクトでもCallbackやNotificatins機能を活用することで、ある程度ユースケースを実装することは可能。 ただし、ユースケースが複雑化すると保守性やテスト容易性が落ちることもわかっている。 では、どのようにユースケースの組み立ての責務をコントロールするか、自分なりの考えと方針を書いておく。
前述したように、複数のトランザクションリソースを操作するようなユースケースの組み立てを独立した層に切り出すことで、Controllerの肥大化の抑制やテスト容易性の担保につながる。 一方で、サービスレイヤーの責務を明確にした上で、設計者の意図した形で維持し続けるのは難しい。(元々Railsで提供されていない層であることも大きい) ユースケースとビジネスロジックが混在してしまったり、本来Modelにあるべき処理がサービスレイヤーに書かれてしまうことで、ドメインモデル貧血症につながる可能性も高い。 そういったこともり、個人的には、ユースケースの分離や切り出しを遅延させることが重要だと考えている。
PofEAAでは
私はビジネスロジックを含むサービスオブジェクトをもつべきでないと言っているのではない。サービスオブジェクトの固定レイヤを必ずしも作成しなくてもよいと言っているのである。手続き型サービスオブジェクトはロジックを抽出するのに役立つこともあるが、私個人はアーキテクチャレイヤとしてではなく、必要なときにだけ使用することが多い。
https://www.martinfowler.com/eaaCatalog/index.html
37signalsのブログでは
We don’t use services as first-class architectural artifacts in the DDD sense (stateless, named after a verb), but we have many classes that exist to encapsulate operations. We don’t call those services and they don’t receive special treatment. We usually prefer to present them as domain models that expose the needed functionality instead of using a mere procedural syntax to invoke the operation.
https://dev.37signals.com/vanilla-rails-is-plenty/
とそれぞれサービスレイヤーについての言及があったりする。