Scalaでドメイン駆動設計: お手軽な表明DSLの作り方

ドメイン駆動設計ではAggregate Rootが不変条件について最終的な責務を負っているわけですが、Scalaで不変条件を表明する場合、`Predef`の`assert`を使うのが最も手っ取り早い方法です。

下の例は、登録ユーザのEntityのシンプルな実装例です。パスワードを変更する際に、パスワードの長さ等のチェックを行っています。

```scala
case class User(username: String, password: String) {
  def changePassword(nextPassword: String): User = {
    assert(nextPassword.length >= 6, "Password length must be at least 6 characters")
    assert(nextPassword.matches(".*[0-9].*"), "Password must have at least one number")
    assert(nextPassword != username, "Password must not be same with username")
    copy(password = nextPassword)
  }
}
```

こうしておけば、例えば `user.changePassword("pass")` のような許されないパスワード変更に対しては、`AssertionException`が throw されます。

```
java.lang.AssertionError: assertion failed: Password length must be at least 6 characters
```

しかしこの実装は、不変条件違反が例外であるため、そのハンドリングをクライアントコードに強制することができません。シグネチャからは例外が発生することが読み取れないため、クライアントコードが例外のハンドリングの必要性に気づかないことがあります。

そこで`Either`を使って例外が発生することを明示し、例外のハンドリングを強制するようにシグネチャを変更します。

```scala
def changePassword(nextPassword: String): Either[Throwable, User]
```

変更後でも`changePassword`の`assert`は例外を投げるので`Try`で囲む必要があります。

```scala
import scala.util.{ Success, Failure, Try }

def changePassword(nextPassword: String): Either[Throwable, User] = Try {
  assert(nextPassword.length >= 6, "Password length must be at least 6 characters")
  assert(nextPassword.matches(".*[0-9].*"), "Password must have at least one number")
  assert(nextPassword != username, "Password must not be same with username")
} match {
  case Failure(err) => Left(err)
  case Success(_)   => Right(copy(password = nextPassword))
}
```

これで例外は飛ばなくなりましたが、Ubiquitous Languageともドメイン駆動設計の「表明」とも直接関係のないキーワード、Try・Failure・Successが出てきてしまいました。また、matchやLeft・Rightのような形式的なコードも増えてしまい、最初に提示した例外を投げるコードよりも可読性が低くなりました。


Ubiquitous Languageとドメイン駆動設計の「表明」と直接関係のないキーワードが出ずに、かつ、シンプルで宣言的なコードにしたいところです。例えば次のような:

```scala
def changePassword(nextPassword: String): Either[Throwable, User] = assertion {
  assert(nextPassword.length >= 6, "Password length must be at least 6 characters")
  assert(nextPassword.matches(".*[0-9].*"), "Password must have at least one number")
  assert(nextPassword != username, "Password must not be same with username")
} {
  copy(password = nextPassword)
}
```

表明の部分とドメインロジックがブロックに分かれて宣言的で自明なコードになりましたね。

この例の`assertion`関数は`Try`をラップするだけで簡単に作ることができます。

```scala
import scala.util.{ Success, Failure, Try }

def assertion[A, B](r: => A)(value: => B): Either[Throwable, B] = Try(r) match {
  case Failure(err) => Left(err)
  case Success(_)   => Right(value)
}
```

こうしたDSLを用意しておけば、表明が無かったメソッドに表明を追加する改修もシンプルに済むメリットがあります。下の例であれば、戻り値の型を`User`から`Either[Throwable, User]`に変更して、`assertion`のブロックをメソッドの先頭に追加するだけで済みます。

_表明がまだないメソッド_

```scala
//                                     ↓このへんをちょっと変更するだけ
def changeUsername(username: String): User = {
  copy(username = username)
}
```

こうした表明にも、ScalazのValidationやCatsのValidated、wix/accordなどを活用することもできます。ただし、ライブラリに依存してしまうことについては、導入する前に一考の余地があります。ドメインモデルのライフサイクルと技術的なライフサイクルが異なるので、長期的な保守の観点から、ドメインレイヤはできるだけ何にも依存しないほうがいいというのが個人的な見解です。その点では、上記で示した簡素なDSLであれば、ScalaのわずかなAPIにのみ依存しているので、ドメインモデルの保守性にも貢献できるのではないかと思います。


ちなみに、`assertion`を少し変更すれば例外をcatchしない実装もできます。

```scala
def assertion[A](a: ((Boolean, => String) => Unit) => Unit)(value: => A): Either[Throwable, A] = {
  var violation: Option[String] = None
  a((assertion, message) => if (violation.isEmpty && !assertion) violation = Some(message))
  violation match {
    case Some(v) => Left(new AssertionError(v))
    case None    => Right(value)
  }
}

case class User(username: String, password: String) {
  def changePassword(nextPassword: String): Either[Throwable, User] = assertion { assert =>
    assert(nextPassword.length >= 6, "Password length must be at least 6 characters")
    assert(nextPassword.matches(".*[0-9].*"), "Password must have at least one number")
    assert(nextPassword != username, "Password must not be same with username")
  } {
    copy(password = nextPassword)
  }
}
```