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アプリで学ぶデザインパターン - Adapter + ActiveJob + Pub/Sub (4/7)

0
Last updated at Posted at 2026-03-16

はじめに

この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第4回です。
第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 モデルへの表示ロジック混入

Adapter + ActiveJob + Pub/Sub パターン

対応PR:

概要

この記事では3つのパターンを扱います。

パターン 解決する問題
Adapter case/when による通知チャネル分岐をなくす
ActiveJob 通知処理を非同期化してレスポンス時間を短縮
Pub/Sub 処理フローとロギング・監視を疎結合にする

問題:アンチパターン

main ブランチでは、通知ロジックがコントローラにハードコードされています。

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

# インライン 同期通知
case channel
when "email"
  WeeklyReportMailer.report_ready(@report).deliver_now
when "slack"
  Rails.logger.info "[Slack] Weekly report ready: #{@report.id}"
end

@report.update!(notified_at: Time.current)

問題点

  1. 開放/閉鎖原則に違反: 新しいチャネル(LINE, SMS等)を追加するたびに case/when を修正する必要がある
  2. 同期処理でレスポンスが遅い: メール送信(deliver_now)がHTTPレスポンスをブロックする
  3. 関心の混在: 「レポート生成」と「ロギング」が同じコードに混在している

解決策1:Adapter パターン

# app/adapters/notification_adapter.rb

class NotificationAdapter
  # Factory Method: チャネル名からサブクラスを返す
  def self.for(channel)
    case channel
    when "email" then MailNotificationAdapter.new
    when "slack"  then SlackNotificationAdapter.new
    else raise ArgumentError, "Unknown channel: #{channel}"
    end
  end

  def deliver(report:)
    raise NotImplementedError, "#{self.class}#deliver is not implemented"
  end
end
# app/adapters/mail_notification_adapter.rb

class MailNotificationAdapter < NotificationAdapter
  def deliver(report:)
    WeeklyReportMailer.report_ready(report).deliver_now
  end
end
# app/adapters/slack_notification_adapter.rb

class SlackNotificationAdapter < NotificationAdapter
  def deliver(report:)
    Rails.logger.info "[Slack] Weekly report ready: #{report.id}"
  end
end

コントローラは case/when の代わりに NotificationAdapter.for(channel).deliver(report:)
書くだけになります。新しいチャネルを追加する場合は、新しいサブクラスを追加するだけでよく、
既存のコードを変更する必要がありません。


解決策2:ActiveJob による非同期化

# app/jobs/send_weekly_report_job.rb

class SendWeeklyReportJob < ApplicationJob
  queue_as :default

  def perform(weekly_report_id, channel)
    report = WeeklyReport.find(weekly_report_id)

    case channel
    when "email"
      WeeklyReportMailer.report_ready(report).deliver_now
    when "slack"
      Rails.logger.info "[Slack] Weekly report ready: #{report.id}"
    end

    report.update!(notified_at: Time.current)
  end
end

コントローラからの呼び出しは deliver_now から perform_later に変わります。

# Before: 同期(HTTPレスポンスをブロック)
WeeklyReportMailer.report_ready(@report).deliver_now
@report.update!(notified_at: Time.current)

# After: 非同期(すぐにレスポンスを返す)
SendWeeklyReportJob.perform_later(@report.id, channel)

@report.id を渡す点がポイントです。@report オブジェクトをそのまま渡すと
シリアライズの問題が起きる可能性があるため、IDを渡してジョブ内で再度 find します。


解決策3:Pub/Sub(ActiveSupport::Notifications)

# config/initializers/event_subscriber.rb

ActiveSupport::Notifications.subscribe("weekly_report.generate") do |event|
  Rails.logger.info "[Event] weekly_report.generate: #{event.payload.inspect} (#{event.duration.round(2)}ms)"
end

ActiveSupport::Notifications.subscribe("weekly_report.notified") do |event|
  Rails.logger.info "[Event] weekly_report.notified: #{event.payload.inspect}"
end

イベントを発行するのはコントローラ(またはワークフロー)です。

# コントローラ(または Workflow)でイベントを instrument する
ActiveSupport::Notifications.instrument("weekly_report.generate", user_id: current_user.id) do
  # レポート生成処理...
  @report = WeeklyReport.create!(...)
end

# 通知完了後にイベントを発行
ActiveSupport::Notifications.instrument("weekly_report.notified",
  report_id: @report.id, channel:)

Before / After 比較

通知チャネルの追加コスト

# Before: case/when を修正(既存コードに触れる)
case channel
when "email"  then WeeklyReportMailer.report_ready(@report).deliver_now
when "slack"  then Rails.logger.info "..."
when "line"   then LineNotifier.send(...)  # ← 追加のたびに修正
when "sms"    then SmsClient.send(...)     # ← 追加のたびに修正
end

# After: 新しいクラスを追加するだけ(既存コードに触れない)
class LineNotificationAdapter < NotificationAdapter
  def deliver(report:)
    LineNotifier.send(...)
  end
end
# NotificationAdapter.for("line") で自動的に使われる(要: self.for に追記)

同期 vs 非同期

# Before: メール送信がレスポンスをブロック(数秒かかる可能性)
WeeklyReportMailer.report_ready(@report).deliver_now  # ← ここでブロック
redirect_to weekly_report_path(@report)               # ← 完了後にリダイレクト

# After: ジョブをキューに積んですぐにリダイレクト
SendWeeklyReportJob.perform_later(@report.id, channel)  # ← すぐに返る
redirect_to weekly_report_path(@report)                  # ← 即座にリダイレクト

イベント購読の疎結合

# Before: ログ出力がビジネスロジックに混在
def create
  # ...レポート生成...
  Rails.logger.info "Report generated: #{@report.id}"  # ← ログがここに
  # ...通知...
  Rails.logger.info "Notified: #{@report.id}"           # ← ログがここにも
end

# After: ビジネスロジックはイベントを発行するだけ
# ログ出力は initializer の subscriber が担当(関心の分離)
ActiveSupport::Notifications.instrument("weekly_report.generate", ...) do
  # ...レポート生成のみ...
end

まとめ

Adapter パターンのメリット

  • 通知チャネルの切り替えを実装の詳細から隠蔽
  • 開放/閉鎖原則: 新しいチャネルの追加がコードの修正なしにできる
  • 各アダプターを独立してテスト可能

ActiveJob のメリット

  • 時間のかかる処理をバックグラウンドに移してUXを向上
  • 失敗時の自動リトライ機能
  • キューの優先度設定が可能

Pub/Sub(ActiveSupport::Notifications)のメリット

  • 計測・ロギング・監視をビジネスロジックから完全に分離
  • instrument ブロックは自動的に実行時間を計測する
  • 購読側(subscriber)を追加・削除しても発行側のコードを変えなくてよい

3パターンの組み合わせ

これら3つのパターンは独立して使えますが、組み合わせると強力です。

コントローラ
  → instrument でイベント発行(Pub/Sub)
    → レポート生成
    → SendWeeklyReportJob.perform_later(ActiveJob)
      → NotificationAdapter.for(channel).deliver(Adapter)
  → イベント完了をログに記録(subscriber)
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?