kakudooo docs

Railsアプリケーションと外部APIに依存するビジネスロジックの設計・実装

目次

これは何か?

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を使ったビジネスロジックをどのように扱っているのかをみていくことにする。 今回は以下のアプリケーションを参考にさせてもらった。

GitLab

Jira 連携の例

gumroad

Stripe を使用した決済処理の例

を設けて、外部 API との連携を行っている。

Forem

Mailchimp と同期する例

まとめ

Service 層を設けて、各オブジェクトのロジックの呼び出しを取りまとめる。外部 API の呼び出しとビジネスロジックの置き場所は他につくる(もしくは Service 層で兼ねる)構成が多いことがわかった。

Railsを使っている以上、独自の層を設ける前にModel層を最大限に活用してビジネスロジックを組み立てたい。Railsの機能にのっかれるだけのっかりたい。 みたいなことを、いろいろ考えていたところ、まさにやりたいことを実現している事例を見つけた。

もうこれでよいのだが、RailsのModelの役割や設計の思想を整理して、自分なりにも設計・実装を考えてみることにする。

Rails の Model について整理してみる

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のテーブルと対応させることが基本として設計されているし、そういった使い方をすることが多い。 ただ、公式ドキュメントをみると

  1. DBのテーブルと対応するもの
  2. DBのテーブルと対応しない、Rubyオブジェクト(PORO)

の2種類が想定されていることが分かる。 また、ビジネスロジックをモデルとして実装する上で必要な機能が ActiveModel モジュールとして切り出されているとのこと。

上記より、RailsのModel層が必ずしもDBのテーブルと対応したものだけを扱うためのものではないことがわかった。

Modelの役割

続いて、RailsのModelが担う役割を確認して、今回扱っているテーマにおける「ビジネスロジック」を配置することが可能そうかをみていくことにする。

を参考に Model の役割を挙げると以下

これらは

をカプセル化したいという今回のニーズを満たすことができそうである。

ActiveModel モジュールの機能

また、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/

とそれぞれサービスレイヤーについての言及があったりする。

方針