事業紹介 事業紹介トップ 経営データ分析基盤 Claude / MCP 導入 育つ業務アプリ 複雑な SaaS を専用 UI に Shopify Plus 移行・拡張 生成AI 活用(Multi AI) SEO / AIO / 広告運用 顧問・アドバイザリ インフラ構築 自社メディア投資・開発
Claude Claude / MCP 総合 Claude Cowork Claude Code Claude Design MCP サーバー実装
Shopify Plus Shopify Plus トップ EC-CUBE からの移行 大手カートからの移行 Shopify 通常プラン
実績
業界ニュース 業界ニュース トップ AI ニュース └ Claude └ ChatGPT・Codex └ Gemini └ その他 Shopify ニュース SaaS ニュース お知らせ(自社発信)
会社情報 お問い合わせ
2026.05.16

SmartHR の大規模 Rails アプリで belongs_to_required_by_default を安全に有効化した手順と落とし穴

記事のサマリー(TL;DR)

  • SmartHR の基幹 Rails アプリに config.active_record.belongs_to_required_by_default = true を導入するため、既存 belongs_to 全件に optional: true を一括付与し段階的に PR をマージ
  • shoulda-matchersbelong_to マッチャが belongs_to_required_by_default の値で判定ロジックを切り替える仕様により、設定変更後に CI テストが大量失敗
  • :blank:required の i18n メッセージの差異まで対処が必要で、最終的に 4 ステップ・複数 PR に分割して無事完了

大規模 Rails 移行で直面する「デフォルト設定の借金」問題

Rails アプリを長期運用している国内の SaaS 企業では、config.load_defaults が古いバージョンに固定されたまま Rails 本体だけアップグレードを続けるケースが多い。SmartHR の事例が示すように、load_defaults 5.0 相当の設定一つでも、数百モデルを抱えるアプリでは「既存 belongs_to への一括追記」「テストフレームワークの挙動変化」「i18n 定義の差異」という三重の地雷を踏む。kintone や Salesforce のカスタム Rails 連携基盤、あるいは EC-CUBE から移行した独自 Rails アプリを保守している場合も同様の問題が潜在する。AST パーサーを使った自動置換スクリプトをあらかじめ武器として持っておくことが、大規模移行コストを大幅に圧縮する。

詳細

config.active_record.belongs_to_required_by_default = true の概要

この設定は Rails 5.0 のデフォルト(config.load_defaults 5.0)から有効になるもので、モデルに belongs_to :user と書いた際に validates_presence_of :user, message: :required を自動で追加する挙動をします。新規プロジェクトでは当然 on になっていますが、古くから運用しているアプリでは明示的に有効化されていないことがあります。

移行方針:先に optional: true を全件付与する

「基本機能」は長年の開発で膨大な belongs_to が存在するため、素朴に設定を切り替えると既存の振る舞いが壊れます。そこで次の方針を採りました。

  1. 設定変更の前に、既存の全 belongs_tooptional: true を明示的に付与して既存挙動を保護する
  2. 設定を true に変更し、以降に新規追加される belongs_to だけに新しいルールを適用する
  3. 最終的に不要になった optional: true を削除する

全件付与の確認方法:PresenceValidator インスタンスの増減で検知

optional: true が漏れている belongs_to があれば、設定変更前後で ActiveRecord::Validations::PresenceValidator インスタンス数が増えます。config.eager_load = true を設定した開発環境で以下を実行することで差分を確認します。

ActiveRecord::Base.descendants.sum do |model|
  model._validators.values.flatten.count do |v|
    v.is_a?(ActiveRecord::Validations::PresenceValidator)
  end
end

今回は activerecord-multi-tenant が動的に belongs_to を実行していたケースと Active Storage のモデルが例外として見つかりました。

PR を分割して少しずつ適用する

自動スクリプトを適用した結果、1 PR の Files changed が 352 になりました。コンフリクトリスクを下げるため複数 PR に分割してマージを進め、全件への optional: true 付与を完了させました。

「やったか!?」——shoulda-matchers の罠

belongs_tooptional: true を付与したのち belongs_to_required_by_default = true に変更した直後、CI で大量のテストが失敗しました。原因は shoulda-matchers v7.0.1belong_to マッチャの仕様です。

belong_to マッチャの判定ロジック一覧

番号 チェインメソッド 判定条件
1 .required user.errors[:company]I18n.t('errors.messages.required') と一致する要素があるか確認
2 .optional user.errors[:company] の有無のみ確認(メッセージ内容は不問)
3-a なし(belongs_to_required_by_defaultfalse または nil .required 判定の否定が条件
3-b なし(belongs_to_required_by_defaulttrue .required と同じ判定
4 .without_validating_presence presence 確認をスキップ

これまで「基本機能」のテストでは optionalrequired もチェインしていなかったため、設定変更前は 3-a の振る舞い(バリデーションがないことを期待)でテストが通っていました。設定変更後は 3-b に切り替わり、バリデーションが存在しない belongs_to のテストが全て失敗したのです。

:blank:required の i18n 差異という追加の壁

「基本機能」には belongs_to :company に加えて validates :company, presence: true を重複して書くパターンが多数存在していました。これを belongs_to :company, optional: false に一本化したい場合、エラーメッセージの差異が問題になります。

  • validates :company, presence: true → エラーメッセージキーは :blank
  • belongs_to :company, optional: false → エラーメッセージキーは :required

SmartHR の i18n 設定では :blank のみアプリ内で定義されており、:requiredrails-i18n のデフォルト値が使われていました。そのため両者のメッセージ文字列が異なる状態で、既存の shoulda-matchers テストがこの差異を前提として通っていたのです。

最終的な 4 ステップの変更計画

これらの前提を踏まえ、次の順序で作業を完了させました。

ステップ 1:belong_to マッチャに .optional を付与

テストコードの is_expected.to belong_to(:company)is_expected.to belong_to(:company).optional に変更。バリデーション確認が不要なケースは .without_validating_presence を使用。

ステップ 2:validates :company, presence: true の呼び出しを整理

belongs_to :company + validates :company, presence: true のパターンを belongs_to :company, optional: false に一本化し、テスト側を belong_to(:company).required に変更。
あわせて I18n.t('errors.messages.required')I18n.t('errors.messages.blank') と同一メッセージに揃えます(Files changed: 152)。条件付きバリデーション等の手動対応が必要なエッジケースは個別に対応。

ステップ 3:デフォルト設定の変更

config/application.rbconfig.active_record.belongs_to_required_by_default = true を追記。

ステップ 4:一時的に付与した optional: true を削除

ステップ 3 で設定が有効になったことで不要になった optional: true を全件削除。

まとめと教訓

大規模プロジェクトを横断して変更する際は、AST パーサーを使った自動置換スクリプトを作成することが非常に有効です。作業が進むにつれて想定外の落とし穴(マルチテナント gem の動的 belongs_to・shoulda-matchers の判定ロジック・i18n メッセージの差異)が次々と発覚しましたが、内容を精査して一つずつ対処することで、最終的に不具合なく設定変更を完了できました。

今後も Rails のデフォルト設定を一つずつ適用し、最終的に最新 Rails の設定と同等にしていく予定です。