個人的におすすめなドメインサービスの書き方

DDDの実装パターンでドメインサービスがあります。 ドメインサービスは、集約や値オブジェクト、エンティティでは扱えない操作を代わりにやる「その他」的なポジションで、ベスプラ的な実装方法がないように思います。ここでは、例としてイメージしやすいメールアドレスがユニークであることをチェックするドメインサービスを例に、よくある実装と、そのペインを確認し、ありたい姿を示した後、最後にその解決策として、ドメインサービスからエンティティへメッセージを送るようにする実装を示します。

扱う例

ここでは、例としてシンプルな、ユーザーのメールアドレスを変更するというユースケースを扱います。

  • ストーリー: ユーザーは自身のメールアドレスを変更することができる
  • 制約: メールアドレスはシステム内で一意である必要がある

処理の流れとしては以下のようになるでしょう。

  1. ユーザーは、メールアドレスの変更をシステムにリクエストする
  2. システムは、リクエストされたメールアドレスがユニークであるかどうかをチェックする
  3. メールアドレスがユニークであれば、Userオブジェクトのemailの値を、リクエストした値に書き換えて保存する

よくある実装

まずは User オブジェクトにメールアドレスを変更するメソッドを定義していきます。

final class User(
  id: UserId,
  name: Option[String],
  email: EmailAddress
) extends Entity[UserId] with Aggregate {
  
  // メールアドレスを変更するメソッド
  // ここで引数で渡されたメールアドレスがユニークかどうかは判断できないので、
  // 暗黙的にドメインサービスを経由したことを期待している
  def changeEmail(email: EmailAddress) = copy(email = email)

  private def copy(...) = { ... }
}

次に、メールアドレスがユニークであることを検証するドメインサービスを準備します。詳細な実装は重要ではないため、ここでは省きます。

trait UserEmailUniqueChecker extends DomainService {
  
  /**
    * 引数のメールアドレスがユニークであるかどうかを調べる
    */
  def check(email: EmailAddress): Boolean
}

この二つを用いて、ユースケースでこの仕様を実現するのが一番簡易的でよくある実装かと思います。

class ChangeEmailUseCase @Inject() (
  userRepository: UserRepository,
  userEmailUniqueChecker: UserEmailUniqueChecker
) {
  import ChangeUserEmailOutput._
  
  def handle(input: ChangeUserEmailInput): ChangeUserEmailOutput = {
    val result = for {
     // 入力されたユーザーが存在するか?
      user <- userRepository.findBy(input.userId).toRight(NotFoundUser(input.userId))
     // 入力されたメールアドレスはユニークか?
      _ <- Either.cond(userEmailUniqueChecker.check(input.email), (), EmailIsNotUnique(input.email))
    } yield {
      val emailChangedUser = user.changeEmail(input.email)
      userRepository.update(emailChangedUser)
      Success(emailChangedUser)
    }

    result.merge
  }
}

全体像

gist.github.com

この実装のペインは何か?

よくある実装の大きなペインは以下の2つです。

  • エンティティの状態変更の際に仕様上必要なバリデーションのありかが、Entityのメソッドからわからない。
  • エンティティの状態変更に必要なバリデーションを通過したかどうかがわからない。

ユースケースでバリデーションをしていますが、これは暗黙知的で、結局どのチェックを通過していればいいのかが分かりにくいのがペインでした。 つまりは、エンティティの状態変更と、ドメインサービスは密結合であるべきなのに、疎になってしまっているわけです。凝集度が低い状態ですね。

どうあって欲しいのか?

上記のペインを解消し、以下のような実装にしたいです。

  • Entityの状態変更時に必要なドメインサービスを通過していることを保証したい。
  • Entityの状態変更メソッドと密結合なドメインサービスをエンティティから辿れるようにしたい。

これが実現できれば、凝集度高く応用の効くドメインサービスが作れそうです。

解決策: エンティティがドメインサービスのメッセージを期待すれば良い

悩んだ末、これだなと思ったのが「エンティティのメソッドの引数にドメインサービスからのメッセージを取れば良い」という方針です。 具体的な書き方を示します。大きく変わる箇所は二つ、エンティティのメソッドの引数と、ドメインサービスの返り値です。

まずは、ドメインサービスを以下のようにしてみます。

trait UserEmailUniqueChecker extends DomainService {
  import UserEmailUniqueChecker._

  /**
   * メールアドレスがユニークなものであるか調べる
   *
   * @param email
   *   調べるメールアドレス
   * @return
   *   引数のメールアドレスがまだDBに同じものが存在せず、ユニークである時、[[Right]]で[[EmailIsUnique]]メッセージを返す
   */
  def check(email: EmailAddress): Either[Because, EmailIsUnique] =
    Either.cond(isNotExist(email), new EmailIsUnique(email), EmailAlreadyExist)

  /**
   * 引数のメールアドレスを持つユーザーがまだDB上に存在しない
   * @param email
   *   メールアドレス
   * @return
   *   存在する時: `false`, 存在しない時: `true`
   */
  protected def isNotExist(email: EmailAddress): Boolean
}

object UserEmailUniqueChecker {
  /**
   * メールアドレスがユニークであることを示すメッセージ
   */
  final class EmailIsUnique private[unique] (val email: EmailAddress)

  /** 失敗理由 **/
  sealed trait Because

  /** メールアドレスがすでに使われていた **/
  case object EmailAlreadyExist extends Because
}

check メソッドのIFが以下のようになっています。この、EmailIsUniqueドメインサービスとエンティティを繋ぐメッセージです。

def check(email: EmailAddress): Either[Because, EmailIsUnique]

このメッセージはコンパニオンオブジェクトに以下のように定義されています。

object UserEmailUniqueChecker {
  /**
   * メールアドレスがユニークであることを示すメッセージ
   */
  // コンストラクタがpackage private なので、パッケージが適切に切れていればよほどのことがなければドメインサービス以外からは生成できない
  // final なので継承もできないのでやはり他で誤って使われる確率が低い。
  final class EmailIsUnique private[unique] (val email: EmailAddress)
  ...
}

コメントに書いた通り、生成手段を絞っているので基本的にここでしか生成できないように作っています。つまり、この型の値を使うためにはこのドメインサービスを必ず経由する必要があるわけです。これで、一つ目のペインは解消されました。

次に User オブジェクトの changeEmail メソッドを見ていきましょう。

def changeEmail(message: EmailIsUnique): User = copy(email = message.email)

上記のようになっています。引数に先ほどのドメインサービスのメッセージを期待しています。この引数が何なのかを辿れば2つ目のペインが解消されます。また、このメッセージは上述した通り、ドメインサービスを経由しないと生成できないので、ドメインサービスを見落とすことがありません。

全体像

gist.github.com

最後に

この実装アイデアはAkkaで有名なアクターモデルからのインスパイアです。アクター間でメッセージをやり取りし、メッセージによってアクターの状態が変わるのを見て、オブジェクト間でメッセージをしてあげるだけで良いということに思い当たりました。オブジェクト間のメッセージングをやるのにわざわざActorモデルのプログラミングをする必要はなく、オブジェクトが返すメッセージを定義してあげて、その生成可能箇所を制限してあげれば簡易的にやりたいことが実現できるな思い至り、今回の実装に至りました。