{
"$type": "site.standard.document",
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigewikkgstztreppvjveq6hmsfmwpd7zdxpp5djoepkzzqgxlenay"
},
"mimeType": "image/png",
"size": 363252
},
"path": "/entry/2026/02/05/100000",
"publishedAt": "2026-02-05T01:00:00.000Z",
"site": "https://toranoana-lab.hatenablog.com",
"tags": [
"railsguides.jp",
"api.rubyonrails.org",
"toranoana-lab.co.jp",
"@post.body",
"@comment.content",
"@post.body",
"@post.save",
"@post",
"@post.body",
"@post.save",
"@post",
"@post",
"@post.save",
"@post"
],
"textContent": "リファクタリングに ActiveModel::EachValidator はいかがですか\n\n## 外部APIによるチェック処理の重複問題\n\nこんにちは、虎の穴ラボのawamoです。\n先日、Railsアプリの機能改修をしていて気づいたことがありました。\n「あれ、このテキスト分析のチェック処理、他のコントローラーでも見たような...」\n調べてみると、同じ外部APIを呼び出すチェック処理が、複数のコントローラーにコピペで散らばっています。\n\n※ コードは例です。実際のプロダクトコードではありません。\n\n\n # 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 にも同様のコード...\n\nこれは典型的な DRY 原則違反です。修正が必要になったら、すべてのコントローラーを探し回らなければなりません。\n\n皆さんにも、このような経験はないでしょうか。\n長い間運用されてきたサービスであれば目にすることもある問題ではないかと思います。\n本記事では、この「複数のコントローラーに散らばったバリデーション処理」を `ActiveModel::EachValidator` でリファクタリングし、モデル層に集約した際のことを紹介します。\n\nここでは、特に外部のAPIをラップした TextAnalyzer クラスが用意されていた場合について記載します。\n\n## リファクタリング前の問題点の整理\n\nまず、既存コードの何が問題なのかを明確にしましょう。\n\n### 問題1: 同じ処理が複数箇所に存在\n\n\n # 3つのコントローラーに同じコード... analyzer = TextAnalyzer.new analyzer.analyze(value) if analyzer.invalid? # エラー処理 end\n\nチェック処理の追加・削除・修正が必要になった場合、すべてのコントローラーを探して変更を行う必要があります。\n\n### 問題2: コントローラーの責務が肥大化している\n\nコントローラーは本来「リクエストを受けてレスポンスを返す」ことに集中すべきです。バリデーションロジックがコントローラーにあると以下の問題が起こり得ます。\n\n * コントローラーのテストが複雑になる\n * ビジネスロジックがリクエストと密結合になる\n\n\n\n### 問題3: モデルの `valid?` と連動しない\n\nコントローラーでチェックをしていると、`model.valid?` を呼んでもそのチェックは実行されません。これにより以下の問題が起こりえます。\n\n * Form Object や Service からの保存時にチェックが漏れる\n * `create` と `update` で、意図せず挙動が変わる\n\n\n\n## 解決策: EachValidator でモデル層に集約\n\nこっらの問題への解決策は、ActiveModel::EachValidator を用いたカスタムバリデータの作成です。\n\nrailsguides.jp\n\napi.rubyonrails.org\n\n### 特定の属性検証に適した EachValidator の採用\n\nカスタムバリデータを作成するのには他にも使えるクラスがあります。\nただ、今回のようなテキスト分析は「本文」「コメント」など特定のカラムに対して行うため、`EachValidator` の方が良いでしょう。\n\nクラス | 用途 | 使用シーン\n---|---|---\n`ActiveModel::Validator` | レコード全体を検証 | 複数カラムの組み合わせチェック\n`ActiveModel::EachValidator` | 特定の属性を検証 | 1つのカラムに対する検証\n\n### リファクタリング後のメリット\n\n**1. 宣言的で読みやすい記述になる**\n\n\n # 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\n\n**2. 外部APIロジックをモデルからも完全分離**\n\nチェックはチェック単体で検証できるので、コントローラーとバリデーションのそれぞれで単体テストが容易になります。\n\n**3. どこから保存しても必ずチェックされる**\n\nコントローラー、Form Object、Service、Job、どこから `save` しても同じバリデーションが走るようになります。\nまた、`create` や `update` についても、意図的に処理をスキップしない限りはチェックが走ります。\n\n## 実装手順: コントローラーからモデルへ移行\n\nそれでは、実際にリファクタリングを進めていきましょう。\n\n### ステップ1: バリデータークラスを作成\n\n`app/validators/` ディレクトリにファイルを作成します(ディレクトリがなければ作成します)。\n\n**ファイル:** `app/validators/text_analyze_validator.rb`\n\n\n 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\n\n### 実装のポイント\n\n#### ポイント1: 早期リターンで不要な処理をスキップ\n\n\n return if value.blank?\n\n空文字や `nil` の場合は、外部APIを呼ぶ必要がありません。`presence: true` など他のバリデーションに任せて良いでしょう。\n\n#### ポイント2: 業務エラーとシステムエラーを分離\n\n\n rescue MyApp::ValidationError => e # ユーザーに伝えるべきエラー(禁止ワード検出など) record.errors.add(attribute, e.message) rescue StandardError => e # 運用上の問題(API障害など) Rails.logger.error(...) record.errors.add(:base, '汎用メッセージ')\n\n * **業務エラー** : 「禁止ワードが含まれています」など、ユーザーが修正可能な内容\n * **システムエラー** : APIタイムアウトなど、ユーザーには詳細を見せず汎用メッセージを表示\n\n\n\n外部APIでのバリデーションで、それに通らなければデータの登録や更新をさせたくない場合などには、システムエラー時には汎用エラーを提示することになるかと思います。\n\n### ステップ2: 命名規則に従う\n\nRails は `validates` に渡されたキー名から、対応するバリデータークラスを自動で探します。\n\nvalidates のキー | 探されるクラス名\n---|---\n`text_analyze` | `TextAnalyzeValidator`\n`presence` | `PresenceValidator`\n`length` | `LengthValidator`\n\nsnake_case のキー名が、CamelCase + `Validator` のクラス名に変換されます。\nRails のCoC(Convention over Configuration)における規約の部分ですね。\n\n### ステップ3: モデルにバリデーションを追加\n\n\n 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\n\n### ステップ4: コントローラーから重複コードを削除\n\n\n # 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\n\n\n # 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\n\nコントローラーがシンプルになり、本来の責務に集中できるようになりました。\n\n## まとめ\n\nコントローラーに散らばっていたバリデーション処理を、`ActiveModel::EachValidator` でモデル層に集約するリファクタリングを解説しました。 外部サービス連携のような「ちょっと重い処理」こそ、コントローラーから追い出してモデル層で整理することで、コードの保守性が向上するかと思います。\n\n「あれ、このチェック他でも見たな...」と感じた際は、`EachValidator` による共通化を検討してみるのはいかがでしょうか。\n\n# Fantia開発採用情報\n\n虎の穴ラボでは現在、一緒にFantiaを開発していく仲間を積極募集中です!\n多くのユーザーに使っていただけるtoCサービスの開発をやってみたい方は、ぜひ弊社の採用情報をご覧ください。\ntoranoana-lab.co.jp",
"title": "リファクタリングに ActiveModel::EachValidator はいかがですか",
"updatedAt": "2026-02-05T01:00:00.000Z"
}