🌞

今度こそFatModelを正しく分割するために、手法と所感をまとめた

2022/12/23に公開

はじめに

Railsアプリケーションを運用していると、Modelが膨らんできます。
肥大化するModelの可読性を保つための手法について、経験を含めた 自分の所感 をまとめてみました。
まだ完全に方向性は決まってませんが、過去分割がうまくいかなかったこともあり、今度こそいい分割・ガイドライン整備をやっていきたいと思っています。
参考として現状のModel数は120個くらい、この話に出てくるリポジトリに関わっている人数はならして20人弱くらいです。
※書き味については好みが分かれると思うので、あくまで筆者からみたものとして捉えてください。

Model層の振り返りと所感

Service層(4年前に導入済み)

  • Service層に、本来Modelに書くべきドメインロジックが書かれてしまいがち。
  • Modelがドメインモデル貧血症になってくるととてもつらい。
  • 「どこに書いたらいいかわからないコード」はServiceに集まりやすいので、つらいところはここだっていうわかりやすさはありそう。
    • Serviceをマシにするということを考えてみると、Serviceに集まりがちなのが、でかめのトランザクション・ API Clientであることが多いように思う。
    • API Clientは app/api_clients/ に切ってしまうのはアリだと思う。

Interactor層(未導入)

  • Interactorはアプリケーションロジックのみを書き、ドメインロジックを書かないという前提
    • MVCに慣れたRailsエンジニアはドメインロジックとアプリケーションロジックは一緒に書くことが多いので、逆のメンタルモデルになるので導入の難しさがありそう。
  • つよいエンジニアが腕力でドメインロジックとアプリケーションロジックの分類をし続けないと難しいのではないか。
  • ドメインロジックとアプリケーションロジックが混ざっていて困った経験が自分としてはない
    • ドメインロジックとアプリケーションロジックの境界を議論するよりもドメインモデルの議論に100%の時間を使いたい。
  • ドメインロジックとアプリケーションロジックの分割により薄いファイルがたくさんできてしまうことで、Model層の見通しが悪くなってしまう懸念。
    • InteractorからInteractorを呼ばないなど工夫が必要か。

Form Object(3年前に導入済み)

  • Formごとに生成されるので、迷いがないのが良い。
  • Form Object を通らずに副作用のある処理をしたら死ぬなどならないように Model 側メインでバリデーションなどの処理を書いておきたい。
  • どうしても検索フォーム周辺はパラメーターをたくさん捌く必要があるので、その場合はアリではないか。

Decorator(4年前に導入済み)

  • helperの肥大化を回避するために導入した。
    • Viewで使うような装飾や文字列結合を伴うものはDecoratorでという切り分けだが View で使うべきものか否かという判定が結構ブレる。外部サービス連携で使いたくなって結局Modelに書いたほうが良かった・・・ということがある。
  • Decoratorという層を設けずに、普通にModelに書いて良い。

ドメインとは関わらない便利なコード

Util(4年前に導入済み)

  • Slackに投稿するコードなどが入っている薄いやつ。
  • とても薄いのと、特に中間層になっているわけではないので、まぁこのままでもええかという感じ。
  • libとかに置いてもいいかも。

Model内部の分割

PORO(未導入)

  • Modelの責務の移譲先としてアリではないかと思う。
  • ただPOROは名前のとおりただのRubyオブジェクトなので、自由度が高すぎるところが難しい。
  • メソッド利用者からみてModel→POROにアクセスしているのか、Model→DBにアクセスしているのかの差がわからないようなインターフェースにしたい。

Concern(一部あるけどほとんど入ってない)

  • 運用イメージ
    • グローバルなものは app/models/concerns 配下。
    • 特定のModelに閉じるものは app/models/accounts/terminatable.rb などに置くと良さそう。
  • ActiveRecordのパワーが失われずにそのまま使えるのが強い。
  • 一方で関心の分割ではなくコードを移したいからConcernを使う場合はうまく機能しない。
  • includeしてるだけなので 関心 は分離されても責務自体は重いままのような気がしてしまう。

Concern内部でPOROをつかったClass分割(未導入)

できるだけModelに処理を書いていきたいが、どうしてもコードの見通しが悪くなってきたらアリだと思う。

class Account < ApplicationRecord
  include Closable
end

module Account::Closable
  def terminate
    purge_or_incinerate if terminable?
  end

  private
    def purge_or_incinerate
      eligible_for_purge? ? purge : incinerate
    end

    def purge
      Account::Closing::Purging.new(self).run
    end

    def incinerate
      Account::Closing::Incineration.new(self).run
    end
end

引用元: https://world.hey.com/jorge/code-i-like-iii-good-concerns-5a1b391c

ServiceClassからの呼び出し
AccountTerminationService.new(account).run

引用元: https://world.hey.com/jorge/code-i-like-iii-good-concerns-5a1b391c
↑よりも↓はかなりスッキリ呼び出せてとてもRubyらしさがあるコードになるし、メソッド利用者からみてとても使いやすい状態になっている(メソッド利用者には、サービス経由の処理なのか直接DBを叩いているのかは意識してほしくない)

インスタンスメソッドからの呼び出し
account.terminate

引用元: https://world.hey.com/jorge/code-i-like-iii-good-concerns-5a1b391c

concerning(未導入)

導入していないものの、大きいModelに出会ったときにまず関心ごとにまとめることで後々移行なり責務分割しやすいのではないかと思う。

class Account
  concerning :Terminatable do
    ~~~
  end
  concerning :Registerble do
    ~~~
  end
  concerning :Provisionalable do
    ~~~
  end
end

Model層ポエム

基本の方向性

  • 迷ったらとりあえずどんどんModelに処理を追加していっていい。
    • 闇雲な切り出し方をするよりも Fat Modelを一度作ってしまってから考えるくらいが調子良い。
      • Fat Model を避けようと層を入れたり責務は分かれていないのにファイルだけ分割すると Fat Model のつらさ以上につらい目に合う。
        • 20人の認識を揃えるコストをなめてはいけない(何回か死んだ)。シンプルでわかりやすいガイドラインが重要で、ドメインモデル以外で毎回議論が発生するものは運用上現実的ではない。現実には設計以外にも解くべき問題があまりにも多い。
    • Fat Controller のつらさ >>>>>壁>>>>> Fat Model のつらさ であり Fat Model はそこまでつらくない。まずはModelを厚くしたい。
  • Modelから離れるとどうしてもActiveRecordのパワーが半減してしまう。引数にでかいオブジェクトをバケツリレーしがちで書き味もよくないのでできるだけActiveRecordに乗っかりたい。

ModelとControllerの間に中間層をいれることの可否

  • 原則中間層を入れない状態でModelに直接アクセスする状態をキープしたい。
  • ActiveRecordの長所はデータの流れとドメインロジックを同時に表現できること。
    • 中間層を入れるとデータの流れかドメインどちらかが表現できなくなりがち。
      • データの流れが見えなくなることが多い印象。

分割の進め方

  • ドメインモデルの分割をうまくやらない限りは、根っこの問題は解決しないように思う。
    • マイクロサービスは、ドメインの境界が見えない状態で分割すると失敗すると言われている。
      • 神クラスに遭遇した場合、レイヤーの分割は根本解決ではないのでModelの 責務分割 に全力を割きたいていきたい。
        • Modelの責務分割(モデリング)はとても大事なので設計議論の100%の時間を使っていきたい気持ち(理想論)
    • とはいえ、ドメインモデルの責務分割できるラインが見えないこともある
      • concerningやPOROなどを使って最低限の保守性を保って時間を稼ぎつつ、責務分割ラインが見えたら判断する形にできたらとても良い。
  • ドメインモデルを整理することが一番(できるだけここを目指す)だが、現実問題難しい場合は、次点で用途を限定したServiceクラス or POROでやっていくのが良さそう。

終わりに

自分なりの解釈をまとめてみたものの、この記事自体もかなり悩みながら書きました。
うちはModel数2倍だけどもっとシンプルにやれているよ!同じような悩みを抱えてるよ!などありましたら、教えてもらえるととてもありがたいです!

参考

https://dev.37signals.com/vanilla-rails-is-plenty/
https://world.hey.com/jorge/code-i-like-iii-good-concerns-5a1b391c
https://blog.willnet.in/entry/2019/12/02/093000
https://api.rubyonrails.org/v4.1.0/classes/Module/Concerning.html

Discussion