Railsアンチパターン「なんでもConcern」
こんにちは、マネーフォワード福岡にてクラウド債務支払のバックエンドエンジニアをしているMocchiです。
クラウド債務支払のリポジトリは長年運用されており、機能追加や改修を重ねる中で、いくつかの技術的負債が蓄積されています。
今回はクラウド債務支払プロダクトに実際にあった事例を元に、Concernによってかえってコードの複雑さを増し、メンテナンス性を低下させることになってしまったアンチパターンについてまとめてみました。
私自身もこうした実装をしてしまった経験があります。自戒の意味も込めつつ、同じ轍を踏まないための知見として共有できればと思います。
そもそも Concern とは?
Railsガイドより抜粋しました。詳細は Railsガイドをご覧ください。
Railsの「concern(関心事)」とは、大規模なコントローラやモデルの理解や管理を楽にする手法の1つです。複数のモデル(またはコントローラ)が同じ関心を共有していれば、concernを介して再利用できるというメリットもあります。concernはRubyの「モジュール」で実装され、モデルやコントローラが担当する機能のうち明確に定義された部分を表すメソッドをそのモジュールに含めます。なおモジュールは他の言語では「ミックスイン」と呼ばれることもよくあります。
アンチパターン1:Concernにコールバック(before_xxx等)を隠蔽する
「保存時に自動でデータを加工したい」という理由で、Concernの中に before_save や before_validation を仕込んでしまうパターンです。これは「原因の特定が難しいバグ」を引き起こす要因になりがちです。
⚠️ コード例
module OrderValidation
extend ActiveSupport::Concern
included do
# ⚠️ includeしただけで、暗黙的にデータが書き換わるようになる
before_validation :clear_shipping_date_if_invalid
end
def clear_shipping_date_if_invalid
self.shipping_date = nil if some_condition?
end
end
😖 ここが困る
-
「値が変わった?」原因調査: 開発者は
order.saveしただけなのに、意図せず値が変わります。バグ調査の際、どのConcernが悪さをしているのか特定するのが困難になります。 -
実行順序の迷宮: 複数のConcernに
before_saveがある場合、どの順番で実行されるか(includeの順序)に依存し、非常に脆いコードになります。
💊 解決策
素直に保存処理側で値をセットするのが良いと思います。
class OrderForm
include ActiveModel::Model
def save
# 保存処理の中で「明示的に」整形メソッドを呼ぶ
adjust_shipping_date
order.save!
end
private
def adjust_shipping_date
self.shipping_date = nil if some_condition?
end
end
アンチパターン2:include先のメソッドを「暗黙的に期待」する
Concernの中で、include 先のモデルに特定のメソッドや属性が存在することを前提にコードを書いてしまうパターンです。
⚠️ コード例
module OrderCalculatable
extend ActiveSupport::Concern
def calculate_tax
# ⚠️ `items` や `user` が include先に存在しないとエラーになる
return 0 if user.blank?
items.sum(&:price) * 0.1
end
end
😖 ここが困る
-
再利用性の欠如: 「この計算ロジック、
Cartモデルでも使いたい」と思っても、Cartにuserメソッドがなければ動きません。 -
変更に弱い: モデル側のメソッド名が変わった(例:
user→customer)瞬間、Concernが動かなくなります。エラー原因を追うのも困難です。
💊 解決策
メソッドの引数として必要なデータを渡します。(依存性の注入)
module OrderCalculatable
def calculate_tax(user, items)
return 0 if user.blank?
items.sum(&:price) * 0.1
end
end
また、これらの困りごとはMix-inする側とされる側の境界が曖昧なことが原因のため、前提条件を明示するアプローチも考えられます。
module OrderCalculatable
def calculate_tax
# items と user に依存していることを明示する。
raise NotImplementedError unless defined?(items) && defined?(user)
return 0 if user.blank?
items.sum(&:price) * 0.1
end
end
アンチパターン3:Concernで initialize する
モジュール(Concern)は本来「振る舞いをmix-inするため」のものですが、そこに状態を持たせようとして、初期化メソッドを定義してしまうパターンです。
⚠️ コード例
module Loggable
def initialize
@logger = Logger.new(STDOUT)
end
end
class User
include Loggable
def initialize(name)
@name = name
super # ⚠️ ここでsuperを忘れると不具合になる可能性に繋がる
end
end
さらにinitializeのあるモジュールが追加されると...
module Loggable
def initialize
@logger = Logger.new(STDOUT)
end
end
module Authenticatable
def initialize
@auth_token = generate_token
super # ⚠️ ここでsuperを忘れるLoggable#initializeが飛ばれず不具合になる可能性に繋がる
end
end
class User
include Loggable
include Authenticatable
def initialize(name)
@name = name
super # ここで Authenticatable#initialize が呼ばれる
end
end
😖 困ること
-
継承チェーンの断絶: あるConcernが
superを呼び忘れると、それ以降に読み込まれる(チェーンの奥にある)Concernや親クラスのinitializeが実行されず、原因不明のバグを生みます。 -
密結合: Moduleが初期化処理を要求することで、それを使うクラス側も「正しく
superを呼ぶ」という責任を負わされ、再利用性が下がります。
💊 解決策
そもそもモジュールが状態を持たなくて済むなら、必要なデータを引数で渡してもらうのが安全で疎結合です。
module Loggable
# ロガーを引数として受け取る(状態を持たない)
def log_something(logger, message)
logger.info(message)
end
end
class User
include Loggable
def do_something
# 呼び出し側が依存オブジェクト(logger)を渡す
log_something(Rails.logger, "Did something")
end
end
キャッシュ目的などでどうしても状態を持ちたい場合は、initialize ではなく「その変数が初めて必要になった時」に値をセットする遅延初期化を使います。
module Loggable
def logger
# @logger が nil なら作成、あればそれを返す
@logger ||= Logger.new(STDOUT)
end
end
class User
include Loggable
def do_something
# 普通にメソッドを呼ぶだけで安全に使える
logger.info "Did something"
end
end
アンチパターン4:マトリョーシカConcern
Concernが別のConcernをincludeし、さらにそれが別のConcernを……とネストしている状態です。
⚠️ コード例
module A
include B
end
module B
include C
end
class User < ApplicationRecord
include A # ⚠️ Cのメソッドが使えるが、どこから来たか追いづらい
end
😖 困ること
-
定義元の追跡不能:
Userモデルでメソッドが呼ばれた時、それがAなのかBなのかCなのか、追いにくくなります。
💊 解決策
- 継承より委譲: 共通処理はConcernのネストではなく、独立したクラス(Service)に切り出します。
アンチパターン5:ファイル分割リファクタリング
Fat ModelやFat Controllerの解消を意図して、単にコードを別のファイルに移動させただけの状態です。
⚠️ コード例
# app/models/concerns/user/validation_methods.rb
module User::ValidationMethods
# ⚠️ Userモデルのバリデーションをただ移動させただけ
extend ActiveSupport::Concern
included do
validates :name, presence: true
validates :email, presence: true
# ...他20行
end
end
😖 困ること
-
複雑さは変わっていない: 物理的にファイルが分かれただけで、
Userクラスが持つ責務の量(論理的な複雑さ)は減っていません。 - 認知負荷の増大: バリデーションを確認するのにファイルを開く手間が増えます。
💊 解決策
- 意味のあるまとまり(Value ObjectやForm Object)に切り出せないか検討します。(論理的凝集から機能的凝集へ)
- ただし、無理に切り出すよりは、凝集度を保つために
User.rbに記述しておく方が保守性が高い場合もあります。
アンチパターン6:孤独なConcern
そのモデルでしか使わないのに、最初からConcernに切り出してしまうパターンです。
⚠️ コード例
# ⚠️ Userモデルでしか使われていない
module User::Authentication
# ...
end
class User < ApplicationRecord
include User::Authentication
end
😖 困ること
- YAGNI違反: 再利用の予定がなければ切り出さなくて良いと考えます。
- コンテキストスイッチ: ファイルを行き来するコストが増えます。
💊 解決策
- まずはモデル内に
privateメソッドとして書く。 - 本当に他のモデルでも必要になったタイミングで初めてConcernを採用する。
アンチパターン7:テスト困難な塊
Concern単体でテストコードを書くのが難しく、ダミーのApplicationRecordを用意しないとテストできない状態です。
⚠️ コード例
# spec/models/concerns/complex_logic_spec.rb
# ⚠️ テストのためだけにDB接続が必要なダミーモデルを作る必要がある
class FakeModel < ApplicationRecord
include ComplexLogic
end
RSpec.describe ComplexLogic do
# ...
end
😖 困ること
- 結合度の高さ: DBやRailsフレームワークに依存しすぎており、純粋なロジックの検証が遅くなります。
💊 解決策
ロジック部分をRubyクラスに切り出せば、DBなしで高速にテストできます。
# PORO に切り出した例
class ComplexCalculator
def initialize(data)
@data = data
end
def call
# 純粋な計算ロジック
@data.map { |d| d * 2 }
end
end
# RSpec でのテストは DB 不要で高速
RSpec.describe ComplexCalculator do
it "calculates" do
expect(ComplexCalculator.new([1,2,3]).call).to eq([2,4,6])
end
end
ダミークラスにincludeすることでテストを軽量にできます。
# ダミークラスに include して振る舞いを確認する
klass = Class.new do
include OrderCalculatable
def tax_rate_for(region); 0.1; end
end
instance = klass.new
expect(instance.calculate_tax_for(items: [OpenStruct.new(price: 100)], region: :jp)).to eq(10)
アンチパターン8:「とりあえずCommon」
具体的な名前付けを避け、関連性の薄いメソッドを UserCommon や SharedUtils、BaseFunctions といった名前でひとまとめにしてしまうパターンです。
⚠️ コード例
module SharedCommon
extend ActiveSupport::Concern
# 日付フォーマット
def format_date(date)
date.strftime('%Y-%m-%d')
end
# CSV出力
def to_csv
# ...
end
# 外部API連携
def sync_to_salesforce
# ...
end
# 権限チェック
def can_edit?(user)
# ...
end
end
😖 困ること
- 凝集度の欠如: まったく関係ない機能が同居しているため、変更の影響範囲が予測不能になります。いわゆるGod Object(神クラス)のConcern版です。
- 責務の曖昧化: 「共通だから」という理由で安易にまとめると、そのモジュールが「何をするものか」が不明確になり、メンテナンス性が低下します。
💊 解決策
- 偶発的凝集となっている状態だと思います。こちらもアンチパターン5の解決策と同様、意味のあるまとまり(機能的凝集)で分割できないか検討します。(例:
DateFormattable,CsvExportable)。
おわりに
ここまでアンチパターンを紹介してきましたが、私自身、ActiveSupport::Concern という機能自体は Rails の魅力の一つだと考えています。適切に使えば、コードの再利用性を高め、モデルの見通しを良くする武器になります。
しかし、便利すぎるがゆえに、安易な共通化や隠蔽に使われがちなのも事実です。
チーム開発において最も大切なのは「今書いているコードが、1年後の自分やチームメンバーにとって読みやすいか」という視点だと思います。
「とりあえずConcern」の手を一度止めて、「これは本当にConcernであるべきか?」「Service ObjectやPORO(普通のRubyクラス)の方がシンプルではないか?」と自問することで、より堅牢でメンテナンスしやすいアプリケーションに育てていきたいです。
Discussion