mybook

Observer パターン — 変更を通知する仕組み

「注文が完了したら全部やれ」

Strategyパターンをマスターしてほっとしたのも束の間、ケンタの次のタスクは注文完了処理だった。

Slackのメッセージには、こんな要件が書かれていた。

注文完了時の処理:
1. 確認メールを送る(即時)
2. 在庫を更新する
3. ポイントを付与する
4. 管理画面の売上ダッシュボードを更新する
5. Slackに通知する(※今週追加)
6. 来月:Google Analyticsにコンバージョンイベントを送る
7. 再来月:CRMシステムに購買履歴を連携する

ケンタは「また増えていく予感がする……」と思いながらも、素直に全部コントローラに書いた。

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def complete
    order = Order.find(params[:id])
    order.update!(status: :completed)
 
    # 注文完了後の処理を全部ここに書く
    OrderMailer.confirmation(order).deliver_later
    order.items.each { |item| item.product.decrement!(:stock, item.quantity) }
    order.user.increment!(:points, (order.total_price / 100).floor)
    Dashboard.refresh_sales_data
    SlackNotifier.post("注文#{order.id}が完了しました。金額: #{order.total_price}円")
 
    render json: { status: "ok" }
  end
end

山田さんのコメントが来た。

「コントローラが注文完了後の全処理を知っている必要があるの?来月『注文完了時にGoogle Analyticsにイベントを送る』が追加されたら、またコントローラを触るの?再来月のCRM連携は?毎月ここを触ることになるよ。」

ケンタはため息をついた。「また同じパターンだ……」

「何に気づいた?」と山田さんが聞いた。

「コントローラが全処理を知りすぎています。Strategyパターンで学んだことと同じ問題が起きています。」

「そう。今回はObserverパターンで解決しよう。」

Observer パターンとは

Observer パターンは、あるオブジェクト(Subject/Observable)の状態が変わったとき、それに依存するオブジェクト(Observer)に自動的に通知するパターンだ。

最もわかりやすい比喩はニュースの購読だ。新聞社(Subject)が記事を書いたら、購読者(Observer)全員に配達する。新聞社は購読者が何人いるか、それぞれが記事をどう使うか(読む、保存する、スクラップする)を知らない。購読者が増えても減っても、新聞社のオペレーションは変わらない。

もう一つの比喩:Youtubeのチャンネル登録だ。Youtuber(Subject)が動画をアップロードすると(状態変化)、チャンネル登録者全員(Observer)に通知が届く。Youtuberは誰が登録しているかを知らなくていい。登録者が増えたからといって、動画のアップロード方法を変える必要はない。

Loading diagram...

肝心な点:Subjectは「誰が聞いているか」を知らない。Observerを追加しても、Subjectのコードは変わらない。

問題のあるコードの解剖

現在のコントローラの問題を、依存関係の図で見てみよう。

Loading diagram...

コントローラが全処理クラスに依存している。これは密結合と呼ばれる状態だ。

問題は4つある。

1. 知りすぎている(高い結合度):コントローラが注文完了後の全処理を知っている。OrderMailerもStockServiceも、どちらが注文完了と関係するかはビジネスの知識だ。ビジネスロジックがコントローラに混入している。

2. 変更のたびにコントローラを触る:新しい処理が追加されるたびにコントローラを修正する。コントローラは本来「リクエストを受けてレスポンスを返す」だけの役割のはずが、ビジネスの変更に毎回引きずられる。

3. テストが複雑になる:コントローラのテストを書くとき、OrderMailer、StockService、PointService、Dashboard、SlackNotifierを全部モックしなければならない。1つ追加されるたびにテストのセットアップが膨らむ。

4. ドメインの知識が散らばる:「注文完了後に何をすべきか」という知識が、コントローラという技術的なレイヤーに埋もれている。ドメインの知識はドメインのクラス(OrderやOrderCompletionService)に置くべきだ。

WARNING

コントローラにビジネスロジックを書くのは「便利屋アンチパターン」だ。コントローラは「HTTPの橋渡し役」に徹するべきで、業務ロジックはモデルやサービスクラスに置く。「Fat Controller, Skinny Model」は避けるべきで、逆の「Skinny Controller, Fat Model/Service」を目指す。

ActiveSupport::Notifications で解決

RailsにはObserverパターンが組み込まれている。ActiveSupport::Notifications がそれだ。イベントを発行する側と受信する側を分離できる。

ステップ1: イベントの発行(Subject)

モデルにイベントを発行するメソッドを追加する。

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :items, dependent: :destroy
 
  enum status: { pending: 0, completed: 1, cancelled: 2 }
 
  # 注文完了処理——イベントを発行するだけ
  def complete!
    transaction do
      update!(status: :completed, completed_at: Time.current)
 
      # 誰が聞いているかは知らない。ただ「完了した」と伝えるだけ
      ActiveSupport::Notifications.instrument(
        "order.completed",
        order: self,
        user: user,
        total_price: total_price
      )
    end
  end
end

重要:Orderモデルは ActiveSupport::Notifications.instrument を呼ぶだけで、その後どんな処理が走るかを一切知らない。

ステップ2: Observerを登録する(Observer)

各処理を担うObserverクラスを作り、イベントを購読させる。

# app/observers/order_mail_observer.rb
class OrderMailObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      event = ActiveSupport::Notifications::Event.new(*args)
      order = event.payload[:order]
 
      # 確認メールは即時送信(ユーザーが待っている)
      OrderMailer.confirmation(order).deliver_later
    end
  end
end
# app/observers/stock_update_observer.rb
class StockUpdateObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      event = ActiveSupport::Notifications::Event.new(*args)
      order = event.payload[:order]
 
      StockService.update_from_order(order)
    end
  end
end
# app/observers/point_granting_observer.rb
class PointGrantingObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      event = ActiveSupport::Notifications::Event.new(*args)
      order = event.payload[:order]
 
      PointService.grant_for_order(order)
    end
  end
end
# app/observers/slack_notification_observer.rb
class SlackNotificationObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      event = ActiveSupport::Notifications::Event.new(*args)
      order = event.payload[:order]
 
      SlackNotifier.order_completed(order)
    end
  end
end

ステップ3: initializerでObserverを登録

# config/initializers/observers.rb
OrderMailObserver.subscribe!
StockUpdateObserver.subscribe!
PointGrantingObserver.subscribe!
SlackNotificationObserver.subscribe!
 
# 来月追加されるAnalyticsは、このファイルに1行追加するだけ
# AnalyticsObserver.subscribe!

ステップ4: コントローラはシンプルに

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def complete
    order = Order.find(params[:id])
    order.complete!  # モデルのメソッドを呼ぶだけ
 
    render json: { status: "ok" }
  end
end

order.complete! の中でイベントが発行され、登録されたObserver全員に通知が届く。コントローラはその後の処理を一切知らない。

新しい処理(Analytics、CRM)を追加するとき:

  1. 新しいObserverクラスを作る
  2. config/initializers/observers.rb に1行追加する

コントローラはもちろん、Orderモデルも触らない。

INFO

order.complete! の中でイベントが発行され、登録されたObserver全員に通知が届く。コントローラはその後の処理を一切知らない。これが「疎結合」だ。発行者は受信者の存在を知らず、受信者は発行者の内部を知らない。お互いがイベントという「契約」だけで繋がっている。

Observerをさらに整理する:集約Observer

Observerが多くなってきたら、関連する処理を集約したObserverクラスにまとめることもできる。

# app/observers/order_completion_observer.rb
class OrderCompletionObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      new.handle(*args)
    end
  end
 
  def handle(*args)
    event = ActiveSupport::Notifications::Event.new(*args)
    order = event.payload[:order]
 
    send_confirmation_email(order)
    update_stock(order)
    grant_points(order)
    notify_slack(order)
  rescue => e
    # Observerの失敗が注文完了をロールバックしないようにする
    Rails.logger.error "OrderCompletionObserver error: #{e.message}"
    ErrorTracker.notify(e, order: order.id)
  end
 
  private
 
  def send_confirmation_email(order)
    OrderMailer.confirmation(order).deliver_later
  end
 
  def update_stock(order)
    StockService.update_from_order(order)
  end
 
  def grant_points(order)
    PointService.grant_for_order(order)
  end
 
  def notify_slack(order)
    SlackNotifier.order_completed(order)
  end
end
# config/initializers/observers.rb
OrderCompletionObserver.subscribe!

エラーハンドリングが重要だ。Observerの1つが失敗しても、注文完了処理全体がロールバックされては困る場合がある。rescue でエラーをキャッチし、ログに記録してから継続する。

ActiveRecord Callbacks との比較

after_saveコールバックじゃダメなんですか?」とケンタが聞いた。

山田さんは「いい質問だ」と答えた。「コールバックはObserverパターンの一種だが、Railsのコールバックには罠がある。」

# 問題のあるパターン — コールバックでの実装
class Order < ApplicationRecord
  after_save :send_confirmation_email,
    if: -> { status_previously_changed?(to: "completed") }
  after_save :update_stock,
    if: -> { status_previously_changed?(to: "completed") }
  after_save :grant_points,
    if: -> { status_previously_changed?(to: "completed") }
  after_save :notify_slack,
    if: -> { status_previously_changed?(to: "completed") }
 
  # 問題1: テスト中のcreate(:order, status: :completed)でも発火する
  # 問題2: 意図しないsaveで副作用が走る
  # 問題3: コールバックが増えるほどデバッグが困難になる
  private
 
  def send_confirmation_email
    OrderMailer.confirmation(self).deliver_later
  end
 
  # ...
end

WARNING

after_saveコールバックはあらゆるsaveで発火する。テスト中の create(:order, status: :completed) でもメールが送られ、SlackNotifierが呼ばれる。テストが遅くなり、外部サービスへの意図しない呼び出しが発生する。ActiveSupport::Notificationsはより明示的に「このイベントを発行した場合だけ」通知できる。

使い分けの目安:

コールバック向きNotifications向き
パスワードのハッシュ化外部サービスへの通知
デフォルト値の設定メール送信
レコードの整合性維持在庫更新(別テーブル)
タイムスタンプの更新分析イベントの送信
バリデーション補助ポイント付与

ルール:「そのモデルのレコードだけに影響する処理」はコールバックで、「他のサービスやテーブルに影響する処理」はNotificationsで。

ActiveJob との組み合わせ

本番環境では、注文完了処理の一部は時間がかかる場合がある。ActiveJobと組み合わせて非同期処理にしよう。

# app/jobs/order_completion_job.rb
class OrderCompletionJob < ApplicationJob
  queue_as :default
  retry_on StandardError, wait: :polynomially_longer, attempts: 3
 
  def perform(order_id)
    order = Order.find(order_id)
 
    # 時間のかかる処理は非同期で実行
    StockService.update_from_order(order)
    PointService.grant_for_order(order)
    SlackNotifier.order_completed(order)
    AnalyticsService.track_order_completion(order)
  end
end
# app/observers/order_completion_observer.rb
class OrderCompletionObserver
  def self.subscribe!
    ActiveSupport::Notifications.subscribe("order.completed") do |*args|
      event = ActiveSupport::Notifications::Event.new(*args)
      order = event.payload[:order]
 
      # メールは即時(ユーザーが画面で待っている)
      OrderMailer.confirmation(order).deliver_later
 
      # 重い処理・外部サービス連携はジョブに委ねる
      OrderCompletionJob.perform_later(order.id)
    end
  end
end

Sidekiqなどのバックグラウンドジョブと組み合わせることで、APIレスポンスを遅延させずに処理を実行できる。在庫更新やポイント付与が数秒かかったとしても、ユーザーへのレスポンスは即座に返る。

イベントの一覧管理

「でも、どこでどんなObserverが登録されているか、追いかけるのが大変になりませんか?」ケンタは懸念を示した。

「いい指摘だ」と山田さんは言った。「Observerパターンの弱点がそこにある。イベント名を一覧にしたドキュメントや、イベントを定数で管理する工夫が重要だ。」

# app/events/order_events.rb
module OrderEvents
  COMPLETED = "order.completed"
  CANCELLED = "order.cancelled"
  REFUNDED  = "order.refunded"
  SHIPPED   = "order.shipped"
end
 
# 使う側
ActiveSupport::Notifications.instrument(OrderEvents::COMPLETED, order: self)
 
# Observerの登録
ActiveSupport::Notifications.subscribe(OrderEvents::COMPLETED) { ... }

定数で管理することで、typoによるバグを防ぎ、「どんなイベントがあるか」が一目でわかる。IDEの補完も効く。

さらに、イベントの一覧をドキュメント化することも重要だ。

# EVENTS.md や docs/events.md に記述
# または専用の管理ファイルを作る
 
# config/initializers/observers.rb
#
# == 発行されるイベントの一覧 ==
#
# order.completed  - 注文完了時
#   payload: { order: Order, user: User, total_price: Integer }
#   Observers: OrderMailObserver, StockUpdateObserver,
#              PointGrantingObserver, SlackNotificationObserver
#
# order.cancelled  - 注文キャンセル時
#   payload: { order: Order, reason: String }
#   Observers: RefundObserver, StockRestoreObserver
#
OrderMailObserver.subscribe!
StockUpdateObserver.subscribe!
PointGrantingObserver.subscribe!
SlackNotificationObserver.subscribe!

テストの書き方

ObserverパターンではSubject(Order)とObserver(各処理クラス)のテストを分けて書ける。

# spec/models/order_spec.rb
RSpec.describe Order do
  describe "#complete!" do
    let(:order) { create(:order, :with_items, status: :pending) }
 
    it "ステータスをcompletedに更新する" do
      order.complete!
      expect(order.reload.status).to eq("completed")
    end
 
    it "order.completedイベントを発行する" do
      expect(ActiveSupport::Notifications).to receive(:instrument)
        .with("order.completed", hash_including(order: order))
 
      order.complete!
    end
  end
end
# spec/observers/order_mail_observer_spec.rb
RSpec.describe OrderMailObserver do
  describe "order.completedイベントの購読" do
    let(:order) { create(:order, :completed) }
 
    it "確認メールを送信する" do
      expect(OrderMailer).to receive(:confirmation)
        .with(order)
        .and_return(double(deliver_later: nil))
 
      # イベントを直接発行してObserverをテスト
      ActiveSupport::Notifications.instrument("order.completed", order: order)
    end
  end
end

SubjectとObserverを独立してテストできるので、テストの責務が明確になる。

AWSでのObserverパターン

AWSのイベント駆動アーキテクチャはObserverパターンそのものだ。

Loading diagram...

S3にファイルがアップロードされると(状態変化)、登録されたLambda関数(Observer)に通知が飛ぶ。S3は「誰が聞いているか」を知らない。

EventBridge はさらに強力だ。AWSサービスやアプリから発行されたイベントを、ルールに基づいて複数のターゲットに配信する。

{
  "source": ["com.myapp.orders"],
  "detail-type": ["OrderCompleted"],
  "detail": {
    "amount": [{ "numeric": [">", 10000] }]
  }
}

このルールに一致するイベントが来たら、Lambda、SQS、SNS、Step Functionsなど様々なターゲットに通知できる。新しい処理を追加するとき、既存のコードに触れずにEventBridgeのルールを追加するだけでいい。

SNS(Simple Notification Service) も同じ考え方だ。1つのSNSトピックに複数のSQSキューをサブスクライブすると、トピックへのメッセージが全キューに届く。新しいサブスクライバーを追加しても、パブリッシャー側は変わらない。

ケンタの気づき

「Observerパターンって、疎結合のためのパターンなんですね。発行者は受信者を知らない。受信者が増えても、発行者は何も変えなくていい。」

山田さんは頷いた。「そう。依存の方向を逆転させるとも言える。コントローラが処理を知っている状態から、処理が自分でイベントを購読する状態になった。コントローラはもはや『誰に何をしてもらうか』を管理しなくていい。」

「でも、どこで何が購読されているか、追いかけるのが大変になりませんか?」

「正直に言うと、その通りだ」と山田さんは言った。「Observerパターンの弱点がそこで、デバッグしにくいという代償がある。『なぜこのメールが送られたのか』を追跡するのが難しくなる。だから、イベント名の一覧管理、ログへのイベント名出力、テストでのイベント発行確認が重要になる。パターンは魔法の解決策じゃない——トレードオフがある。」

ケンタはノートにメモした。

Observerパターン = Subjectは状態変化をイベントとして発行するだけ。Observerはイベントを購読して処理する。両者は直接知り合わない。新しい処理を追加するとき、既存コードは触らない。弱点:デバッグの追跡が難しくなる。


INFO

この章のまとめ

  • Observerパターンは状態変化を購読者に通知する仕組み
  • SubjectはObserverを知らなくていい(疎結合)
  • RailsではActiveSupport::NotificationsがObserverパターンを提供
  • after_saveコールバックより明示的なイベント発行が管理しやすい場面が多い
  • ActiveJobと組み合わせて非同期処理にすることで、レスポンス速度を保てる
  • イベント名は定数で管理し、一覧ドキュメントを整備することで追跡が容易になる
  • AWSのEventBridge・SNS・S3 Event Notificationも同じ考え方
  • 弱点は「どこで何が購読されているか」が追いにくくなること——ログと一覧管理で補う