読者です 読者をやめる 読者になる 読者になる

リラクのサーバサイド事情 with Scala / ドメイン編 - Re.Ra.Ku アドベントカレンダー day 8

Re.Ra.Ku アドベントカレンダー 8日目です。

こんにちは。ヘルステックチームの近藤です。

2日目のリラクのサーバサイド事情 with Scalaにて宣言した通り、今回はサーバサイド事情の中でも中核となるドメインについての話です。

「ドメイン」とは何か

「ドメイン (domain)」はそもそも「領域」という意味ですが、様々な箇所で使われ、文脈によって指すものが異なりやすい言葉だと思います。

今私が掲題している「ドメイン」とは、レイヤードアーキテクチャで言うところのドメイン層で、ビジネス要件の塊です。含まれるものは:

  • ユーザ名は必須かつ20文字以内
  • ログインできるユーザはステータス: Activatedのみに限り、ステータス: Registeredおよびステータス: Suspendedのユーザはログインできない
  • チャットルームはユーザが自由に作成することができ、作成したユーザがオーナーとなる
  • チャットルームのオーナーは任意のユーザーをチャットルームに参加させられる。既に参加済みのユーザーを参加させようとすると、UserAlreadyJoinedというエラーが起きる

などなどで、あくまで「方針」です。

反対に含まれない(含みたくない)ものは:

  • ユーザなどの情報はPostgreSQLに保存する
  • ユーザのアイコンはAmazon S3に保存する
  • 各種操作のリクエストはHTTPにて受け付ける

などの、何によって実現するかという「詳細」です。

ですが、何故詳細を含みたくないのか?それは方針が詳細に引き摺られてしまうからです。

詳細はそれぞれが特有のインタフェースを持っています。PostgreSQLならばSQLにてやりとりしますし、Amazon S3とはHTTP APIでやりとりするでしょう。もしその情報をドメインに持ち込んでしまうと、ドメインはSQLやHTTPという方法に依存し、それらに関するコードを含むようになります。

正直この時点で匂います。関心事が混ざり合っている匂いです。そこに追い討ちとして、「永続化先をPostgreSQLからMongoDBに変更する」という要請が来たらどうなるでしょう?処理のコアとなるドメインには変更がないのに、ドメインのコードを変更しなければなりません。おかしい話ですよね?

また、表現の自由度が低下するので、その点を私たちは気にしています。詳細にはフレームワークも含まれています。フレームワークは設計を制限し、表現を規定します。これによって開発者間のコミュニケーションがしやすくなるなどのメリットもあるのですが、ことドメインにおいてはそれが枷となりやすく、足を引っ張りがちです。よって嫌います。

私はとある漫画が好きなので、思わず「ドメインを設計する時はね、詳細に引っ張られず、自由でなんというか」と続けてしまいたくなるのですが、その通りだと思います*1

DDDの採用

ドメインの設計にはDDD (Domain-driven design)を採用しています。

詳細によって表現の自由度は下げたくないのですが、表現の指針がないのもそれはそれで無法地帯です。そういうわけでドメインの表現としてはメジャーであろうDDDを選びました。よってコード中にはEntityやRepositoryといった概念が出て来ます。

もちろんDDDの核であるユビキタス言語にも気を付けています。週1でチーム内の定例ミーティングがあるのですが、そこでは現在関わっているプロジェクト単位で使用している言葉(日本語/英語共に)に違和感はないかを議論し、常にメンバー間で擦り合わせるようにしています。そのとき「現場ではこういう言葉が使われている」や「こっちの方が実際の要件に即している」ということがあれば、即座にコードに反映していきます。

そんな感じで試行錯誤しつつも結構マジメにDDDを実践していっています。

値オブジェクト

そんなわけでDDDの概念である値オブジェクト、エンティティ、リポジトリなどがどうなっているか、です。

よくあるDDDの説明で、エンティティはいの一番に語られるものだと思っていますが、関連する属性を取りまとめた論理的な単位である値オブジェクトの方が分かりやすいと思っています。ですので、まず値オブジェクトに触れていきます。

値オブジェクトの特徴は:

  • 一度生成したらその値オブジェクトが持つ属性は不変であり
  • 属性すべてが等値であれば、値オブジェクトそのものが等値である

というものです。それ以外にも実践ドメイン駆動設計には「概念的な統一体」などを挙げています。それらは何を値オブジェクトとしてモデリングすべきなのかを示しているのですが、今回はさきほど挙げた2点に絞ってみます。

値オブジェクトの特徴は「不変」で「属性によって等値かを判断する」という点です。Scalaはこれにおあつらえ向きのものがあります。case classです。よって値オブジェクトはすべてcase classを用いて定義しています。以下が値オブジェクトのコード例です。

package domain.user

import java.util.UUID

import org.usagram.clarify._

sealed trait UserId {
  def value: UUID
}

private case class UserIdImpl(value: UUID) extends UserId

object UserId {
  def apply(value: Indefinite[UUID]): UserId = {
    // valueをバリデーションし、パスしたらUserIdImplを生成し、返す
  }
}

case classで定義していると言ったのに、いきなりtraitを用いています。シンプルに書けば:

package domain.user

import java.util.UUID

case class UserId(value: UUID) {
  // valueをバリデーション
}

となり、大分すっきりするのに、なぜ先に掲載した回りくどい書き方をしているのか。それは以下に起因します。

  • バリデーションを強制したい: case class内にバリデーションを書くというのはあくまで任意なので、バリデーション忘れなどのミスには対応できません(まあ今回の例ではjava.util.UUIDなので特にバリデーションすることもないのですが……)
  • case classのapplyを使用禁止にしたい: バリデーションを強制するためにcase classの外、例えばコンパニオンオブジェクトのapplyにてバリデーションを強制する仕組みを導入したとしましょう。しかし、その場合コンパニオンオブジェクトのapplyはcase classに既に使用されているため使えません。じゃあ別のメソッド名を使うか。そうなるとcase classのapplyを使ってバリデーションを擦り抜けることが出来てしまいます

とまあ、ようするに「バリデーションを強制するためにcase classデフォルトのapplyを使用禁止にしたい」という事情があります。ですので、回りくどい書き方をしているのです。

sealed trait UserId {
  def value: UUID
}

まずtraitで値オブジェクトの型を定義します。sealed修飾子によってこのtraitが定義されたファイル以外でUserId型となりえるクラスを定義できなくなります。

private case class UserIdImpl(value: UUID) extends UserId

UserId型の実装です。ここでcase classを使っているため、「不変」かつ「属性によって等値かを判断する」という特徴を備えます。しかし、このcase classはprivateであるため、外から直接扱えません。このままではUserId型のオブジェクトを生成できなくなります。

object UserId {
  def apply(value: Indefinite[UUID]): UserId = {
    // valueをバリデーションし、パスしたらUserIdImplを生成し、返す
  }
}

ですのでコンパニオンオブジェクトに生成のためのメソッドを定義します。ここではapplyとしています。UserIdImpl case classのコンパニオンオブジェクトにapplyを定義することは出来ません(2回定義されているよとコンパイラに怒られます)が、これはUserId traitのコンパニオンオブジェクトなので、問題なくapplyを定義することが出来ます。ここでバリデーションを強制しています。詳細は後日としますが、Indefinite[UUID]という型がそれです。

こうすると:

  • UserId traitはsealed修飾子によって他のファイルにて継承することが出来ない。当然 new UserId { val value = ... } とその場でオブジェクトを生成することも不可能
  • UserIdImpl case classはprivate修飾子によって参照することが出来ない
  • よって必ずUserId objectのapplyメソッドを使用する必要があり、バリデーションは強制される

となり、要望を満たせます。

あとはビジネス要件に応じたバリデーションを書き、値オブジェクトを操作する必要があれば、traitの方にユビキタス言語に則ったメソッド名で定義していきます。

エンティティ

次にエンティティです。エンティティは以下の特徴を持ちます。

  • 識別子を持ち
  • 属性の変更が可能である

しかし、識別子を持つことはともかく、属性の変更が可能であっても、私たちはイミュータブルにモデリングしています。そっちの方がシンプルに考えられ、テストも容易ですしね。ですので、エンティティ自身を操作するすべてのメソッドにおいて、操作された結果の新たなエンティティを返すようになっています(もちろん識別子は同一です)。

以下がエンティティの例です。値オブジェクトのときと同様、バリデーションを強制するようになっています。

package domain.user

import org.usagram.clarify._

sealed trait User extends Entity[UserId] {
  def name: String
  
  def email: String
  
  def updateName(newName: String): User =
    User(Indefinite(id), Indefinite(newName), Indefinite(email))
}

private class UserImpl(val id: UserId, val name: String, val email: String) extends User

object User {
  def apply(id: Indefinite[UserId], name: Indefinite[String], email: Indefinite[String]): User = {
    // id, nameおよびemailをバリデーションし、パスしたらUserImplを生成し、返す
  }
}

エンティティが等値であるかどうかは、識別子が等値であるかによって判断されるべきなので、case classではなくclassとなっています。その代わり、Entity[ID] traitを利用しています。Entity[ID] traitは以下のように定義しています。

trait Entity[ID] {
  def id: ID

  override final def equals(obj: Any) = obj match {
    case that: Entity[_] => id == that.id
    case _               => false
  }

  override final def hashCode = 31 * id.##
}

Entity[ID]型は必ずid属性を持つようになります。これによってエンティティの条件である「識別子を持つ」をクリアします。あとはequalsとhashCodeをオーバライドして、識別子による等値を実現しています。実際のコードではID型パラメータに上限境界を設けたりしていますけどね。

リポジトリ

リポジトリはエンティティ(正確には集約)の永続化のための仕組みです。行うことは:

  • エンティティの取り出し
  • エンティティの格納

が主です。Userエンティティのためのリポジトリ、UserRepositoryは以下のようなコードになっています。

package domain.user

trait UserRepository[X] {
  def find(id: UserId)(implicit session: X): User
  
  def store(user: User)(implicit session: X): Unit
}

これだけです。Repositoryはたしかに永続化するための仕組みなのですが、散々言うように「何を使って永続化するか」がドメインにあってはいけません。よって提供するのは「UserIdを与え、それに対応するUserを返すfindメソッド」と「Userを与え、それを格納するstoreメソッド」というインタフェースだけなのが望ましいです。

なお上記例では定義していませんが、リポジトリにはドメインの処理を実現するための問い合わせ(例: メールアドレスの重複を防ぐために、既に使用済みでないかをリポジトリに問い合わせるなど)用メソッドがあっても構いません。

もちろん、インタフェースだけではアプリケーションとして完成しません。ですので、リラクのサーバサイド事情 with Scalaに書いたよう、インフラストラクチャ層で上記UserRepositoryを実装し、アプリケーション層にて実装されたUserRepositoryを使うように書いていきます。

サービス

あとはサービスですかね。サービスが特定の方法に依存する場合、当然ドメインにはインタフェースのみを定義し、インフラストラクチャ層で実装しています。例えばメールの送信とか。

package domain.user

trait SendActivationEmailService {
  def apply(user: User): Unit
}

サービスが複数のリポジトリやサービスをどうこうする程度のものであれば、サービスの処理自体はドメインに書き、アプリケーション層にてそのリポジトリをサービスに渡すようにしていたりはします。こんな感じ。

package domain.user

import java.util.UUID

trait RegisterUserService[X] {
  def userRepo: UserRepository[X]
  
  def sendActivationEmailService: SendActivationEmailService
  
  def apply(name: String, email: String)(implicit session: X): User = {
    val userId = UserId(Indefinite(UUID.randomUUID))
    val user = User(Indefinite(userId), Indefinite(name), Indefinite(email))
    
    if (userRepo.findByEmail(user.email).isDefined) {
      throw new UserEmailAlreadyUsed(email)
    }
    
    userRepo.store(user)
    sendActivationEmailService(user)
    user
  }
}

class UserEmailAlreadyUsed(email: String) extends Exception(s"$email is already used in other user")

これをアプリケーションサービスで:

package application.user

// 省略

object RegisterUserApplicationService {
  val registerUserService = new RegisterUserService[DBSession] {
    val userRepo = new JDBCUserRepository()
    
    val sendActivationEmailService = new SMTPSendActivationEmailService(...)
  }
  
  def apply(command: RegisterUserCommand): User =
    DB localTx { implicit session =>
      registerUserService(command.name, command.email)
    }
}

こんな感じで組み込みます。

今後やりたいこと

弊社ではドメインを値オブジェクト、エンティティ、リポジトリ、そしてサービスの4種類を用いてモデリングしています。ですので、イベントなどは用いていません。それらにはまだ手付かずですね。

ゆくゆくはイベントを導入し、エンティティ(集約)はイベントの集合(イベントソーシング)で表し、それらによるメリットを自らの手で確かめていきたいです。

しかし、イベントソーシングを導入するとなると、永続化される情報はイベントの集合になり、問い合わせが困難になるため、当然問い合わせ専用のモデル(RDB的な話ならばテーブル)が必要になり、それってもうCQRSじゃん?と、あれもこれもとなります。正直まだ中々踏ん切りが付いていないところですね。まあ小さく始めている途中ということで。

というわけで弊社のサーバサイドのドメイン事情でした。それでは。

*1:ちなみにこの場合の「自由」は何をしてもいいという意味ではなく、要件を満たすための前提としての自由です