Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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コールバックの逸脱した用法を改善する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。