煩悩エンジニアの備忘録

煩悩シンタックスハイライト。良いコードと良い人生を求めて生きるエンジニア2年目のあれこれをお届けします。

Railsを知る

この記事は 株式会社SUPER STUDIO - Qiita Advent Calendar 2024 - Qiitaの16日目の記事になります。

2日目に公開した以下の記事で、共通処理の実装方針について悩んでるという内容の話を書きました。

モジュールか、サービスクラスか - 煩悩エンジニアの備忘録

今回は上記の投稿をし、再度また悩み始めましたという記事になります。 字数が1万字近くになってしまいました...お急ぎの方は足を止めずに先に進んでください。

fqqkさん、ちょっと良いですか?👀

2日目の記事を投稿してから、会社の大先輩にオンライン会議室で声をかけていただき、ご指摘をいただきました。

内容としては

  • レイヤーという言葉があってほしかった

  • moduleでもspecが書きにくいということはない

  • サービスは横断的に何かをするために導入するもの

その指摘をもとに改めて記事を漁って、以下の考えにたどり着きました。

共通化手段の検討より前に、まずはドメインに向き合い、共通化の気配を感じること。そして、その気配を手がかりに構造化を行う。

今回は上記の考えを自分なりに落とし込み、実際にコードにどのように向き合っていけばよいのか練習してみました。という内容を書いた記事です。

共通化と構造化の違いについて

そもそも共通化と構造化は具体的にどう違うのでしょうか?

以下の記事が理解や言語化の助けになります。

共通化という考え方はアンチパターンを生み出すだけ説 - タオルケット体操

共通化という名の下におこなわれるのは「同じロジックを持つコードをまとめる」行為

抽象化というのはロジックを意味単位ごとにひとくくりにしていく行為。理想の状態を作り上げるための指針。

この記事を読んで、自分の中に構造化という視点がそもそも抜け落ちていたということに気づくことができました。

そして、この「構造化」という行為は、単に「この箇所とこの箇所のコードのロジックが同じだから共通化しよう。よし、リファクタできたぞ。」的な視点で眼の前のコードと対峙していても見えてはきません。

今の自分の中にある言葉で表現するとしたら、以下のようになります。

Railsのモデル(ドメインロジックやビジネスロジックの置き場)をじっくり観察して、そのモデル本来の責務とは毛色が異なる処理が書かれているということにまずは気づく。 書かれているものが少ないときはまだその姿は見えません。 ただ、よりモデルが育ってきて、少しFATなモデルになってきたときに、「どうやら今のこのモデルは本来はこういう責務を担うべきだけど、Aという別の責務も担っていそうだな」 この責務を言葉で表現すると例えばどういうものがあるだろうか...🤔

という思考の流れの中で浮かび上がってくるものというふうに捉えています。

なぜFATモデルは生まれるのか

FATモデルに対してアプローチする上で、上記で説明した"構造化"という行為をまずしていくのが大切という話なのですが、そもそもどうしてモデルはFATになるのでしょうか。 Railsは 「MVC + ActiveRecord(ORM)」ですね。 このMVCフレームワーク(厳密にはMV*系フレームワーク)というのはMartin Fowler氏のPDS(Presentation Domain Separation)*1を実現するために存在します。 PDSとはかんたんにいうと、プレゼンテーション層かそれ以外か。でレイヤーをわける設計原則のことです。

加えてRailsはActiveRecordというORMを採用していますが、このActiveRecordというORMは、ActiveRecordパターン*2という設計パターンを適用したORMのことのようです。

この設計パターンはデータベースのレコードと対応するクラスを作成して、データアクセスのメソッドとドメインロジックを持たせるというもの。*3

  • DBレコードと対応するデータを持つ
  • データアクセスメソッドを持つ
  • ドメインロジックを持つ

ドメインロジックがDBと密結合になる代わりに、実装コストが低くなるという特徴があります。

そのため、Railsは「モデルがDBに強く依存することを受け入れる代わりに、開発者がスピーディーに開発できるように作られている」というわけです。

モデルがファットになっていくというのは、RailsのモデルがActiveRecordと密に関わっていて、PDSにおける「プレゼンテーション以外」のビジネスロジックやドメインロジックの置き場が何も手を加えていないRailsではモデルしかないため、開発を進めていくにあたり、モデル固有のロジックであるドメインロジック以外のビジネスロジックもそのモデルに書かれてしまい、モデルの責務が膨らんでいく。という流れかなと思います。 ただ、これはActiveRecordパターンを採用したときからの想定の範囲内の話なのかなと思っていて、Railsでうまくアプリを育てていく方法がぼくたちにはきっと残されているはずだし、Railsはそれを提供してくれています。というのを後半は書いていきます。

モデルに必要な責務を残しつつ、別の責務をどう取り扱っていくのが良いのか

Railsのモデルの責任は、設計パターンから以下のようであるべきだとわかりました。

  • DBレコードと対応するデータを持つ
  • データアクセスメソッドを持つ
  • ドメインロジックを持つ

ちなみに、上記が満たされていないモデルが陥っている状態を、ドメインモデル貧血症*4と呼んだりします。

ドメインロジックという言葉をここまで平気で使ってきましたが、ドメインロジックとビジネスロジックは似て非なるものみたいなんですよね。*5

ビジネスロジックとは、企業がビジネスを遂行する際のルールや条件、プロセスをコードで表現したもの

ドメインロジックとは、ビジネスロジックの一部でもありますが、特定の「ドメイン」または業界特有の知識やルールをコードで表現したもの

スタックオーバーフローにもこんなトピックがありました。

architecture - What is domain logic? - Stack Overflow

自分がわかりやすかった回答はこのあたりです。

Domain is the world your application lives in. So if you are working on say a flight reservation system, the application domain would be flight reservations.Business Logic on the other hand is a more discrete block of the entire Application Domain. Business Logic is usually a section of code built to perform one specific business process. So you would have business logic to take a reservation. Another bit of business logic would be code to refund cancelled tickets.The objects that support your business process then become your business objects!(ドメインは、アプリケーションが存在する世界です。たとえば、フライト予約システムに取り組んでいる場合、アプリケーション ドメインはフライト予約になります。一方、ビジネス ロジックは、アプリケーション ドメイン全体のより個別のブロックです。ビジネス ロジックは通常、特定のビジネス プロセスを実行するために構築されたコードのセクションです。つまり、予約を取るためのビジネス ロジックがあります。ビジネス ロジックのもう 1 つの部分は、キャンセルされたチケットを払い戻すコードです。ビジネス プロセスをサポートするオブジェクトがビジネス オブジェクトになります。)

また以下の記事も大変参考になりました。

「ビジネスロジック」とは何か、どう実装するのか #設計 - Qiita

先ほどFatなモデルというのは、モデル固有のドメインロジックの他にもビジネスロジックが増えてきてしまっている状態のモデルという話をしました。 そしてその状態のモデルから、新たに責務を見出していく行為が構造化であると。

ここで、世の中でサービスレイヤーについて否定的な声があるのは、この構造化という行為がしづらくなるというのも理由の一つなのかなと思いました 🤔

どういうことかというと、本来そのモデルの近くにあるべきビジネスロジックが別のレイヤーに切り出されている場合、将来的に構造化という行為を行っていくのが大変になるんじゃないかと。 一つのモデルの中にたくさんのビジネスロジックが散らばっている状態というのは、一見まとまりがないかもしれませんが、新しいドメインモデルを見出していくためのヒントがそこに集約されている状態とも捉えられます。 そのため、どこに置くべきか今は見出すことができない...という場合は一旦モデルにビジネスロジックを書いて、モデルを育てていく。というのが良いのかなと個人的には思いました。 そのうえで「さすがにこれはちょっと膨れすぎたな」というタイミングで構造化作業を行っていくわけですが、この作業自体ドメインをどう捉えるか?という抽象的な話であり、いろいろな視点が必要になることから、関係者を巻き込んで議論の上で決めていくというのが良さそうだなと思いました。

構造化への道を探る

では実際にいざ構造化をしていくぞ!となるときがいつか来るかもしれないので、ちょっと練習してみようと思います。練習なので失敗しても良いんだ....

膨らんでいるモデルが目の前にあるとして、そのモデルを観察し、ドメインロジック以外にどのような責務を抱えているのか?それはどのように表現できるのか?Railsは責務の分離をするために何を提供してくれているのか?あたりについて考えていきます。

ECサービスを例にしてみます。 顧客が注文をしてポイントが付与されるユースケースを考えてみます。

ドメインモデル

class Customer < ApplicationRecord
  has_many :orders
  has_many :points

  validates :name, presence: true
end

class Order < ApplicationRecord
  belongs_to :customer
  has_many :order_items

  validates :total_price, numericality: { greater_than_or_equal_to: 0 }

  def total_price
    order_items.sum(quantity * price)
  end
end

class OrderItem < ApplicationRecord
  belongs_to :order

  validates :quantity, numericality: { greater_than: 0 }
  validates :price, numericality: { greater_than_or_equal_to: 0 }
end

class Point < ApplicationRecord
  belongs_to :customer

  validates :amount, numericality: { greater_than_or_equal_to: 0 }
end

ポイント付与のロジックがOrderに存在するケース。

class Order < ApplicationRecord
  belongs_to :customer
  has_many :order_items

  validates :total_price, numericality: { greater_than_or_equal_to: 0 }

  def total_price
    order_items.sum(quantity * price)
  end

  # 注文時にポイントを付与するメソッド
  def apply_points
    points_earned = complex_calc_points
    customer.points.create(amount: points_earned)
  end

  # 複雑なポイント算出処理
  def complex_calc_points
   # if文がたくさんあったりして、例えば100行ぐらいあるとします。
   # ポイント付与率が可変
   # ポイントの種類も複数
   # ポイント同士組み合わせて利用できる
   # 特定の商品購入の場合にポイントがついたり。
  end
end

受注のドメインロジックってなんでしょうね。

注文が作成されたり、作成された注文が発送されたりといった移り変わりでしょうか?

メソッドとしては、completeとか、shippedとかのみがあれば健全という印象です。

そのため「受注の合計金額とポイント付与率から、ポイント数を計算し、顧客に付与する」という仕事はOrderではない何か別のドメインモデルが持つべき責務であると考えられそうです。

やりたいこととしては、Orderから責務を移動はさせるんだけど、全く別のところに置くわけではなく、近いところに置くということ。

これにRailsのconcernsが使えるという話。 以下記事がわかりやすいということで教えてもらったので置いておきます。

37signals Dev — Good concerns

自分はこれまで、concernsって何かいろいろなモデルで共通して備わる機能を書く場所という程度の理解だったのですが、上記記事では

  • For common model concerns: we place them in app/models/concerns.
  • For model-specific concerns: we place them in a folder matching the model name: app/models/<model_name>.

というふうに書かれており、このmodel固有パターンがFATモデル解消に活躍してくれそうです。

# app/models/recording.rb
class Recording < ApplicationRecord
  include Completable
end

# app/models/recording/completable.rb
module Recording::Completable
  extend ActiveSupport::Concern
end

concernsは"has trait"(特性を持つ) や "acts as"(のように振る舞う)のため、ポイントを配る人のように振る舞うと考えて、PointGiverという機能を持つとします。

今回は"ポイントを配る"と"ポイントを計算する"を分けてみました。 ポイントを計算するのは、Order以外にもProductに付与されるポイントを計算したりすることもあるなと思ったためです。そういったポイント計算系の処理は一つの関心事として切り出して、今回ならOrder::PointGiverにincludeしますが、のちのちProduct::PointGiverみたいなのも出てくるかもしれないので、分けてみました。

# app/models/concerns/order/point_giver.rb
module Order::PointGiver
  extend ActiveSupport::Concern
  include PointCalculater

  def give_points_to_customer
    customer.points.create(amount: complex_calc_points) if customer
  end
end
# app/models/concerns/point_calculater.rb
module PointCalculater
  extend ActiveSupport::Concern
  
  # 複雑なポイント算出処理
  def complex_calc_points
   # if文がたくさんあったりして、例えば100行ぐらいあるとします。
   # ポイント付与率が可変
   # ポイントの種類も複数
   # ポイント同士組み合わせて利用できる
   # 特定の商品購入の場合にポイントがついたり。
   # 最終的にrateが0.1になったとします。
   rate = 0.1
   self.total_price * rate
  end
end
class Order < ApplicationRecord
  include PointGiver

  belongs_to :customer
  has_many :order_items

  def total_price
    order_items.sum(quantity * price)
  end
end

concernsの導入によって良いなと思った点としては、以下のように@order.give_points_to_customerのように呼び出せることです。 呼び出し方がシンプルだし、orderがcustomerにpointをgiveするというふうに読めます。 自分はこれまであまりconcernsを使わずにサービスを使っていたのでこれは非常に新鮮でした。

class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)
    if @order.save
      @order.give_points_to_customer
      # その他の処理
    else
      # エラーハンドリング
    end
  end
end

これにより、Orderというドメインモデルからポイントを顧客に付与するメソッドを生やしつつ、実際のドメインモデルからは関心事を移動させはしたけど、Orderの近くに存在させるということができました。

まとめ

今回記事を書いていく上で今までにないくらい情報を集めて一旦の結論を出してみました。 正直なところ、かなり難しかったです... 今も完全に理解したという感覚はないです。

ただ、コーディングの方針として一つ確かに決まったことがあり、それは収穫でした。 とりあえず迷ったら一旦モデルに書いていくというのを徹底しようと思います。 そのうえで新しいドメインモデルがくっきりじゃなくても見えてきそうな感じがしたらチームの人に話したりして、ドメインモデルの解像度を協働で高めていきたいです。

また、モデルがFATになることはそんなにおおごとじゃないという理解が得られたのも大きかったです。 モデルよりも、コントローラーやビューにビジネスロジックが置いてあったりすることの方がのちのちドメインモデルを考えていくうえでの足かせになるため、今後見つけたら至急モデルに移していきたいと思いました。

最後に、今回記事を書くきっかけをくれた先輩に感謝を伝えたいと思います。 ありがとうございました!

参考