🐶

Railsアンチパターン「なんでもConcern」

に公開

https://adventar.org/calendars/11579

こんにちは、マネーフォワード福岡にてクラウド債務支払のバックエンドエンジニアをしているMocchiです。

クラウド債務支払のリポジトリは長年運用されており、機能追加や改修を重ねる中で、いくつかの技術的負債が蓄積されています。
今回はクラウド債務支払プロダクトに実際にあった事例を元に、Concernによってかえってコードの複雑さを増し、メンテナンス性を低下させることになってしまったアンチパターンについてまとめてみました。
私自身もこうした実装をしてしまった経験があります。自戒の意味も込めつつ、同じ轍を踏まないための知見として共有できればと思います。

そもそも Concern とは?

Railsガイドより抜粋しました。詳細は Railsガイドをご覧ください。

Railsの「concern(関心事)」とは、大規模なコントローラやモデルの理解や管理を楽にする手法の1つです。複数のモデル(またはコントローラ)が同じ関心を共有していれば、concernを介して再利用できるというメリットもあります。concernはRubyの「モジュール」で実装され、モデルやコントローラが担当する機能のうち明確に定義された部分を表すメソッドをそのモジュールに含めます。なおモジュールは他の言語では「ミックスイン」と呼ばれることもよくあります。

https://railsguides.jp/v7.0/getting_started.html#concernを使う


アンチパターン1:Concernにコールバック(before_xxx等)を隠蔽する

「保存時に自動でデータを加工したい」という理由で、Concernの中に before_savebefore_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

😖 ここが困る

  1. 「値が変わった?」原因調査: 開発者は order.save しただけなのに、意図せず値が変わります。バグ調査の際、どのConcernが悪さをしているのか特定するのが困難になります。
  2. 実行順序の迷宮: 複数の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

😖 ここが困る

  1. 再利用性の欠如: 「この計算ロジック、Cart モデルでも使いたい」と思っても、Cartuser メソッドがなければ動きません。
  2. 変更に弱い: モデル側のメソッド名が変わった(例: usercustomer)瞬間、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 に記述しておく方が保守性が高い場合もあります。

https://ja.wikipedia.org/wiki/凝集度


アンチパターン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」

具体的な名前付けを避け、関連性の薄いメソッドを UserCommonSharedUtilsBaseFunctions といった名前でひとまとめにしてしまうパターンです。

⚠️ コード例

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)。

https://ja.wikipedia.org/wiki/凝集度

おわりに

ここまでアンチパターンを紹介してきましたが、私自身、ActiveSupport::Concern という機能自体は Rails の魅力の一つだと考えています。適切に使えば、コードの再利用性を高め、モデルの見通しを良くする武器になります。

しかし、便利すぎるがゆえに、安易な共通化や隠蔽に使われがちなのも事実です。
チーム開発において最も大切なのは「今書いているコードが、1年後の自分やチームメンバーにとって読みやすいか」という視点だと思います。

「とりあえずConcern」の手を一度止めて、「これは本当にConcernであるべきか?」「Service ObjectやPORO(普通のRubyクラス)の方がシンプルではないか?」と自問することで、より堅牢でメンテナンスしやすいアプリケーションに育てていきたいです。

Money Forward Developers

Discussion