【Scala】type-safe builder パターンを応用して、保存前にバリデーションが必須であることを示す

Javaのビルダーパターンは有名ですね。Javaでイミュータブルにしたいけど、インスタンスの生成が難しかったり、デフォルト値が多く部分的に変えたい場合などによく使われます。Javaの場合だと、setterで準備したくなる誘惑に駆られるわけですね。しかし、状態のミューテーションはバグの元。コンピュータリソースがたくさんある現在のエンプラ領域ではミュータブルは基本アンチパターンと考えられています。私の場合、業務だとAWS SDKを扱う際によくBuilderパターンの実装に出会う気がします。 Javaでは有用なパターンですが、 私が普段使うScalaは基本がイミュータブルなので、あまりBuilderパターンを使う場面に出会うことはほぼありません。しかし、Builderパターンの更なる発展系としての type-safe builder パターンで使われる考え方は非常に有用です。

Builderパターンとtype-safe builder の話は過去に記事で挙げているのでこちらをご確認ください。

tchiba.hatenablog.jp

実装例は下記リポジトリに上げています。

github.com

実装アイデアを利用できそうな状況

  • 処理の順番を明示したい時。例えば、処理Bをする前に処理Aをしてほしいことを明示したい時
  • 状態をクラスメンバではなく、型パラメータによって表現し、コンパイル時にそれで示された状態による事前条件チェックがしたい時

業務でよく出会うDBと通信しながらのバリデーション

業務で開発をしていると、マスタにあるかどうか?とか、既存のものと重複がないかとか色々と通信しながらのバリデーションを行うことは常です。 そして、そのバリデーションをどこで行うか?絶対必須のバリデーションをどうやって明示するかは開発者の悩みの種です。

今回は、例としてドメインサービスによるバリデーションを検討します。 その例として、「ドメイン駆動設計入門」のドメインサービスの章の例を使って考えていきます。

「現実において同姓同名は起こりえますが、システムにおいてはユーザ名の重複を許可しないことはありえます。ユーザ名の重複を許さないというのはドメインのルールであり、ドメインオブジェクトのふるまいとして定義すべきものです。さて、このふるまいは具体的にどのオブジェクトに記述されるべきでしょうか。」

—『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』成瀬 允宣著 https://a.co/hf7h5Nj

上記の本では、ユーザー名の重複をクラスの振る舞いとして持たせるのではなく、ドメインサービスとして実装しましょうと例を挙げています。

下記のようなドメインサービスですね。

/**
 * ユーザーのドメインサービス
 */
trait UserService extends DomainService {

  /**
   * DBに問い合わせをして、ユーザー名が重複してないかを確認する
   *
   * @param user ユーザー
   */
  def exists(user: User): Either[UserNameDuplicateError, Unit]
}

そして、保存の際にこれを使ってチェックすることでバリデーションとします。

gist.github.com

よしっ!

このくらいの簡単な場合は見ればわかりますが、業務システムではもっと要件は複雑で関わる人も多くいます。このバリデーションの仕様を知らない人が、ユーザーをあるとき保存使用しようとしてバリデーションせずにいきなり

userRepository.insert(newUser)

することは容易に想像できます。 後続の開発者になんとかして制約があることを示してあげたいところです。そこで思いだしたのが、type-safe builderです。

型パラメータを利用してバリデーション状態を表現する

type-safe builder を思い出す

Scalaでビルダーパターンを使う機会ってほぼないですが、type-safe builder の考え方はかなり有用です。 type-safe builder で重要な点はADT(抽象データ型)によって状態を表現していることにあります。

最初に紹介した記事のtype-safe builderの実装で下記の記述がありました。

sealed trait BuildStep
sealed trait HasFirstName extends BuildStep
sealed trait HasLastName  extends BuildStep

これはビルドの状態を示しているわけです。HasFirstName の状態でなければ、setLastName することはできません。

def setLastName(lastName: String)(implicit ev: PassedStep =:= HasFirstName): Builder[HasLastName] = {
  this.lastName = lastName
  new Builder[HasLastName](this)
}

また、HasLastName の状態でなければ、build することはできません。

def build(implicit ev: PassedStep =:= HasLastName): Person = {
  new Person(firstName, lastName, age)
}

これはつまり、状態に対する事前条件として機能しているわけです。型パラメータを使わずに書くととこのような状態です。この場合は実行時に呼び出し順が間違っていれば実行事例外が投げられる。

gist.github.com

そう、つまり type-safe builder で用いられている HasFirstNameHasLastName などのADTは状態を示しているというのがわかったと思います。

ADTが状態ならば、バリデーションした、してないの状態を持たせることができるのでは?

それでは、User を書き換えましょう。生成処理を絞り、validate を通すことでのみ、ValidUser が生成できるようにしています。 create 時は生成前なので、NotValid です。

gist.github.com

そして利用する際は下記のようになります。

gist.github.com

上記にも記載しましたが、validation 前にinsertをしようとすると type mismatch でエラーになります。

これで、ADTを利用して、利用者にバリデーションの必要性を示すことができました。

最後に

この実装アイデアは、業務で複数エンティティ間の整合性を担保しなければならないことが判明し、それを直す際に思いつきました。 通常ならばそのような場合は集約を利用して複数エンティティをまとめ、トランザクション整合を保つように実装するのですが設計を変えるのはコストが大きくすぐにはできそうにありませんでした。そこでまずはドメインサービスでバリデーションを追加しようとなるわけですが、業務で扱うようなアプリケーションはまあ複雑です。今実装している人はいいかもしれませんが、後から見たらバリデーションが必要かどうかなんて分かりませんし忘れそうです。なんとかしてバリデーション必須になるように制約をつけたいと考えました。そこで、バリデーションの有無を型パラメータで示せばいいのでは?とtype-safe builderのことを思い出したのです。

問題点もあります。

最終的な実装は入門ドメイン駆動設計では、バリデーションの振る舞いがあるべきはそこじゃないよね?とされる箇所に舞い戻ってしまっています。 理想的には集約にしたい。でもそれもできない。せめて事前条件としてのバリデーションを。それが今回の思いつきです。