こんにちはFT2(Feature 2 Unit)の松本です。
Ruby on Rails で好きなライブラリは ActiveRecord です。
ActiveRecord の嫌いな機能は callback と STI (Single Table Inheritance)です。
弊社の業務では、RailsのCallbackを使ってDBの変更をKafkaへ流す場合があります。
previous_changes と Callback を組み合わせたとき、Callbackでの挙動で疑問が出てきたので、アプリを作りながらCallbackの挙動を確認していきます。
はじめに
あなたは複数の組織に跨るユーザーを管理するSaaSのアプリケーションプログラマです。
「ユーザーの組織が変更されたら通知を送る」という、よくある要件を実装することになりました。
ActiveRecordのコールバックとprevious_changesを使えば簡単に実装できるはず…と思っていたのですが、
トランザクション内で複数回save!を呼ぶと、期待したタイミングでコールバックが呼ばれないという問題に遭遇しました。
この記事では、実際にコードを動かしながら、コールバックとprevious_changesの挙動を学び、
なぜ問題が起きたのか、どう解決すればよいのかを見ていきます。
この記事で学べること
- ActiveRecordのコールバックの基本的な実行順序
previous_changesがクリアされるタイミング- トランザクション内で複数回saveした時の挙動
- 実践的な問題の解決方法
動作環境
モデル構成
- User(ユーザー): システムの利用者
- Organization(組織): 会社や部署などの組織単位
- Role(ロール): 組織内でのユーザーの役割(管理者、メンバーなど)
これらのモデルは以下のように関連しています。
User ─┬─ belongs_to ─→ Organization
└─ belongs_to ─→ Role
└─ belongs_to ─→ Organization
ポイントは、Roleが組織に紐付いていることです。 つまり、「A社の管理者」と「B社の管理者」は別のRoleレコードとして管理されます。
業務要件
ユーザーが組織を移動する時(例:A社からB社への異動): 1. 所属組織を変更する 2. 古い組織のロール(A社の管理者)を削除 3. 新しい組織のロール(B社のメンバー)を設定 4. この一連の操作をトランザクションで保証 5. 組織変更が完了したら関係者に通知
この「組織変更時の通知」を実装する過程で、コールバックの挙動について深く学ぶことにしましょう。
ステップ1: コールバックの基本を理解する
実装に入る前に、まずはActiveRecordのコールバックがどのような順序で実行されるのかを確認しましょう。
基本的な実行順序
シンプルなUserモデルを作成して、各コールバックがどのタイミングで呼ばれるか確認してみます。
class User < ApplicationRecord before_validation :before_validation_callback after_validation :after_validation_callback before_save :before_save_callback around_save :around_save_callback before_create :before_create_callback around_create :around_create_callback after_create :after_create_callback after_save :after_save_callback after_commit :after_commit_callback private def before_validation_callback = Rails.logger.info('before validation callback method') def after_validation_callback = Rails.logger.info('after validation callback method') def before_save_callback = Rails.logger.info('before save callback method') def around_save_callback Rails.logger.info('around save callback method - before yield') yield Rails.logger.info('around save callback method - after yield') end def before_create_callback = Rails.logger.info('before create callback method') def around_create_callback Rails.logger.info('around create callback method - before yield') yield Rails.logger.info('around create callback method - after yield') end def after_create_callback = Rails.logger.info('after create callback method') def after_save_callback = Rails.logger.info('after save callback method') def after_commit_callback = Rails.logger.info('after commit callback method') end
ユーザーを作成してみましょう。
User.create!(name: "Test User")
実行すると、以下の順序でコールバックが呼ばれます。
before validation callback method
after validation callback method
before save callback method
around save callback method - before yield
before create callback method
around create callback method - before yield
TRANSACTION (0.0ms) SAVEPOINT active_record_1
User Create (0.1ms) INSERT INTO "users" ("name", "created_at", "updated_at", "role_id", "organization_id") VALUES (?, ?, ?, ?, ?) RETURNING "id"
around create callback method - after yield
after create callback method
around save callback method - after yield
after save callback method
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
after commit callback method
ここでのポイント:
- validation → save → create → commit という流れ
- around_* コールバックは yield の前後で処理を挟める
- after_commit は最後に実行される(これが後で重要になります)
トランザクション内での動作
次に、transaction ブロックで囲んだ時の動作を見てみましょう。
User.transaction do User.create!(name: "Test User") end
before validation callback method
after validation callback method
before save callback method
around save callback method - before yield
before create callback method
around create callback method - before yield
TRANSACTION (0.0ms) SAVEPOINT active_record_1
User Create (0.2ms) INSERT INTO "users" ("name", "created_at", "updated_at", "role_id", "organization_id") VALUES (?, ?, ?, ?, ?) RETURNING "id"
around create callback method - after yield
after create callback method
around save callback method - after yield
after save callback method
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
after commit callback method
TRANSACTION (0.0ms) ROLLBACK TRANSACTION
コールバックの実行順序は変わりませんが、SAVEPOINT が作られていることに注目してください。
Railsはネストしたトランザクションを自動的にSAVEPOINTで管理します。
ステップ2: previous_changes を使ってみる
コールバックの基本が分かったところで、次は「どのカラムが変更されたか」を判定する機能を追加します。
これには previous_changes を使います。
previous_changes とは
previous_changes は、保存直後のレコードの変更内容を確認できるActiveRecordの便利な機能です。
変更前の値と変更後の値をHashオブジェクトで取得できます。
user = User.create!(name: "Test User") user.name = "Updated User" user.save! user.previous_changes # => {"name" => ["Test User", "Updated User"], # "updated_at" => [2025-09-19 08:20:03 UTC, 2025-09-19 08:20:09 UTC]}
変更検知の実装
では、previous_changes を使って「name が変更された時だけログを出力する」という機能を実装してみましょう:
class User < ApplicationRecord after_commit :log_name_change, if: -> { previous_changes.key?("name") } private def log_name_change old_name, new_name = previous_changes["name"] Rails.logger.info "Name changed: #{old_name} → #{new_name}" end end
実際に動かしてみます。
user = User.create!(name: "Test User") user.name = "Updated User" user.save!
期待通り、ログが出力されます。
Name changed: → Test User
Previous changes before save: {"id" => [nil, 980190963], "name" => [nil, "Test User"], "created_at" => [nil, 2025-12-23 08:15:09 UTC], "updated_at" => [nil, 2025-12-23 08:15:09 UTC]}
TRANSACTION (0.0ms) SAVEPOINT active_record_1
User Update (0.1ms) UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
Name changed: Test User → Updated User
Previous changes after save: {"name" => ["Test User", "Updated User"], "updated_at" => [2025-12-23 08:15:09 UTC, 2025-12-23 08:15:09 UTC]}
previous_changes は after_commit の中でも正しく値を保持していることが確認できました。
この仕組みを使えば、組織変更時の通知も簡単に実装できそうです。
ステップ3: 組織変更機能を実装する
さて、いよいよ本題の「組織変更時の通知」機能を実装します。
モデルの実装
まず、Organization と Role モデルを追加します。
class Organization < ApplicationRecord has_many :roles has_many :users end class Role < ApplicationRecord belongs_to :organization has_many :users end
次に、User モデルに組織変更機能を追加します。
class User < ApplicationRecord belongs_to :role, optional: true belongs_to :organization, optional: true # 組織が変更されたら通知を送る after_commit :notify_organization_change, if: -> { previous_changes.key?("organization_id") } def change_organization_and_role(new_org_name, new_role_name) # 実際の要件では排他制御も必要だったため、with_lockを使用 ActiveRecord::Base.transaction do role&.with_lock do replace_organization_with(new_org_name, new_role_name) end end end private def replace_organization_with(org_name, role_name) # 組織を変更して保存 self.organization = Organization.find_or_initialize_by(name: org_name) save! # 新しいロールを設定して保存 transaction do self.role = Role.find_or_initialize_by( name: role_name, organization: organization ) role.save! save! # ロールIDを保存 end end def notify_organization_change old_org_id, new_org_id = previous_changes["organization_id"] old_org = Organization.find_by(id: old_org_id)&.name || "なし" new_org = Organization.find_by(id: new_org_id)&.name || "なし" Rails.logger.info "===== 通知: 組織変更 #{old_org} → #{new_org} =====" end end
動かしてみる
では、実際に組織変更を実行してみましょう。
user = User.create!(name: "田中太郎", organization: Organization.create!(name: "A社")) user.change_organization_and_role("B社", "メンバー")
期待される動作:
1. ユーザーの組織がA社からB社に変更される
2. 新しいロールが設定される
3. after_commit が実行され、通知ログが出力される
しかし、実行してみると…
通知が出力されない!
ログを確認しても、===== 通知: 組織変更 ... ===== というメッセージが見当たりません。
なぜでしょうか?
ステップ4: 原因調査 - ログから読み解く
問題を解明するため、詳細なログを確認してみましょう。
previous_changes の中身を各所で出力するように修正します。
def replace_organization_with(org_name, role_name) Rails.logger.info "1回目save前の previous_changes: #{previous_changes.inspect}" self.organization = Organization.find_or_initialize_by(name: org_name) save! Rails.logger.info "1回目save後の previous_changes: #{previous_changes.inspect}" transaction do self.role = Role.find_or_initialize_by(name: role_name, organization: organization) role.save! Rails.logger.info "2回目save前の previous_changes: #{previous_changes.inspect}" save! Rails.logger.info "2回目save後の previous_changes: #{previous_changes.inspect}" end end
実行すると、以下のようなログが出力されます。
1回目save前の previous_changes: {}
Organization Create (0.1ms) INSERT INTO "organizations" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"
User Update (0.0ms) UPDATE "users" SET "updated_at" = ?, "organization_id" = ? WHERE "users"."id" = ?
1回目save後の previous_changes: {"updated_at" => [2025-12-23 07:54:30 UTC, 2025-12-23 07:54:31 UTC], "organization_id" => [980190963, 980190964]}
Role Create (0.0ms) INSERT INTO "roles" ("name", "power", "created_at", "updated_at", "organization_id") VALUES (?, ?, ?, ?, ?) RETURNING "id"
2回目save前の previous_changes: {"updated_at" => [2025-12-23 07:54:30 UTC, 2025-12-23 07:54:31 UTC], "organization_id" => [980190963, 980190964]}
User Update (0.0ms) UPDATE "users" SET "updated_at" = ?, "role_id" = ? WHERE "users"."id" = ?
2回目save後の previous_changes: {"updated_at" => [2025-12-23 07:54:31 UTC, 2025-12-23 07:54:31 UTC], "role_id" => [980190963, 980190964]}
このログから分かることは以下になります。
- 1回目のsave後:
previous_changesにorganization_idの変更が記録される - 2回目のsave後:
previous_changesがrole_idのみが変更として記録される
問題の核心
つまり、after_commit が呼ばれる時には、previous_changes は最後の save! の内容(role_id の変更)しか保持していません。
organization_id の変更情報は失われているため、if: -> { previous_changes.key?("organization_id") } の条件が false となり、通知メソッドが実行されないのです。
previous_changes はいつクリアされるのか?
ActiveRecordの仕様として、save! が成功するたびに previous_changes は新しい内容で上書きされます。
これはRailsアプリケーションの設計上正しい挙動です。
- previous_changes は「直前のsaveでの変更」を記録するもの
- 複数回saveした場合、各saveごとに異なる変更が発生する
- 最後のsaveの変更だけが previous_changes に残る
ステップ5: 解決策を考える
間違ったアプローチ
最初に思いつくのは「2回目のsaveをしない」という方法ですが、これは要件を満たせません。 組織変更とロール変更を同時に行い、かつトランザクションで保証する必要があるからです。
正しいアプローチ
問題の本質は「複数回のsaveで previous_changes が上書きされること」です。 解決策としては、以下のような方法があります。
方法1: 変更をまとめて1回のsaveにする
def replace_organization_with(org_name, role_name) new_org = Organization.find_or_initialize_by(name: org_name) new_role = Role.find_or_initialize_by(name: role_name, organization: new_org) new_role.save! # organization と role を同時に変更して1回のsaveに self.organization = new_org self.role = new_role save! end
方法2: 変更を明示的に記録する
def replace_organization_with(org_name, role_name) # 変更前の値を保存 @organization_changed = organization_id_changed? @old_organization_id = organization_id_was self.organization = Organization.find_or_initialize_by(name: org_name) save! transaction do self.role = Role.find_or_initialize_by(name: role_name, organization: organization) role.save! save! end end def notify_organization_change return unless @organization_changed old_org = Organization.find_by(id: @old_organization_id)&.name || "なし" new_org = organization&.name || "なし" Rails.logger.info "===== 通知: 組織変更 #{old_org} → #{new_org} =====" end
方法3: after_commitではなくサービスオブジェクトで通知
class ChangeOrganizationService def call(user, new_org_name, new_role_name) old_org = user.organization&.name user.transaction do user.replace_organization_with(new_org_name, new_role_name) end notify_organization_change(old_org, new_org_name) end end
まとめ
学んだこと
previous_changesは最後のsaveの内容のみを保持する- トランザクション内で複数回saveすると、途中の変更情報は失われる
after_commitは便利だが万能ではない- 複雑なトランザクション処理では、期待通りに動かないことがある
コールバックに頼りすぎない設計も検討する
- サービスオブジェクトなど、明示的な制御フローの方が分かりやすい場合もある
ベストプラクティス
- トランザクション内で複数回saveする場合、
previous_changesに依存しない - 変更を検知する必要がある場合は、
*_wasや*_changed?を save 前に使う - 複雑な処理はコールバックよりサービスオブジェクトで明示的に実装する
ActiveRecordのコールバックは便利な機能ですが、その挙動を正しく理解していないと思わぬ落とし穴にハマります。 この記事が、同じような問題に遭遇した時の助けになれば幸いです。
