「ロジックをモデルに移す」よりシンプルなファットコントローラの解消法【個人開発で学んだこと】

# 記事を読むのにかかる時間
約10分

# 結論
コントローラの記述が膨れ上がってしまう、いわゆる**「ファットコントローラ」**について、
最もオーソドックスな解消法として挙げられる「ロジックをモデルに移す」よりシンプルなのは
**「テーブル設計を工夫し、そもそもコントローラに何も書かなくて良い状態を作る」**
ことだと思います。
今回、個人開発でその体験ができたので共有します。

# この記事から得られること
実装の前段階にあたる**テーブル設計**を工夫することで
コードをシンプルにする方法を一つ知ることができる。

# 必要なRailsの予備知識
・一般的なCRUDのアクション
・多対多の関連付け（`has_many :through`とか`belongs_to`）の概要
・enum

# 目次
[結論](#結論)
[開発したサービス](#開発したサービス)
[テーブル設計で工夫したこと](#テーブル設計で工夫したこと)
[詳しく](#詳しく)
[まとめ](#まとめ)
[最後に](#最後に)

# 開発したサービス

https://bachelor-derby4.herokuapp.com

[![Image from Gyazo](https://i.gyazo.com/98a562d5e414be445596ab9756129828.jpg)](https://bachelor-derby4.herokuapp.com)
11/25からAmazonPrimeVideoで配信される「バチェラー・ジャパン シーズン4」の優勝予想ゲームです。
ポイント制のゲームで、「途中で予想を変えると減点だが、予想した人が脱落するともっと減点される」
という減点法を採用しています。

### バチェラー・ジャパンとは
> アメリカの人気恋愛リアリティ番組「The Bachelor（原題）」の日本版。バチェラーとは独身男性の意で、番組では1人の幸運なバチェラーが、一般応募で集まった15人の女性とのグループ・パーティーや2人きりのロマンチックなデートを経て、最終的に自分に最もふさわしい女性1人を選び出す「婚活サバイバルゲーム」。

今回は全4話で、各放送回で徐々に候補者（=番組の女性メンバー）が減っていくイメージです。

### ポイント
ここでポイントなのは、
**以下のユーザーに対して減点が行われる必要がある**ということです。
①BETする候補者を変更したユーザー
②管理者が候補者を脱落させたとき、その候補者にBETしていたユーザー

# テーブル設計で工夫したこと
今回のテーブル設計で唯一意識したのは、ユーザーの得点が
### 「操作によって変動する」のではなく、「履歴によって算出される」設計にする
ということだけです。
以降で詳細の説明をしていきます。

# 詳しく
### 「操作によって変動する」とは

今回の処理を普通にやろうとすると、テーブル設計は以下のような感じになるかと思います。


[![Image from Gyazo](https://i.gyazo.com/76d12df8d320135d171b325e3079f072.png)](https://gyazo.com/76d12df8d320135d171b325e3079f072)

```ruby:各モデルの関連付けとenumの定義
# 本サービスのユーザー
class User < ApplicationRecord
  belongs_to :candidate
end

# バチェラーに参加する女性候補者
class Candidate < ApplicationRecord 
  has_many :users

  # 候補者の生き残り情報
  enum :status { active: 0, dropout: 1 }
end
```

一人の候補者が多数のユーザーにBETされるのでこのような関連付けになると思います。
そしてコントローラのロジックは一般的に以下のような感じになると思います。

①BETする候補者を変更したユーザーへの減点（-10点）

```ruby:users_controller.rb
class UsersController < ApplicationController
  # （中略）
  def update
    @user = User.find(params[:id])
    @user.assign_attributes(user_params)
    if @user.save
      @user.points -= 10 # 減点処理
      redirect_to ...
    else
      render ...
    end
  end

  private

  def user_params
    require(:user).permit(..., :candidate_id)
  end
end
```

②管理者が候補者を脱落させたとき、その候補者にBETしていたユーザーへの減点（-20点）

```ruby:admin/candidates_controller.rb
class Admin::CandidatesController < ApplicationController
  # （中略）
  def update
    @candidate = Candidate.find(params[:id])
    @candidate.dropout!
    @candidate.users.each {|user| user.points -= 20 } # 減点処理
    redirect_to ...
  end
  # （中略）
end
```

上記の①はユーザーの**「操作」**、②は管理者の**「操作」**によって
Userのpointsカラムの値が**「変動」**する設計であることが分かると思います。
これが「操作によって変動する」設計です。

減点処理は一行で記述できており、シンプルなCRUDの記述に近いといえば近いのですが、
以下の点で可読性に改善の余地があると考えられます。
　・一つのアクションに二つの関心事がある
　　（①は`user.candidate`と`user.points`、②は`candidate.status`と`user.points`）
　・②はCandidateのコントローラなのにUserに関する処理が記述されている

どんなにロジックをモデルに移したとしても、最低一行はコントローラを肥やすことになるのです。

### 「履歴によって算出される」とは

一方、今回採用したテーブル設計はこんな感じです。

[![Image from Gyazo](https://i.gyazo.com/9d52b0fe2bcdc8cbc2e10da6931e18f1.png)](https://gyazo.com/9d52b0fe2bcdc8cbc2e10da6931e18f1)

```ruby:各モデルの関連付け
class User < ApplicationRecord
  has_many :bettings
  has_many :candidates, through: :bettings
end

# UserとCandidateの中間モデル
class Betting < ApplicationRecord
  belongs_to :user
  belongs_to :candidate
end

class Candidate < ApplicationRecord
  has_many :bettings
  has_many :users, through: :bettings
  has_and_belongs_to_many :episodes
end

# 番組の放送回（今回は全4回なので、全部で4つのインスタンスが存在）
class Episodes < ApplicationRecord
  has_and_belongs_to_many :candidates
end
```
※`has_and_belongs_to_many`を使ったことがない人は、
ここでは`has_many :through`と同じ多対多の簡易版と理解していればOKです。

さっきと違う大きなポイントが三つあります。

一つ目は**Candidateのstatusカラムを廃止し、Episodeテーブルを採用している点**です。
これは、`active` or `dropout`だけでなく、いつまで`active`だったのか？の
「履歴」まで残すためです。

候補者は自身が出演する放送回を所有し、
逆に放送回は各回に出演する候補者を所有するイメージです。
（例えばある候補者が2つのEpisodeインスタンスを所有している場合、その候補者は
2話までは出演していたがそこで脱落し、3話には出られなかったということを表します）

二つ目は**UserとCandidateを一対多から多対多に変更している点**です。
これは単純にどのユーザーがどの候補者にBETしているかだけでなく、その履歴まで明確に残すためです。
詳しくは後述します。

三つ目は**Userモデルのpointsカラムを廃止したこと**です。
ここも詳しくは後述しますが、結論からいうと`points`は**カラムではなく
モデルのインスタンスメソッド**としました。

こうすると、コントローラは以下のようになります。

```ruby:bettings_controller.rb
class BettingsController < ApplicationController
  # （中略）
  def create
    @betting = Betting.new(betting_params)
    if @betting.save
      redirect_to ...
    else
      render ...
    end
  end
  # （中略）
end
```

```ruby:admin/episodes_controller.rb
class Admin::EpisodesController < ApplicationController
  # （中略）
  def update
    @episode = Episode.find(params[:id])
    @episode.assign_attributes(episode_params)
    if @episode.save
      redirect to ...
    else
      render ...
    end
  end

  private

  def episode_params
    params.require(:episode).permit(..., candidate_ids: [])
  end
end
```
完全にCRUDに関する記述だけになったと思います。

BETについては、先ほどは**update**だったのが今回は**create**に変わっている点に注目してください。
BETは**変更する**のではなく**履歴を累積する**という考え方にシフトしています。

Admin::EpisodesControllerにおいても、「各放送回にどの候補者が出演したか」を履歴として残しているだけです。
管理画面はこんな感じで、次の出演が決まった候補者にチェックを入れてUpdateするだけでOKです。
[![Image from Gyazo](https://i.gyazo.com/82d51d5be15e941d6539ef94e81e0b50.png)](https://gyazo.com/82d51d5be15e941d6539ef94e81e0b50)


つまりコントローラで行われているのはあくまで履歴の累積だけ、ということになります。

では、肝心の減点処理はどこで行うのか？
これは先ほど少し触れた**Userモデルのインスタンスメソッド**で行っています。

```ruby:user.rb
class User < ApplicationRecord
  # （中略）
  def points
    points = 100

    # BETの変更（2回目以降のBET）に対する減点
    points -= (bettings.count - 1) * 10

    #脱落した候補者にBETしていることに対する減点（1話時点）
    points -= 20 if current_candidate.episodes.count < 2

    #脱落した候補者にBETしていることに対する減点（2話時点）
    points -= 20 if current_candidate.episodes.count < 3

    #脱落した候補者にBETしていることに対する減点（3話時点）
    points -= 20 if current_candidate.episodes.count < 4

    return points
  end

  private

  def current_candidate #　最後にBETした候補者
    bettings.order(created_at: :desc).first.candidate
  end
end
```

こうすることで、まるでpointsカラムを呼び出すかのように`user.points`でユーザーの得点が取得できます。
また、ここでは得点が**変動**している訳ではなく、
あくまで履歴に応じて**算出**されているだけ、ということが分かると思います。

これが「履歴によって算出される」設計です。
まとめると、
　・コントローラはあくまで「起こった事実の履歴（ユーザーのBETと候補者の脱落）」を残すだけ。
　・あとはUserモデルのpointsメソッドが、その履歴を参照して得点を算出する。
という設計になっています。

### 「履歴によって算出される」設計のメリット
これは単純にコントローラがすっきりするだけではないと思っています。

例えば、BETした候補者が脱落したときに発生する減点を
-20点から-30点に変更したくなったとき、
この設計であればpointsメソッドを数行書き換えるだけで済みます。
（逆に「操作によって変動する」設計だとかなり面倒になることは容易に想像できます）

管理者が候補者の出演/脱落を間違えてインプットしてしまったときも同様です。
先ほどお見せしたように、Episodeインスタンスは管理画面で何度でも修正ができます。

ユーザーのBET履歴が残っているので、各ユーザーが
「いつ何が原因で減点されたか」を把握するのも簡単です。

「履歴によって算出される」設計は、可読性だけでなく
保守性やトレーサビリティにも寄与しているといえるでしょう。

# まとめ
・ファットコントローラを解消する手段として、一般的にはコントローラのロジックをモデルに
　移行することが最適とされているが、必要なエンティティとその履歴があれば、
　そもそもコントローラには何も書かなくていい場合もある。
・何らかの変動するデータを取り扱いたいとき、
　ユーザーや管理者の**「操作」**によってそのデータを**「変動」**させるのではなく、
　累積された**「履歴」**を参照しながら都度データを**「算出」**する設計にすることで、
　可読性や保守性、トレーサビリティが向上する。


https://bachelor-derby4.herokuapp.com/

# 最後に
- 記事の分かりにくい箇所や過不足、誤りなどあればコメントいただけると幸いです。
- 記事のコードは分かりやすさ重視のため、実際のコードとは異なる部分があります。
- 本サービスのコードを詳しく知りたい場合は<a href="https://github.com/a0ki-github/bachelor_derby4" target="_blank" rel="noopener noreferrer">GitHub</a>をご覧ください。