ActiveRecordコールバック再入門 - previous_changesの落とし穴

こんにちはFT2(Feature 2 Unit)の松本です。

Ruby on Rails で好きなライブラリは ActiveRecord です。 ActiveRecord の嫌いな機能は callbackSTI (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_changesafter_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. 1回目のsave後: previous_changesorganization_id の変更が記録される
  2. 2回目のsave後: previous_changesrole_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

まとめ

学んだこと

  1. previous_changes は最後のsaveの内容のみを保持する

  2. after_commit は便利だが万能ではない

  3. コールバックに頼りすぎない設計も検討する

    • サービスオブジェクトなど、明示的な制御フローの方が分かりやすい場合もある

ベストプラクティス

  • トランザクション内で複数回saveする場合、previous_changes に依存しない
  • 変更を検知する必要がある場合は、*_was*_changed? を save 前に使う
  • 複雑な処理はコールバックよりサービスオブジェクトで明示的に実装する

ActiveRecordのコールバックは便利な機能ですが、その挙動を正しく理解していないと思わぬ落とし穴にハマります。 この記事が、同じような問題に遭遇した時の助けになれば幸いです。