💂

Ruby on Railsアプリケーションにて長期運用に耐えられるようにバリデーションの役割を整理してどのように書くのかを決めた話

2024/12/01に公開

※この記事は「COUNTERWORKS Advent Calendar」の1日目の記事です。

はじめましての方もそうでない方もこんにちは!株式会社カウンターワークスのエンタープライズ事業部のエンジニアのshimです。

以前保守しやすいコードにする為に永続化の処理でRails Wayからあえて外れた話という記事を書きましたが、運用していく上で、諸々ここはどうしていこうかというコミュニケーションを重ねて、様々な運用方針が固まってきました。
今回はRuby on Rails(以下Rails)アプリケーション内のバリデーションについて、弊事業部ではこんな感じで運用しているよ、という事を書きたいと思います。

どのように整理したか

私達は下記の通り整理しました

  • インターフェースにおける入力値のバリデーションで、APIとして許容している値かを検証する
  • モデルの属性操作とデータ操作(永続化)用のメソッドのバリデーションで業務ルールに沿っているかを検証する
  • Active RecordバリデーションでDBに保存する際の絶対に守りたいルールに即しているかを検証する

前提

私達の開発するプロダクトは、バックエンド(Rails)とフロントエンド(Next.js)が分かれていて、RailsはAPIモードで使っています。
RailsのActive Record バリデーションは非常に便利なのですが、全部が全部それでやろうとすると、長くRailsアプリケーションを運用していく中でしんどくなっていく(例 context地獄)はあるあるだと思います。
あまりActive Record バリデーションに役割を持たせすぎたくないと考えました。

インターフェースにおける入力値のバリデーションで、APIとして許容している値かを検証する

こちらは入力箇所ごと(APIごと)に作っていて、純粋に入力値がAPIとして許容している値かどうかのみ見ています。
ですので例えばIDのレコードがDBに存在するか、までは見ていません。

参照系のAPIについては作っても作らなくても良く、作成・更新・削除のAPIについて、入力値がある場合は必ず作るルールとなっています。

Active Modelで作る事も考えましたが、私達はgem dry-rbシリーズのdry-validationdry-schemaを使ってValidatorクラスを作り、そこに型を定義し、バリデーションと型変換をさせています。

下記のような感じで書けます

# 通常クラスごとにファイルを分けています
module Post
  class CreateValidator < Dry::Validation::Contract
    Schema = Dry::Schema.Params do
      required(:parent_id).maybe(:integer, gt?: 0)
      required(:object_type).filled(:string, included_in?: %w[post repost])
      required(:body).filled(:string, max_size?: 140)
    end

    params(Schema)
  end

  class UpdateValidator < Dry::Validation::Contract
    Schema = Dry::Schema.Params do
      required(:body).filled(:string, max_size?: 140)
    end

    params(Schema)
  end
end

# Createの処理
params = {'parent_id' => '1', 'object_type' => 'invalid', 'body' => 'a' * 141}
result = Post::CreateValidator.new.call(params)
unless result.success?
  pp result.errors(full: true).to_h
  # 任意のエラー処理
end

# 整数に変換されている
pp result.to_h[:parent_id] # 1

# Updateの処理
params = {'parent_id' => '1', 'object_type' => 'invalid', 'body' => 'a' * 141}
result = Post::UpdateValidator.new.call(params)
unless result.success?
  pp result.errors(full: true).to_h
  # 任意のエラー処理
end

# bodyしか持っていない
pp result.to_h

ここで許容しない値がきた場合、APIとしてはレスポンスステータスで 422 unprocessable entity を返すようにしていることが多いです。

モデルの属性操作とデータ操作(永続化)用のメソッドのバリデーションで業務ルールに沿っているかを検証する

以前の記事で 永続化の為のメソッドをActiveRecordのモデルに定義する と書きましたが、そのメソッドの引数のバリデーションとなります。
ここでは、たとえば「この要素を必ずX個持ち、最大Y個までである」といったビジネス(業務)ルールに即しているかを見ます。
入力値のバリデーションと重複する事も多々ありますが、重複を許容しています。そしてどちらかと言うと必ず書かないといけない、大事な方はこちらと考えています。

なお、完全にActive Recordバリデーションで表現できるならそちらだけでもOKとしています。
例えば入力文字数などは特定の操作でだけ長くするいという事が起こりづらいので、Active Recordバリデーションに任せていることが多いです。

ここで許容しない値がきた場合、APIとしてはレスポンスステータスで 409 Conflict を返すようにしていることが多いです(フロントエンドや入力値のバリデーションの後にレコードが更新されていたりするケースが想定される為)。

Active RecordバリデーションでDBに保存する際の絶対に守りたいルールに即しているかを検証する

ここでは永続化の際に最低限(絶対に)守りたいルールを記述します。
巨大なデータを入れられないように最大値を決めたり、特定の文字列しか許さない(Enumなど)、NULLを許さない、といった事を記述しています。

なお、モデルの属性操作とデータ操作用メソッドのバリデーションと完全に同じことが書ける場合(どのメソッドからも同じバリデーションを実行する場合)はメソッド側に書かずにこちらだけで表現する場合があります。

終わりに

1つのバリデーションに複数の役割を持たせると大変な事になる事が多いので、重複して冗長となっても役割分担した方が良いなと感じております。
dry-validationもネストしたり結構色々な書き方ができるので、慣れるまでは大変ですが、慣れたり書き溜めていくと似たような書き方を大体どこかでしているので、あまり悩まずに書けるようになっていきます。

最後に、株式会社カウンターワークスでは、共に運用しやすいアプリケーション作りを考えながら事業を前進させるメンバーを募集しています!
興味のある方はぜひ以下のリンクからご応募ください!

COUNTERWORKS テックブログ

Discussion