0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsアプリで学ぶデザインパターン - Policy Object + Concern + Callback (6/7)

0
Last updated at Posted at 2026-03-16

はじめに

この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第6回です。
第1回から読むことをお勧めします。

記事 何を解決するか
記事1: Value Object ロジックの分散
記事2: Validator Object + Form Object インラインバリデーション
記事3: Query Object + Service Object インラインクエリ・集計ロジック
記事4: Adapter + ActiveJob + Pub/Sub 通知のハードコード・同期処理
記事5: Workflow + Command ファットコントローラ・トランザクション管理
記事6: Policy Object + Concern + Callback 認可・共通スコープ・監査ログ
記事7: Presenter + ViewComponent モデルへの表示ロジック混入

Policy Object + Concern + Callback パターン

対応PR:

概要

この記事では、コントローラとモデルに混入しがちな3種類の関心事を整理するパターンを扱います。

パターン 解決する問題
Policy Object 認可ロジックをコントローラから切り出す
Concern 複数モデルで共通のスコープ・コールバックを共有する
Callback 副作用(監査ログ生成)をモデルのライフサイクルに結びつける

問題:アンチパターン

認可ロジックがコントローラに直書き

main ブランチでは、認可チェックがコントローラのプライベートメソッドに書かれています。

# app/controllers/weekly_reports_controller.rb (main ブランチ)

before_action :authorize_resource!, only: [ :show, :destroy ]

private

def authorize_resource!
  unless @report.user_id == current_user.id
    redirect_to root_path, alert: "権限がありません"
  end
end

「このユーザーはこのレポートを見られるか」というドメイン知識がコントローラに置かれており、
他の場所(API、バックグラウンドジョブ)から同じチェックをしたいときにコピペが必要です。

監査ログ生成がコントローラに混在

# app/controllers/weekly_reports_controller.rb (main ブランチ)

@report = WeeklyReport.create!(...)

# 手動 監査ログ(コントローラに直書き)
AuditLog.create!(
  auditable: @report,
  event:     "weekly_report_created",
  user_id:   current_user.id,
  payload:   { period_start:, period_end:, notified_at: nil }
)

AuditLog.create! の呼び出しがコントローラにあるため、他の経路(APIコントローラ、
バックグラウンドジョブ)からレポートを作成した場合に監査ログが漏れる可能性があります。


解決策1:Policy Object の導入

# app/policies/weekly_report_policy.rb

class WeeklyReportPolicy
  def initialize(user, report)
    @user = user
    @report = report
  end

  def create?
    @user.present?
  end

  def show?
    @report.user_id == @user&.id
  end

  def destroy?
    @user&.admin?
  end
end

コントローラからの使用例。

# app/controllers/weekly_reports_controller.rb (feature/10-policy-object)

def create
  # Policy Object で認可チェック
  policy = WeeklyReportPolicy.new(current_user, nil)
  return redirect_to root_path, alert: "権限がありません" unless policy.create?
  # ...
end

private

def authorize_resource!
  # Policy Object に認可ロジックを委譲
  policy = WeeklyReportPolicy.new(current_user, @report)
  redirect_to root_path, alert: "権限がありません" unless policy.show?
end

解決策2:Concern の導入

# app/models/concerns/trackable.rb

module Trackable
  extend ActiveSupport::Concern

  included do
    scope :recently_modified, -> { order(updated_at: :desc).limit(10) }
    scope :created_this_week, -> { where(created_at: 1.week.ago..) }

    after_create  { Rails.logger.info "[Trackable] #{self.class.name} created: #{id}" }
    after_update  { Rails.logger.info "[Trackable] #{self.class.name} updated: #{id}" }
  end

  def age_in_days
    (Date.today - created_at.to_date).to_i
  end
end
# 複数モデルで include するだけで共有できる
class WeeklyReport < ApplicationRecord
  include Trackable
  # ...
end

class WeightEntry < ApplicationRecord
  include Trackable
  # ...
end

recently_modified スコープ・created_this_week スコープ・ライフサイクルログが
すべてのモデルで使えるようになります。


解決策3:Callback の導入

# app/models/weekly_report.rb (feature/12-callback)

class WeeklyReport < ApplicationRecord
  belongs_to :user
  has_many :audit_logs, as: :auditable, dependent: :destroy

  after_commit :write_audit_log, on: [ :create, :update ]

  private

  def write_audit_log
    AuditLog.create!(
      auditable: self,
      event: "weekly_report_#{saved_change_to_id? ? 'created' : 'updated'}",
      user_id: user_id,
      payload: { period_start:, period_end:, notified_at: }
    )
  end
end

after_commit を使うことで、トランザクションが確定した後に監査ログが作成されます。
after_save と違い、ロールバックされた場合は発火しないため、
存在しないレポートへの監査ログが作成される心配がありません。


Before / After 比較

Policy Object による認可の一元化

# Before: 認可ロジックがコントローラのプライベートメソッドに
def authorize_resource!
  unless @report.user_id == current_user.id
    redirect_to root_path, alert: "権限がありません"
  end
end

# After: Policy Object に委譲
def authorize_resource!
  policy = WeeklyReportPolicy.new(current_user, @report)
  redirect_to root_path, alert: "権限がありません" unless policy.show?
end

# Policy Object 単体でテスト可能
RSpec.describe WeeklyReportPolicy do
  describe "#show?" do
    it "レポートのオーナーは閲覧できる" do
      user = build(:user)
      report = build(:weekly_report, user: user)
      expect(WeeklyReportPolicy.new(user, report).show?).to be true
    end

    it "他ユーザーのレポートは閲覧できない" do
      user = build(:user)
      other_report = build(:weekly_report)
      expect(WeeklyReportPolicy.new(user, other_report).show?).to be false
    end
  end
end

Callback による監査ログの自動化

# Before: コントローラで毎回手動作成(漏れる可能性あり)
@report = WeeklyReport.create!(...)
AuditLog.create!(auditable: @report, event: "weekly_report_created", ...)

# After: モデルが自動的に作成(どこから作成しても必ず記録)
@report = WeeklyReport.create!(...)
# → after_commit で write_audit_log が自動発火
# → AuditLog が自動作成される

after_commit vs after_save

# after_save はトランザクション中に発火(ロールバックされても実行済み)
after_save :write_audit_log  # 危険: ロールバックされても監査ログが残る

# after_commit はトランザクション確定後に発火(ロールバック時は発火しない)
after_commit :write_audit_log  # 安全: データが確定したときのみ記録

Concern によるスコープの共有

# Before: 同じスコープを複数モデルにコピペ
class WeeklyReport < ApplicationRecord
  scope :recently_modified, -> { order(updated_at: :desc).limit(10) }
  scope :created_this_week, -> { where(created_at: 1.week.ago..) }
end

class WeightEntry < ApplicationRecord
  scope :recently_modified, -> { order(updated_at: :desc).limit(10) }
  scope :created_this_week, -> { where(created_at: 1.week.ago..) }
end

# After: Concern に一か所にまとめて include
class WeeklyReport < ApplicationRecord
  include Trackable  # スコープもコールバックも込み
end

class WeightEntry < ApplicationRecord
  include Trackable  # 同じ定義を再利用
end

まとめ

Policy Object のメリット

  • 認可ロジックをコントローラ・モデルから完全に分離
  • create?, show?, destroy? などアクション別に明確に定義できる
  • 単体テストが容易(userrecord を渡すだけ)
  • Pundit などのgemと思想が同じため、将来的に移行しやすい

Concern のメリット

  • 複数モデルで共通のスコープ・メソッド・コールバックを共有
  • included do ... end ブロックでモデルへの挿入内容を明確に表現
  • Rubyのモジュールなので、テストも通常のモジュールテストと同様

Callback のメリット

  • 副作用(監査ログ、通知)をモデルのライフサイクルに結びつける
  • after_commit によりトランザクションの整合性を保証
  • コントローラを経由しない経路(Seed、ジョブ)でも必ず実行される

注意点

Callbackは便利ですが過剰に使うとモデルが重くなります。
トランザクションの整合性に関わる副作用(監査ログなど)には after_commit が適切です。
一方、メール送信など重い処理はジョブに委ねるべきです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?