やっぱりファット・モデルは分割したい


いまさらながらファット・モデルの分割について。

過去にいろんな失敗をしたけどやっと落ち着いてきた感があるのでファット・モデルの分割について書きたくなりました。
サンプルはRailsで書いていますが、言語・フレームワークに関わらずオブジェクト指向言語では同じように実装できます。いつもはJavaとPHPの人なのでコードの文法が間違っていたらスルーしてください。


クラスを分割する目的は「**オブジェクトを疎結合にしてテスタビリティを確保して品質を向上し変更に強いソフトウェアを構築するため**」です。

# クラス設計のルール

「[ファットモデルは目指すべきものなので、ファットモデル自体が問題ではない](https://qiita.com/klriutsa/items/8d7381f437c225c64a5f)」という意見もあるようですが、個人的な意見としてはやはり大きなクラスは分割したいです。

クラス設計をするときに重要視しているのは以下のルールです。

- オープン・クローズドの原則
- 単一責任の原則
- デメテルの法則
- データと処理を近くに置く

これらに従うとどうしてもファット・モデルは許容できません。

プロジェクトの中で肥大化するモデルはいつもだいたい決まっています。ウェブサービスの`User`(ユーザ)モデルだったり、ECサイトの`Order`(注文)モデルだったり、業務管理システムの`Project`(案件)モデルだったりします。名前からわかるようにシステムの利用者が最も関心を持っている上位のモデルです。いつもこれら上位モデルに処理が集まり肥大化していくことが多いのではないでしょうか。

# ファット・モデルをPOROに分割する

では実際にモデルを分割するときにどのような手順でやっているのか。上位モデルのクラスが大きくなったと感じたら以下のルールで分割しています。

1. PORO(Plain Old Ruby Object)で新しいクラスを定義する
2. 上位モデルからPOROオブジェクトを取得する
  - 実装者が PORO.new でオブジェクトを生成しない
3. コンストラクタに上位モデルを渡す
  - POROのインスタンス変数として保持する

「クラス分割の基準は実装者の考え方に個人差があり無秩序になりやすい」という意見には100%同意できます。そのため分割の基準としてこのルールを適用しています。

# 「通知付きポスト」の再実装

それではルールに従って「ブログを投稿して友達に通知をする」機能を実装してみます。元のコードは「[Railsの太ったモデルをダイエットさせる方法について - メドピア開発者ブログ](http://tech.medpeer.co.jp/entry/2017/11/08/120000)」です。

メドピアさんの例では `PostWithNotifications` クラスに分割してクラスメソッド `create!` を呼び出していました。実装箇所は`User`モデルです。

```ruby
# メドピアさんのコード抜粋
class User < ApplicationRecord
  def create_post_with_notifications!(body)
    PostWithNotifications.create!(creator: self, body: body)
  end
end
```

まずは基本となる`Post`と`User`モデルの作成です。元サイトでは `Post` クラスが出てきませんでしたが、あとで必要になるので定義しておきます。

```ruby
class Post < ApplicationRecord
  belongs_to :user
end
class User < ApplicationRecord
  has_many :posts
  has_many :friends # posts作成時に友達に通知する
  has_many :notifications # 通知
end
```

コントローラの中で `posts.create!` を使って `Post` を作成します。

```ruby
def create
  ActiveRecord::Base.transaction do
    user.posts.create!(body: params[:body])
  end
end
```

次に通知機能を実装します。ここでは先に挙げた分割ルールを満たすように `PostNotification` クラスを定義して通知処理を分割してみます。オブジェクトは `Post.notification` メソッドから取得します。

```ruby
class PostNotification # 1. ActiveRecord::Baseを継承しないPORO
  def initialize(post) # 2. 上位モデル(Post)を引数に取る
    @post = post
  end
  def created! # posts作成を通知する
    @post.user.friends.each do |friend|
      friend.notifications.create!("#{creator.name}さんが投稿しました")
    end
  end
end

class Post
  def notification # 3. 上位モデルからオブジェクトを取得
    PostNotification.new(self)
  end
end
```

Postを作成して通知を送信するときはコントローラから呼び出します。

```ruby
def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    post.notification.created!
  end
end
```

コントローラで `posts.create!`と`notification.created!`を呼び出しているのには理由があります。仕様が変更され一括で`Post`を作成する機能が追加されました。一括作成なので通知は不要です。

```ruby
def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    post.notification.created!
  end
end
# 一括作成フォーム
def batch_create
  ActiveRecord::Base.transaction do
    params[:bodies].each do |body|
      user.posts.create!(body: params[:body]) # 一括作成のときは通知しない
    end
  end
end

```

このように「通知する・しない」の仕様は境界にあることが多いのでコントローラで呼び出しています。「境界」というのはユーザとシステムの接点です。この例のように「1件作成」「一括作成」の他に「定期バッチで作成するときは通知しない」といったような処理にも対応できます。

これを`User`モデルに追加すると、通知を送る`create_post_with_notification`や、通知を送信しない `*_without_notification`や更新時に通知を送る`update_with_notification`のようなメソッドが増えて肥大化していきます。

更新や削除時にも通知を送りたいときは`PostNotification`クラスに`updated!`や`deleted!`メソッドを追加します。さらに通知を受信しない設定にしているユーザは除外してみましょう。

```ruby
# 他の通知を追加して対象ユーザも絞り込む
class PostNotification
  def initialize(post)
    @post = post
  end
  def created!
    notify "#{@post.user.name}さんが投稿しました"
  end
  def updated!
    notify "#{@post.user.name}さんが更新しました"
  end
  def deleted!
    notify "#{@post.user.name}さんが削除しました"
  end

  def friends
    @post.user.friends.select do |friend|
      friend.enable_notification # 通知を受け取る設定にしているfriendだけ
    end
  end
  def notify(message)
    friends.each do |friend|
      friend.notifications.create!(message) # 友達に通知を送信
    end
  end
end
```

このように`PostNotification`クラスを定義することで通知作成の処理を`User`や`Post`モデルから分割することに成功し、かつトランザクションスクリプト的なモデルにもなりませんでした。

今後、通知に関する仕様変更は `PostNotification` だけに閉じた変更になり、新しい仕様、例えば `ActiveJob` を使った非同期通知やメール通知などもこの中だけで完結できるでしょう。

# PostNotificationクラスの見つけ方

この手の議論でいつも課題になるのはモデル分割の指針です。「オーケー、この例はわかったけど`PushNotification`クラスは空から降ってきたの？」というやつです。

基本的には上記の3ルールを満たせばうまく分割できています。また、3つのルールの他に隠れた4つ目のルールがあります。

1. <span style="color: #999;">PORO(Plain Old Ruby Object)で新しいクラスを定義する</span>
2. <span style="color: #999;">上位モデルからPOROオブジェクトを取得する</span>
3. <span style="color: #999;">コンストラクタに上位モデルを渡す</span>
4. **POROのメソッドに引数を渡さない**

3つ目の「コンストラクタに上位モデルを渡す」の裏返しなのですが、POROのメソッドに引数を渡さないような設計にしています。

当初、「通知付きポストの再実装」では `User` モデルをコンストラクタに渡すPOROを考えました。`User`モデルに`create_post_with_notifications!`メソッドがあったので自然ですね。
ただ、このように分割すると通知対象の `Post` がわからないため `created!` の引数に `Post` が必要になってしまい4つ目のルールに違反します。

```ruby
# Userモデルから委譲されるパターン
class UserNotification
  def initialize(user)
    @user = user
  end
  def created!(post)
    # @userを持っていてもどのpostに対する通知なのかわからないのでしょうがなくpostを受け取る...
  end
end

# コントローラ
def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    user.notification.created!(post) # 引数が必要になる
  end
end
```

`PostNotification` は通知対象の `Post` モデルをインスタンス変数として保持しているためデータと処理が近くにあります。一方、`UserNotification` は操作対象のデータが外部から渡されるため、通知を作成するだけのトランザクションスクリプト、サービスクラス的な実装になっています。操作対象のモデルを引数として渡すとトランザクションスクリプト的なモデルになりやすいので避けています。

ここではじめて `UserNotification` クラスが間違っていると気が付き、引数として受け取ってた `Post` モデルをコンストラクタに移動させた `PostNotification` クラスを見つけました。

# クラス数が多くならない？

はい、なります。`models/` 以下に全て置いています。

実際のプロジェクトで使用している `Order`(注文)のイメージを共有します。`Order` には以下のようなデータ、機能があります。

- 注文内容
- 売上
- 仕入
- 配送
- 売上サマリ表を管理者が見る
- 売上推移グラフを管理者が見る

「売上」や「配送」「仕入」の起点は「注文」なので `Order` モデルが肥大化してきます。これを分割の4ルールに従って、人が認識しているモデルに分割していくと `Order` モデルには委譲用のメソッドがどんどん増えていきます。

```ruby
class Order < ActiveRecord::Base
  # (snip: 注文者情報や注文内容,ステータスなど一般的な情報)
  def sales
    OrderSales.new(self) # 売上に関する情報, 税額計算や割引, 請求書など
  end
  def payments
    OrderPayments.new(self) # 仕入れに関する情報
  end
  def delivery
    OrderDelivery.new(self) # 配送に関する情報
  end
  # コレクションメソッド
  # 売上げ集計表のために月別売上げを計算する
  def self.sales_table
    OrderSalesTable.new(all)
  end
  # 売上のグラフを表示するためのデータセットを作成する
  def self.sales_chart
    OrderSalesChart.new(all)
  end
end
```

コントローラではモデルを以下のように使います。

```ruby
# 売上画面コントローラ
class SalesController < ApplicationController
  # 売上げ情報を表示する
  def show
    @sales = Order.find(params[:id]).sales
  end
  
  # 売上げ表を表示するためのAjax API
  def table_dataset
    orders = Order.where(ordered_on: last_month) # 先月の注文データを取得
    table = orders.sales_table
    render json: {
      headers: table.headers,
      rows: table.rows
    }
  end
  
  # Chart.jsでグラフを表示するためのAjax API
  def chart_dataset
    orders = Order.where(ordered_on: last_month)
    chart = orders.sales_chart
    render json: {
      labels: chart.labels,
      datasets: chart.total_amount_dataset, # 税込み合計金額のデータセット
      # datasets: chart.total_amount_without_tax_dataset, # 税抜き金額のグラフはこっち
    }
  end
end
```

このような分割方法、委譲メソッドが正しいのかどうかわかりませんが、いまのところこの構成でテスタビリティのある小さなクラスに分割できています。

# サービスクラスについて

TODO: トランザクションスクリプト的なサービスクラスを使うケースもあるのでそれについても書きたい。
OO