値オブジェクトの表明を使って検証を実装する方法


本題に入る前に、値オブジェクトの表明を使って検証を実装する方法に、必要な検証(バリデーション)と表明(アサーション)の違いについて以下にまとめる。

検証(バリデーション)

以下のブログ記事によると、3種類のバリデーションがあるようです

バリデーションには3種類のバリデーションがある 〜 セキュアなアプリケーションの構造 〜

が、日常的にはこのあたりは区別せずに「入力値の確認」と認識していると思います。厳密に表現すると「入力値が要件を満たしているか妥当性を確認すること」になります。Error, Defect, Fault, Failureの定義と解釈エラー(Error)のカテゴリ。

以下は、バリデーションロジックの一例。値が満たすべき要件を確認するロジックを記述しますが、場合によっては複数の検証を組み合わせて一つの検証を行うことがあります。

object Validators {

  type ValidationResult[A] = ValidatedNel[ValidatedError, A]

  // 個々の入力値が要件を満たしているか妥当性を確認する
  def validateUserAccountId(value: String): ValidationResult[UserAccountId] = {
    ULID.parserFromString(value) match { // IDフォーマットが正しいか確認する
      case Success(result) => result.validNel
      case Failure(ex) => UserAccountIdFormatError(ex.getMessage).invalidNel
    }
  }

  // ...

  // 複数の入力値が要件を満たしているか妥当性を確認する
  // 最初に見つかったバリデーションエラーだけはなく、他の項目でのバリデーションエラーも積み上げられ、呼び出し元に通知される
  // すべての入力値の検証が完了したら、独自型に変換される
  def validateTextMessage(
      replyMessageIdValue: Option[String],
      toAccountIdsValues: Seq[String],
      textValue: String,
      senderId: String
  ): ValidationResult[TextMessage] = {
    (
      validateMessageIdOpt(replyMessageIdValue),
      validateToAccountIds(toAccountIdsValues),
      validateText(textValue),
      validateAccountId(senderId)
    ).mapN { 
      case (replyMessageIdOpt, toAccountIds, text, senderId) =>
        TextMessage(MessageId(), replyMessageIdOpt, toAccountIds, text, senderId)
    }
  } 

}

表明(アサーション)

プログラミングにおける概念のひとつであり、そのプログラムの前提条件を示すのに使われる。アサーションとも呼ばれる。表明は、プログラムのその箇所で必ず真であるべき式の形式をとる。

表明(アサーション)は入力値の検証が目的ではなく、「関数やオブジェクトが持つ特性に対する妥当性を確認すること」Error, Defect, Fault, Failureの定義と解釈欠陥(Defect)のカテゴリ。

オブジェクト指向入門 第2版 原則・コンセプトによると、オブジェクトの表明は以下の役割を持つ。

ー 信頼性の高いソフトウェアの生産を助ける
ー 系統的なドキュメントを提供する
ー オブジェクト指向的ソフトウェアのテストとデバッグのための中心的なツールとなる

表明の種類には以下がある

  • 事前条件
  • 事後条件
  • 不変表明

表明の主な役割は3つある。第1に、信頼性の高いソフトウェアの生産を助ける。第2に、系統的なドキュメントを提供する。そして、第3に、オブジェクト指向的ソフトウェアのテストとデバッグのための中心的なツールとなる。
言語は、クラスと特性に対して表明(事前条件、事後条件、不変表明)を与えることができなければならない。また、ツールを使って表明からドキュメントを生成でき、選択的に実行時に表明を監視できなければならない。
ソフトウェアモジュールの社会においては、クラスは「都市」であり、命令(実行コード)は「行政府」であり、表明は「立法府」である。

「立法府」というメタファはわかりやすい。

値オブジェクトの表明を使って検証を実装する

前置きが長くなったけど、本題に入ります。

一般的に検証と表明は目的が異なるので実装も異なりますが、値の検証は値の表明を使って実装可能です。

以下のように、ユーザアカウントIDオブジェクトのコンストラクタに表明させます。コンストラクタ引数の文字列がULIDに変換できないと不変条件に違反するものとします。

// ユーザアカウントID
class UserAccountId(value: String) {
  private val ulid: ULID = try {  
    ULID.parseFromString(value)
  } catch {
    catch ex: ParseException => // 不変条件違反
      throw new IllegalArgumentException("values is invalid", ex)
  }

  def asULID: ULID = ulid
}

さて、このコンストラクタを使って、ユーザアカウントIDのバリデータを実装してみます。

object Validators {

  type ValidationResult[A] = ValidatedNel[ValidatedError, A]

  def validateUserAccountId(value: String): ValidationResult[UserAccountId] = {
    try {
       new UserAccountId(value).validNel
    } catch {
       case ex: IllegalArgumentException =>
         UserAccountIdFormatError(ex.getMessage).invalidNel
    }
  }
// ...
}

バリデータの実装はすっきりしました。ユーザアカウントIDの値としての不変条件が変われば、バリデータの実装もそれに追従できます。

うーんでも、何か違和感がありますね。バリデータのエラーは想定内のエラーであるため呼び出し元でリカバリできるように、明示的な型としてValidatedNel[UserAccountIdFormatError, UserAccountId]を返しますが、内部実装としてはUserAccountIdが送出する実行時例外(IllegalArgumentException)に頼っています…。

この違和感を解消するために、以下のようにUserAccountId#applyメソッドをTry型に変えた場合は、バリデータの実装としては型安全でわかりやすくなりますが、ここで送出される例外は事前条件違反という意味です。バリデータ以外でもインスタンス化することがあります。たとえば、リポジトリを使ってインスタンスを再構築する場合などです。そういうケースではTry型が邪魔になってしまいます。そもそも事前条件違反は、呼び出し側が契約を守っていない(=バグ。バグは欠陥(defect)に含まれる)ときに生じます。このような状況では、プログラムの実行を継続して、故障(Failure)に発展しないうちに早期にプログラムを停止すべきです。つまるところ、表明はリカバリ可能なエラー型で表現すべきではないと考えます。

object UserAccountId {
   // 表明違反をTryで返すとは…。
   def apply(value: String): Try[UserAccountId] = ...
}

object Validators {

  type ValidationResult[A] = ValidatedNel[ValidatedError, A]

  def validateUserAccountId(value: String): ValidationResult[UserAccountId] = {
    UserAccountId(value) match {
      case Success(result) => result.validNel
      case Failure(ex) => UserAccountIdFormatError(ex.getMessage).invalidNel
    }
  }
// ...
}

先ほど述べた違和感は、おそらくエラー(Error)と欠陥(Defect)の違いからきているものだと思います。このあたりは妥協点になる気がします。やるなら、最初の例のように値オブジェクトのコンストラクタから送出される契約違反(IllegalArgumentException)をキャッチして、バリデーションエラーに変換したほうがよいかもしれません。

ということで、検証は表明と別ロジックにしますか?それとも上記のように表明を使って検証を実現しますか?みなさんの意見を募集中です。