RailsのConcernはアンチパターンか?正しい使い方と乱用を避ける代替設計
Ruby on RailsのConcern(ActiveSupport::Concern)は、複数のモデルやコントローラで共通する処理をモジュールにまとめて再利用する仕組みです。手軽に使える一方で「Fat Model対策」「DRY化」を名目に何でも切り出した結果、かえって読みにくく変更しにくいコードを生むことがあります。このため「Railsアンチパターン」と呼ばれることも少なくありません。この記事では、Concernが有効な場面と乱用が生む弊害を切り分け、正しく使う条件と、サービスオブジェクトなど代替設計への置き換え方を実例コードで整理します。
まとめ:Concernは「複数クラスの再利用」なら有効、「肥大化隠し」ならアンチパターン
結論を先に示します。Concern自体は悪ではなく、使いどころで評価が分かれます。
- 有効:タグ付けやログ記録のように、2つ以上のクラスで実際に再利用される副次的な関心事を、依存の少ない単一責務のモジュールとして切り出すとき。
- アンチパターン:Fat ModelやRuboCopの行数警告を消す目的で1クラス専用の処理をファイル分割するだけのとき。実行時の責務は減らず、コードの所在が不透明になる。
- 判断軸:「複数クラスで再利用するか」「include先に依存していないか」「責務は1つか」。1つでもNoならConcern化を保留し、モデルやサービスオブジェクトへの集約を先に検討する。
以降で、仕組み・弊害・乱用の典型例・正しい使い方・代替設計の順に掘り下げます。
ActiveSupport::Concernの仕組み:ミックスインとincludedブロック
ConcernはRubyのモジュールを使ったMix-in(ミックスイン)です。ActiveSupport::Concernをextendしたモジュールとして定義し、対象クラスでincludeするとメソッド・スコープ・コールバックがそのクラスに取り込まれます。モジュール自体はRails 3.0から利用でき、app/models/concerns・app/controllers/concernsディレクトリが読み込みパスへ標準追加されたのはRails 4.0です。ここに置いたモジュールは自動ロードされるためrequireは不要です。
# app/models/concerns/taggable.rb
module Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
end
def tag_names
tags.map(&:name)
end
end
# 利用側のモデル
class Post < ApplicationRecord
include Taggable
end
included do ... endには、include先で評価させたいhas_manyやbefore_actionなどを書きます。クラスメソッドを足したいときは、素のmodule ClassMethodsより、Railsが用意するclass_methods do ... endブロックのほうが簡潔で意図が明確です。素のModule#includedを自前で書く定型を省けるのがActiveSupport::Concernの役割で、この例のように複数のモデルで本当に再利用する共通関心事を切り出すなら妥当な使い方です。Taggableが依存するhas_manyの仕組みはRailsのAssociationで確認できます。
Concernはアンチパターンか:有効な場面と乱用が生む3つの弊害
Concernへの評価が割れるのは、同じ仕組みが「再利用の道具」にも「肥大化の隠し場所」にもなるためです。まず有効な条件を押さえ、次に乱用が招く弊害を具体化します。
有効な場面:複数クラスで再利用する、依存の少ない副次的関心事
Concernが機能するのは、限定的な責務・低依存・複数クラスでの再利用という条件を満たすときです。ログ記録(Auditable)やタグ付与(Taggable)のように、ドメインの本質から一歩引いた共通処理を1機能ずつ切り出す場合が該当します。逆に、この条件を外れたConcernは以下の弊害を生みます。
弊害1:メソッド所在の不透明化と挙動把握コストの増大
メソッドの実体が別ファイルへ移るため、あるクラスの振る舞いを理解するにはincludeしている全Concernを追う必要があります。1クラスで複数のConcernをincludeし、さらにConcernが別のConcernをincludeする入れ子(マトリョーシカ状態)になると、どのモジュール由来のメソッドが動いているか辿るのが難しくなります。
弊害2:暗黙の依存・コールバックによるデバッグ難化
Concern内でbefore_saveなどのコールバックや状態変更を行うと、モデル側には呼び出しが一切書かれていないのに裏で値が書き換わる状況が生まれます。「保存時に勝手にデータが変わる」バグを追うとき、どのConcernの副作用かを特定する手間が増えます。さらにConcernがinclude先のインスタンス変数やメソッドを前提にしていると、モデル側の小さな変更でConcernが壊れる暗黙の双方向依存になります。
弊害3:責務を減らさないファイル分割と技術的負債
Concernへ切り出してファイルが細くなっても、実行時はそのクラスが全機能を担ったままです。責務の数(複雑さ)は減らず、変更や機能追加のたびに複数ファイルを横断する負担だけが残ります。この状態を放置すると、迂闊に手を入れられない箇所が増え、リファクタリングのコストが跳ね上がります。
乱用の典型:Fat ModelとRuboCop警告つぶしのためのConcern分割
Concernがアンチパターン化する最も多いパターンが、肥大化したクラスを「とりあえず分割する」目的での利用です。Railsは「Fat Model, Skinny Controller」を掲げモデルにロジックが集まりやすく、DHH自身も、モデルが担う責務の一部をConcernへ切り出してカプセル化する手法を提唱してきました。ただしその手軽さが乱用の温床にもなります。
Fat Model対策のファイル分割:実行時は肥大したまま
1000行のUserモデルを、バリデーション・計算・通知などのConcernに分けたとします。ファイルは複数に割れても、Userモデルは相変わらず全機能を持つ巨大な存在です。散らかった荷物を別の箱に移して隠しただけで、部屋の片付けは終わっていません。開発者が把握すべき「Userに何ができるか」は一切減っていないのです。
RuboCopのClassLength警告を黙らせるだけの分割
RuboCopのMetrics/ClassLengthは、クラスが既定100行を超えると警告します。これを消す目的でConcernへ小分けにするのは、責務を減らしていないため問題の先送りです。単に見通しを良くしたいだけなら、Rails 4.1で追加されたconcerningを使い、同一ファイル内で論理的にセクション分けするほうが、コンテキストが一箇所に残る分だけ読みやすくなります。
class Post < ApplicationRecord
concerning :Reservation do
included do
has_many :reservations
end
def reservable?
reservations.empty?
end
end
end
コントローラ間の重複ロジックをConcern共通化する誤り
複数のコントローラに同じビジネスロジックがある場合、DRY化のためConcernへ抜き出しがちです。しかし重複しているということは、本来モデルやサービス層に属する処理である可能性が高い、というサインです。
# NG: ビジネスロジックをコントローラのConcernへ逃がす
# app/controllers/concerns/post_findable.rb
module PostFindable
extend ActiveSupport::Concern
def same_category_posts(post)
category_ids = post.category_ids
ids = PostCategory.where(category_id: category_ids).pluck(:post_id)
Post.where(id: ids - [post.id]).includes(:categories).limit(5)
end
end
# OK: 同種の投稿取得はPostモデルの責務として集約する
class Post < ApplicationRecord
def same_category_posts
ids = PostCategory.where(category_id: category_ids).pluck(:post_id)
Post.where(id: ids - [id]).includes(:categories).limit(5)
end
end
# コントローラ側は @post.same_category_posts と呼ぶだけになる
Concernへ逃がすと、同じ機能のバグ調査でモデルとConcernの両方を確認する羽目になり、include可能な性質ゆえ意図しない場所から使われるリスクも残ります。ビジネスロジックはまずモデルへ、モデルが太りすぎるならサービス層へ集約するのが筋です。
正しい使い方:単一責務・低依存・複数クラス再利用の3条件
Concernを使うなら、次の3条件をすべて満たすかを毎回自問します。1つでも欠けるなら、モデルへの集約か後述の代替設計を選びます。
単一責任:1モジュール1機能への限定
1つのConcernに複数の無関係な機能を詰め込まないことが基本です。「認証系」と「ログ出力系」は別モジュールに分け、Auditable(監査ログ)やTaggable(タグ付与)のように名前だけで役割が伝わる命名にします。これは単一責任の原則(SRP)をモジュール粒度で守る、という話でもあります。
低依存:include先に依存せず引数で受け取る設計
Concern内でinclude先のインスタンス変数やメソッドを直接触らず、必要な値は引数で渡します。userやitemsに暗黙依存するのではなくcalculate_tax(user, items)のように受け取る設計です。これは実質的に依存性の注入(DI)で、モジュール単体でのテストも容易になります。どうしても前提メソッドが要るならraise NotImplementedErrorで明示し、誤用を防ぎます。
再利用の確定後に抽出:複数クラスで使う段階まで待つ
1クラスでしか使わない処理は、最初からそのクラス内に置くほうが明示的です。「いつか他でも使うかも」で先にモジュール化せず、2つ目の利用箇所が実際に現れてから抽出します。DRY原則は「同じ知識の重複を避ける」ためのもので、まだ重複していない処理を先回りで共通化する口実ではありません。
Concernの代替設計:サービスオブジェクト・Formオブジェクト・委譲
Fat Modelや重複ロジックの解消は、Concern以外の手段のほうが適する場面が多くあります。共通するのは責務がクラス構造として明示される点で、Concernのように「見えない場所へ隠す」のとは逆の発想です。
- サービスオブジェクト:
OrderProcessorやUserNotifierのように、1つのユースケースをPlain Old Ruby Object(PORO)へ切り出す。モデルから処理フローを丸ごと移せるため、Fat Modelを実際に痩せさせられる。 - Formオブジェクト:複数モデルにまたがる入力やバリデーションを専用オブジェクトへ持たせ、モデルのバリデーションロジックを整理する。
- 委譲・コンポジション:
SimpleDelegatorや自作クラスへ処理を委譲(Delegation)し、機能を継承で「持たせる」のではなく組み合わせる(Composition)。継承より結合が緩く、変更に強い。
これらはオブジェクト間の関係が明示的になり、責務とテスト境界がはっきりします。Fat Modelを健全化する手順は、(1)モデルが担う役割を洗い出し、(2)役割ごとに新クラス(サービス/Form/値オブジェクト)へ切り出し、(3)テストで挙動不変を確認、(4)役目を終えたConcernを削除、という流れです。Concernは「最後の手段」と捉え、まずはモデルと適切な層への集約を検討してください。
よくある質問(FAQ)
Concernは使わないほうがよいのですか?
いいえ。複数クラスで再利用する副次的な処理を、単一責務・低依存で切り出す用途なら有効です。避けるべきは、1クラス専用の処理をFat Model隠しやRuboCop警告つぶしのために分割するケースです。
ビジネスロジックをConcernに書いてはいけませんか?
原則として避けます。ドメインのビジネスロジックはモデルに寄せ、モデルが肥大化するならサービスオブジェクトへ移します。コントローラのConcernへ逃がすとロジックの所在が分散し、保守性が下がります。
Fat Model対策にConcernで分割するのは有効ですか?
ファイルは細くなりますが、実行時の責務は減らないため本質的な解決にはなりません。見た目の整理だけならconcerningで同一ファイル内をセクション分けし、責務そのものを減らしたいならサービスオブジェクト等へ切り出します。
ActiveSupport::Concernを使わず素のモジュールでもよいですか?
可能ですが、includedブロックやクラスメソッド定義、モジュール依存の解決を自前で書く手間が増えます。コールバックやスコープをinclude時に評価させたいならActiveSupport::Concernのほうが簡潔で安全です。