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は誰が登録しているかを知らなくていい。登録者が増えたからといって、動画のアップロード方法を変える必要はない。
肝心な点:Subjectは「誰が聞いているか」を知らない。Observerを追加しても、Subjectのコードは変わらない。
問題のあるコードの解剖
現在のコントローラの問題を、依存関係の図で見てみよう。
コントローラが全処理クラスに依存している。これは密結合と呼ばれる状態だ。
問題は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
endorder.complete! の中でイベントが発行され、登録されたObserver全員に通知が届く。コントローラはその後の処理を一切知らない。
新しい処理(Analytics、CRM)を追加するとき:
- 新しいObserverクラスを作る
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
# ...
endWARNING
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
endSidekiqなどのバックグラウンドジョブと組み合わせることで、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
endSubjectとObserverを独立してテストできるので、テストの責務が明確になる。
AWSでのObserverパターン
AWSのイベント駆動アーキテクチャはObserverパターンそのものだ。
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も同じ考え方
- 弱点は「どこで何が購読されているか」が追いにくくなること——ログと一覧管理で補う