Rails: `before_validation`コールバックで複雑なステートを正規化する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: before_validationコールバックで複雑なステートを正規化する(翻訳)

数か月前に投稿した別記事で、ActiveRecordコールバックbefore_validationがいかに誤った理由で乱用されているか、そしてほとんどの場合before_validationは常用すべきでないことについて説明しましたが、before_validationが適切なユースケースがひとつあることについて書き漏らしていました。before_validationが適切なユースケースは、おそらく多くのRailsアプリに該当するでしょう。そこで再びbefore_validationを取り上げ、before_validationの別の姿をご覧いただきたいと思います。

問題の分析

Paymentモデルがあり、そこにamountcurrencyを保存する必要があるとしましょう。ただし、支払いの作成時点の為替レートを用いて正規化したUSドル表記の総額も保存しておいて、統計で使えるようにしたいとします。ここは私たちの(ビジネス)ドメインの重要な部分なので、amount_in_usd属性のバリデーションも追加したいと思います。この時点のPaymentモデルは次のような感じになります。

class Payment < ApplicationRecord
  validates :amount, :currency, :amount_in_usd, presence :true
end

ここで問題になるのは、「amount_in_usdをどこから取得するか」と「amount_in_usdをどのように代入するか」です。

解決方法

解決方法のひとつは、すべての属性を代入するタイミングで直接代入するというものです。この場合次のような感じになるでしょう。

Payment.new(currency: currency, amount: amount, amount_in_usd: CurrencyExchanger.exchange(amount, from: currency, to: "USD"))

この方法の問題は、支払いを初期化するたびにこのロジックを繰り返す必要がある点です。どのシナリオでも再利用できるファクトリークラスを1つ実装してDRYにする方法もあるといえばありますが、少々オーバーヘッドを伴うためRails界隈では人気がありません。しかも内部ステートを管理するので、これはPaymentモデル自身の責務であるように思われます。

以前の記事でも説明したように、amount_in_usdcurrencyamountという2つの属性に依存しているため、ライターをオーバライドする方法では解決できません。しかも、2つの属性が代入される順序も予測できません。

before_validationはまさにこのような場合にうってつけなのです。before_validationは、複数の属性が絡んでいる複雑なステートをかなりエレガントかつシンプルに正規化できます。

class Payment < ApplicationRecord
  validates :amount, :currency, :amount_in_usd, presence :true

  before_validation :assign_amount_in_usd

  private

  def assign_amount_in_usd
    if currency && amount
      self.amount_in_usd = CurrencyExchanger.exchange(amount, from: currency, to: "USD")
    end
  end
end

別の解決方法

本記事の最初のパラグラフで、この解決方法は特にRailsアプリでうまくいくと書きました。つまり、HTTP params由来の「プリミティブな」属性は通常モデルにマスアサインされるという事実があるということです。言うまでもなくRubyではあらゆるものがオブジェクトですが、シンプルに解決するのであれば数値型と文字列型をプリミティブとして扱いましょう。

しかしプリミティブでない値の場合はどうすればよいのでしょうか?上の事例の場合、広く使われているValue Object的なものを使う手もあります。つまりamountcurrencyを持つMoneyオブジェクトです。代入前の属性が他のドメイン指向オブジェクトにも対応付けられる場合は、次のようにさらにシンプルに解決できるでしょう。

money = Money.new(amount, currency)
Payment.new(money: money)

この場合Paymentモデルは次のようになるでしょう。

class Payment < ApplicationRecord
  validates :amount, :currency, :amount_in_usd, presence :true

  def money=(money_object)
    self.amount = money_object.amount
    self.currency = money_object.currency
    self.amount_in_usd = CurrencyExchanger.exchange_money(money_object, to: "USD")
  end
end

この方法は不要なオーバーヘッドを含むようにも見えますが、Value Objectは多くの場合コードをシンプルかつDRYにする傾向があります。さらに複雑なアプリでは、少々のオーバーヘッドを伴ってもValue Objectを使う価値があります。

最後に

before_validationは有用な場合もあります。しかし、複雑なアプリならValue Objectを検討する価値もあるでしょう。

関連記事

Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ