【Ruby on Rails】Concern(関心の分離)の使い方

Ruby

こんにちは、かつコーチです。

複数のモデルやコントローラに同じようなコードが増えてきたとき、RailsにはConcernという共通化の仕組みが用意されています。

ActiveSupport::Concernを使うと、モジュールの形で関心事を切り出せますが、便利さゆえに乱用してかえって見通しが悪くなるケースも少なくありません。

今回は、Concernの仕組みと実例、そして乱用を避けるための判断基準を解説します。

ActiveSupport::Concernの仕組み

素のRubyモジュールとの違い

Railsを使わない素のRubyでも、モジュールをincludeして共通処理を混ぜ込むことは可能です。

ただし、モジュール側でクラスメソッドやバリデーション、アソシエーションを定義したい場合、includedフックとClassMethodsモジュールを自前で書く必要があり、コードが煩雑になります。

ActiveSupport::Concernは、この定型的な記述を簡潔にまとめるための仕組みです。

# app/models/concerns/archivable.rb
module Archivable
  extend ActiveSupport::Concern

  included do
    scope :archived, -> { where.not(archived_at: nil) }
    scope :active, -> { where(archived_at: nil) }
  end

  class_methods do
    def archive_all!
      update_all(archived_at: Time.current)
    end
  end

  def archived?
    archived_at.present?
  end

  def archive!
    update!(archived_at: Time.current)
  end
end

included do ... endの中はクラス本体で実行される扱いになり、scopehas_manyなどのクラスマクロをそのまま書けます。

class_methods do ... endで定義したメソッドは、includeしたクラスの特異メソッド(クラスメソッド)として使えるようになります。

モデルへの適用例

「アーカイブ機能」を持つ複数のモデルに、このConcernを適用してみます。

# app/models/article.rb
class Article < ApplicationRecord
  include Archivable
end

# app/models/product.rb
class Product < ApplicationRecord
  include Archivable
end
article = Article.find(1)
article.archive!
article.archived?          # => true
Article.archived           # => アーカイブ済みの記事一覧
Article.archive_all!       # => 全記事を一括アーカイブ

ArticleとProductという異なるモデルに対して、同じ「アーカイブする」という関心事を1つのモジュールで表現できました。

コントローラでの共通処理の切り出し

Concernはモデルだけでなく、コントローラの共通処理にもよく使われます。

たとえば、複数のコントローラで「ページネーション用のパラメータを組み立てる」処理を共通化する例です。

# app/controllers/concerns/paginatable.rb
module Paginatable
  extend ActiveSupport::Concern

  included do
    before_action :set_pagination_params
  end

  private

  def set_pagination_params
    @page = params.fetch(:page, 1).to_i
    @per = params.fetch(:per, 20).to_i.clamp(1, 100)
  end
end
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  include Paginatable

  def index
    @articles = Article.active.page(@page).per(@per)
  end
end

before_actionのようなクラスマクロ(クラス定義時に実行される設定用メソッド)も、includedブロックの中で問題なく使えます。

Concern乱用への注意

「なんとなく共通化」が招く問題

Concernは便利ですが、機能単位ではなく「なんとなく関連しそうなメソッド」を1つのモジュールに詰め込んでしまうケースが後を絶ちません。

こうしたConcernは名前から中身が推測できず、モデル本体を読んでも実際の挙動が追いにくくなります。

複数のConcernを1つのモデルにincludeしすぎると、そのモデルが実際にどのメソッドを持つのか把握するのに、Concernファイルを何個も往復する必要が出てきます。

Before/Afterで見る改善例

❌ Before:関連の薄いメソッドを1つのConcernに詰め込む

module UserHelperMethods
  extend ActiveSupport::Concern

  def full_name
    "#{last_name} #{first_name}"
  end

  def send_welcome_email
    UserMailer.welcome(self).deliver_later
  end

  def calculate_discount_rate
    orders.count >= 10 ? 0.1 : 0.0
  end
end

UserHelperMethodsという名前からは、氏名の整形・メール送信・割引計算という3つの異なる関心事が同居していることが読み取れません。

✅ After:関心事ごとにConcernを分割する

# app/models/concerns/nameable.rb
module Nameable
  extend ActiveSupport::Concern

  def full_name
    "#{last_name} #{first_name}"
  end
end

# app/models/concerns/discountable.rb
module Discountable
  extend ActiveSupport::Concern

  def calculate_discount_rate
    orders.count >= 10 ? 0.1 : 0.0
  end
end

send_welcome_emailのようなメール送信処理は、Concernではなく前回解説したService Objectに切り出す方が適しています。

Concernは「同じモデル内で完結する振る舞いの共通化」に向いていますが、外部サービスとの連携や複数モデルをまたぐ処理には向いていません。

実際にハマった循環参照エラー

私が実際につまずいたのが、Concern同士が互いを参照してしまったケースです。

Archivableの中でTaggableのメソッドを呼び出し、Taggableの中でもArchivableのメソッドを呼び出す実装をしてしまい、includeする順序によってNoMethodErrorが発生しました。

NoMethodError: undefined method `archived?' for #<Article:0x00007f9b1a1b2c10>

Concern同士に依存関係を持たせると、includeの順序に挙動が左右される壊れやすい設計になってしまいます。

Concernは「独立して成立する関心事」を切り出す仕組みだと割り切り、Concern間の直接依存は避けるのが安全です。

まとめ

この記事のポイント

  • ActiveSupport::Concernincludedclass_methodsで共通処理を簡潔に書ける仕組み
  • モデル・コントローラどちらでも、独立した関心事の共通化に使える
  • 関連の薄いメソッドを1つのConcernにまとめると、可読性が下がる
  • Concern同士を依存させると、includeの順序で壊れやすくなるため避ける

次に読むべき記事

→ 次の記事:Railsプロジェクトのディレクトリ構成のベストプラクティス

タグ: Ruby on Rails, 上級者向け, 設計

タイトルとURLをコピーしました