はじめに
この記事は、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?などアクション別に明確に定義できる - 単体テストが容易(
userとrecordを渡すだけ) - Pundit などのgemと思想が同じため、将来的に移行しやすい
Concern のメリット
- 複数モデルで共通のスコープ・メソッド・コールバックを共有
-
included do ... endブロックでモデルへの挿入内容を明確に表現 - Rubyのモジュールなので、テストも通常のモジュールテストと同様
Callback のメリット
- 副作用(監査ログ、通知)をモデルのライフサイクルに結びつける
-
after_commitによりトランザクションの整合性を保証 - コントローラを経由しない経路(Seed、ジョブ)でも必ず実行される
注意点
Callbackは便利ですが過剰に使うとモデルが重くなります。
トランザクションの整合性に関わる副作用(監査ログなど)には after_commit が適切です。
一方、メール送信など重い処理はジョブに委ねるべきです。