External Publication
Visit Post

リファクタリングに ActiveModel::EachValidator はいかがですか

虎の穴ラボ技術ブログ [Unofficial] February 5, 2026
Source

リファクタリングに ActiveModel::EachValidator はいかがですか

外部APIによるチェック処理の重複問題

こんにちは、虎の穴ラボのawamoです。 先日、Railsアプリの機能改修をしていて気づいたことがありました。 「あれ、このテキスト分析のチェック処理、他のコントローラーでも見たような...」 調べてみると、同じ外部APIを呼び出すチェック処理が、複数のコントローラーにコピペで散らばっています。

※ コードは例です。実際のプロダクトコードではありません。

# posts_controller.rb def create # ... 省略 ... analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? render :new and return end # ... end # comments_controller.rb def create # ... 省略 ... analyzer = TextAnalyzer.new analyzer.analyze(@comment.content) if analyzer.invalid? render :new and return end # ... end # messages_controller.rb にも同様のコード...

これは典型的な DRY 原則違反です。修正が必要になったら、すべてのコントローラーを探し回らなければなりません。

皆さんにも、このような経験はないでしょうか。 長い間運用されてきたサービスであれば目にすることもある問題ではないかと思います。 本記事では、この「複数のコントローラーに散らばったバリデーション処理」を ActiveModel::EachValidator でリファクタリングし、モデル層に集約した際のことを紹介します。

ここでは、特に外部のAPIをラップした TextAnalyzer クラスが用意されていた場合について記載します。

リファクタリング前の問題点の整理

まず、既存コードの何が問題なのかを明確にしましょう。

問題1: 同じ処理が複数箇所に存在

# 3つのコントローラーに同じコード... analyzer = TextAnalyzer.new analyzer.analyze(value) if analyzer.invalid? # エラー処理 end

チェック処理の追加・削除・修正が必要になった場合、すべてのコントローラーを探して変更を行う必要があります。

問題2: コントローラーの責務が肥大化している

コントローラーは本来「リクエストを受けてレスポンスを返す」ことに集中すべきです。バリデーションロジックがコントローラーにあると以下の問題が起こり得ます。

  • コントローラーのテストが複雑になる
  • ビジネスロジックがリクエストと密結合になる

問題3: モデルの valid? と連動しない

コントローラーでチェックをしていると、model.valid? を呼んでもそのチェックは実行されません。これにより以下の問題が起こりえます。

  • Form Object や Service からの保存時にチェックが漏れる
  • createupdate で、意図せず挙動が変わる

解決策: EachValidator でモデル層に集約

こっらの問題への解決策は、ActiveModel::EachValidator を用いたカスタムバリデータの作成です。

railsguides.jp

api.rubyonrails.org

特定の属性検証に適した EachValidator の採用

カスタムバリデータを作成するのには他にも使えるクラスがあります。 ただ、今回のようなテキスト分析は「本文」「コメント」など特定のカラムに対して行うため、EachValidator の方が良いでしょう。

クラス 用途 使用シーン
ActiveModel::Validator レコード全体を検証 複数カラムの組み合わせチェック
ActiveModel::EachValidator 特定の属性を検証 1つのカラムに対する検証

リファクタリング後のメリット

1. 宣言的で読みやすい記述になる

# Before: コントローラーに手続き的なコード def create analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? render :new and return end @post.save end # After: モデルに宣言的に記述 validates :body, text_analyze: true

2. 外部APIロジックをモデルからも完全分離

チェックはチェック単体で検証できるので、コントローラーとバリデーションのそれぞれで単体テストが容易になります。

3. どこから保存しても必ずチェックされる

コントローラー、Form Object、Service、Job、どこから save しても同じバリデーションが走るようになります。 また、createupdate についても、意図的に処理をスキップしない限りはチェックが走ります。

実装手順: コントローラーからモデルへ移行

それでは、実際にリファクタリングを進めていきましょう。

ステップ1: バリデータークラスを作成

app/validators/ ディレクトリにファイルを作成します(ディレクトリがなければ作成します)。

ファイル: app/validators/text_analyze_validator.rb

class TextAnalyzeValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) # 空値は他のバリデーション(presence等)に任せる return if value.blank? analyzer = TextAnalyzer.new analyzer.analyze(value) analyzer.raise_exception! if analyzer.invalid? rescue MyApp::ValidationError => e # 何らかの業務エラー(スパム検出、禁止ワード等) record.errors.add(attribute, e.message) rescue StandardError => e # システムエラー(APIタイムアウト、ネットワーク障害等) Rails.logger.error("TextAnalyzeValidator error=#{e.inspect}") record.errors.add(:base, 'エラーが発生しました。時間を置いて再度お試しください。') end end

実装のポイント

ポイント1: 早期リターンで不要な処理をスキップ

return if value.blank?

空文字や nil の場合は、外部APIを呼ぶ必要がありません。presence: true など他のバリデーションに任せて良いでしょう。

ポイント2: 業務エラーとシステムエラーを分離

rescue MyApp::ValidationError => e # ユーザーに伝えるべきエラー(禁止ワード検出など) record.errors.add(attribute, e.message) rescue StandardError => e # 運用上の問題(API障害など) Rails.logger.error(...) record.errors.add(:base, '汎用メッセージ')
  • 業務エラー : 「禁止ワードが含まれています」など、ユーザーが修正可能な内容
  • システムエラー : APIタイムアウトなど、ユーザーには詳細を見せず汎用メッセージを表示

外部APIでのバリデーションで、それに通らなければデータの登録や更新をさせたくない場合などには、システムエラー時には汎用エラーを提示することになるかと思います。

ステップ2: 命名規則に従う

Rails は validates に渡されたキー名から、対応するバリデータークラスを自動で探します。

validates のキー 探されるクラス名
text_analyze TextAnalyzeValidator
presence PresenceValidator
length LengthValidator

snake_case のキー名が、CamelCase + Validator のクラス名に変換されます。 Rails のCoC(Convention over Configuration)における規約の部分ですね。

ステップ3: モデルにバリデーションを追加

class Post < ApplicationRecord validates :title, presence: true validates :body, presence: true, length: { maximum: 10_000 } validates :body, text_analyze: true, on: :create end class Comment < ApplicationRecord validates :content, presence: true, length: { maximum: 1_000 } validates :content, text_analyze: true, on: :create end class Message < ApplicationRecord validates :body, presence: true validates :body, text_analyze: true, on: :create end

ステップ4: コントローラーから重複コードを削除

# Before class PostsController < ApplicationController def create @post = current_user.posts.build(post_params) # この部分を削除します analyzer = TextAnalyzer.new analyzer.analyze(@post.body) if analyzer.invalid? flash.now[:error] = analyzer.error_message render :new and return end if @post.save redirect_to @post else render :new end end end


# After class PostsController < ApplicationController def create @post = current_user.posts.build(post_params) # Rails でよくみるコントローラーでの保存ロジックと、レンダリング分岐だけ if @post.save redirect_to @post else render :new end end end

コントローラーがシンプルになり、本来の責務に集中できるようになりました。

まとめ

コントローラーに散らばっていたバリデーション処理を、ActiveModel::EachValidator でモデル層に集約するリファクタリングを解説しました。 外部サービス連携のような「ちょっと重い処理」こそ、コントローラーから追い出してモデル層で整理することで、コードの保守性が向上するかと思います。

「あれ、このチェック他でも見たな...」と感じた際は、EachValidator による共通化を検討してみるのはいかがでしょうか。

Fantia開発採用情報

虎の穴ラボでは現在、一緒にFantiaを開発していく仲間を積極募集中です! 多くのユーザーに使っていただけるtoCサービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。 toranoana-lab.co.jp

Discussion in the ATmosphere

Loading comments...