📚

[Ruby]VCにビジネスロジックを書いてはいけない?[Helper/Decorator/ViewObject/N+1クエリ問題]

2025/02/10に公開

昨日の記事の追記について、簡単にしかまとめられてなかったので、詳しくまとめます!


なぜビューに「ビジネスロジック」を書いてはいけない?

そもそも「ビジネスロジック」とは?
ビジネスロジックとは、アプリケーションの「業務のルール」や「処理の流れ」に関するロジックのこと。

前回書いた下記コードは、プレゼンテーション層(View)にビジネスロジックが含まれているのが問題。

管理者: <%= user.owned_groups.pluck(:name).join(", ") %>

「Viewにロジックを書かず、できるだけプレゼンテーションのみに集中させる」
「プレゼンテーション」とは?⇒3層アーキテクチャに関係してくる。


3層アーキテクチャとは?

3層アーキテクチャ(Three-Tier Architecture)とは、アプリケーションを以下の3つの層に分ける設計思想。基本情報でも出てきたような🤔

  1. プレゼンテーション層(Presentation Layer)

    • 役割: ユーザーとのやり取りを担当する(UI・ビュー)
             ユーザーとの対話=プレゼンテーションと覚えた
    • 具体例: View(HTML, CSS, JavaScript, ERB)
    • MVCでの対応: V(View)
    • 重要な点: ここには「データ取得」や「ビジネスロジック」を書かない(表示のみに専念)
  2. アプリケーション層(Application Layer)

    • 役割: アプリケーションのロジック(ビジネスロジック)を処理する
             しかし、Fat Controller(太ったコントローラ)を防ぐためモデルにビジネスロジックを書くことが基本。
    • 具体例: Controller(RailsのController, Service, ViewObject)
    • MVCでの対応: C(Controller)
    • 重要な点: プレゼンテーション層とデータ層の橋渡しをするが、できるだけ薄くする(Fat Controllerを防ぐ)
  3. データ層(Data Layer)

    • 役割: データベースとのやり取りを担当する
    • 具体例: Model(ActiveRecord, Repositoryパターン)
    • MVCでの対応: M(Model)
    • 重要な点: データの取得・保存・更新を担当する。ビジネスロジックが入ることもあるが、Modelが肥大化しやすいので適度に分割する。

MVCと3層アーキテクチャの対応関係

MVCは「3層アーキテクチャ」にほぼ対応している(似ている)けれど、アプリケーション層(コントローラ)にはビジネスロジックを書かず、別の場所(Service, Decorator, ViewObject)に分離するのが理想とする。

3層アーキテクチャ MVCの対応 役割
プレゼンテーション層 View (V) ユーザーにデータを表示する
アプリケーション層 Controller (C) 入力の処理・モデルとViewの橋渡し
データ層 Model (M) データの管理・ビジネスロジック

Fat Controllerを避けるため、「ビジネスロジックはModelに書く」という考え方が強い。
しかし、その分モデルに全部詰め込み、Fat Model(太ったモデル)になり、それもそれで管理が難しくなってしまう。

Fat Model(太ったモデル)を防ぐために、ビジネスロジックを分離する
代わりにHelper / Decorator / ViewObject を使用する。
この3つのやり方どれを使うかは、チームの文化によって異なる。


Helper / Decorator / ViewObject

(1)Helper(ヘルパー)

app/helpers/user_helper.rb にメソッドを定義し、View側ではメソッドを呼ぶだけにする方法。

module UserHelper
  def user_owned_group_names(user:)
    user.owned_groups.pluck(:name).join(", ")
  end
end

コントローラ

def index
  # N+1発生しないようにjoins&preloadしている。eager_loadやincludesでもOK
  @users = User.joins(:owned_groups).preload(:owned_groups)
end

ビューはこれだけで済む。

<% @users.each do |user| %>
  <span class="task-meta">
      管理者: <%= user_owned_group_names(user:) %> # helperのメソッドを呼び出すだけ
  </span>
<% end %>

N+1クエリ問題とは

データの取得時に不必要に多くのクエリ(問い合わせ)が発行される問題。

<% @users.each do |user| %>
  <%= user.owned_groups.pluck(:name).join(", ") %>
<% end %>
@users = User.all

上記コードだと、発生するクエリは以下。

SELECT * FROM users;
SELECT * FROM groups WHERE user_id = 1;
SELECT * FROM groups WHERE user_id = 2;
SELECT * FROM groups WHERE user_id = 3;
...

最初のUserを取得するクエリ(1回) + それぞれのユーザーの owned_groups を取得するクエリ(N回) = 「N+1クエリ」が発生する。
ユーザーが100人いる状況だと、全ユーザの取得(1回)+各ユーザの owned_groups を取得(100回)で、計101回クエリが発生することになる。

問題になる点

  • クエリの数が増えると、データベースへの負荷が高まる
  • パフォーマンスが悪化する(特にデータ量が多い場合)

解決策

  • preloadメソッド
  • eager_loadメソッド
  • includesメソッド
    の使用で解決できる。

(2)Decorator(デコレーター)

ModelとViewの中間にデータの加工の役割を担うもの。gemをインストールが必要となる。
app/decorators/user_decorator.rb を作成し、そこに処理をまとめる。

class UserDecorator < SimpleDelegator
  def owned_group_names
    owned_groups.pluck(:name).join(", ")
  end
end

コントローラにdecorateしてからViewに渡す。

# Controller
def index
  @users = User.includes(:owned_groups).map { |user| UserDecorator.new(user) }
end

ビューはこんな感じ。

管理者: <%= user.owned_group_names %>

(3)ViewObject(ビューモデル)

複数のデータを組み合わせて表示したい時に使う。(ユーザーの情報+その人の投稿数などを1つのオブジェクトとして扱いたい場合)
app/view_objects/user_profile_view.rb を作成する。

class UserProfileView
  def initialize(user)
    @user = user
  end

  def owned_group_names
    @user.owned_groups.pluck(:name).join(", ")
  end

  def task_count
    @user.tasks.count
  end
end

コントローラでViewObjectに変換。

def index
  @users = User.includes(:owned_groups, :tasks).map { |user| UserProfileView.new(user) }
end

ビューで呼び出す。

管理者: <%= user.owned_group_names %> /
合計投稿件数: <%= user.task_count %>

参考文献

ビジネスロジック
https://qiita.com/os1ma/items/25725edfe3c2af93d735
https://qiita.com/os1ma/items/66fb47f229896b32b2e8?utm_source=chatgpt.com
https://qiita.com/masaya8028/items/475c0b3d455523e971ba
https://zenn.dev/norihashimo/articles/a30702d0c68cd5?utm_source=chatgpt.com

Helper / Decorator / ViewObject
https://web-camp.io/magazine/archives/19136/
https://zenn.dev/redheadchloe/articles/014593b949c9cc
https://qiita.com/mmaumtjgj/items/50c8ac82ca5583cac254
https://www.sejuku.net/blog/63786
https://tech.kitchhike.com/entry/2018/02/28/221159

N+1クエリ問題
https://qiita.com/Kazuyaa/items/ec663856925476f237e3?utm_source=chatgpt.com
https://qiita.com/ostk0069/items/23beb870adf785506be2?utm_source=chatgpt.com
https://scrapbox.io/yana-gi/N+1問題_preload_%2F_eager_load_%2F_include_の違い

Discussion