Scalaでのドメインモデリングのやりかた

 Scalaでのドメインモデリングのやりかた

実装コードを中心に、ドメインモデルの設計に対する考え方や改善方法についてまとめた資料です。

933291444e456bfb511a66a2fa9c6929?s=128

かとじゅん

November 09, 2018
Tweet

Transcript

  1. 4.

    ドメインモデルを生み出す、ドメイン分析は1980 年代はじめからあるらしい。 1995 年発刊のオブジェクト指向方法序説〈基盤編〉ジェームズ マーチン著で、ドメインとい う用語が登場する。 歴史を紐解きたい方は、こちらをごらんください。ドメインもしくはドメインモデルという概 念が登場する書籍一覧 FYI: ドメインという概念は昔からある

    Domain analysis ­ Wikipedia 引用: In software engineering, domain analysis, or product line analysis, is the process of analyzing related software systems in a domain to find their common and variable parts. It is a model of wider business context for the system. The term was coined in the early 1980s by James Neighbors. Domain analysis produces domain models using methodologies such as domain specific languages, feature tables, facet tables, facet templates, and generic architectures, which describe all of the systems in a domain. Several methodologies for domain analysis have been proposed. Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 4 / 40
  2. 6.

    システム価値 コンテキストモデル 要求モデル システム外部環境 業務フロー 利用シーン 概念モデル システム境界 ユースケースモデル(今日はここから考える) 画面/

    帳票モデル(Optional) プロトコル/ イベントモデル(Optional) システム ドメインモデル データモデル システムを語るのに不可欠な要素 Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 6 / 40
  3. 8.

    ユースケースの分析 凡例 A はアクター B はバウンダリ E はエンティティ( ドメインオブジェクト) C

    はコントロール ユースケース カンファレンス主催者(A) が、カンファレンス管理画面(B) からカンファレンス (E) を作成する(C) カンファレンス主催者(A) が、カンファレンス管理画面(B) からカンファレンス (E) を公開する(C) ユーザ(A) が、カンファレンス告知ページ(B) からカンファレンス (E) に参加する(C) ユーザ(A) が、カンファレンス告知ページ(B) から参加したカンファレンス (E) をキャンセルする(C) カンファレンス主催者(A) が、カンファレンス管理画面(B) からカンファレンス (E) の参加者一覧 (E) を確認する(C) CRUD よりも、ドメイン知識を表す判断・加工・計算を表すコントロールに注目する Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 8 / 40
  4. 11.

    レイヤーを分割しよう レイヤーをプロジェクトとして分割し、依存の方向を制御してください 循環参照を作ってからでは遅いです! // インフラストラクチャ層 val infrastructure = (project in

    file("infrastructure")).settings(...) // ドメイン層 val domain = (project in file("domain")).settings(...).dependsOn(infrastructure) // ユースケース層 val useCase = (project in file("use-case")).settings(...).dependsOn(domain, infrastructure) // インターフェイス層 val interface = (project in file("interface")).settings(...).dependsOn(useCase, infrastructure) // アプリケーション本体 val boot = (project in file("boot")).settings(...).dependsOn(interface, infrastructure) val root = (project in file(".")).settings(...).dependsOn(boot) Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 11 / 40
  5. 12.

    最初のカンファレンス実装 これはドメインモデルを反映した実装といってよいか? case class Conference(id: Long, status: Int, name: String,

    limit: Int participants: Seq[Long]) MVC の父、Trygve Reenskaug 氏は、モデルは知識の表象と定義した。さて、どれが知識だろうか? Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 12 / 40
  6. 13.

    最初のカンファレンス実装 これはドメインモデルを反映した実装といってよいか? case class Conference(id: Long, status: Int, name: String,

    limit: Int participants: Seq[Long]) MVC の父、Trygve Reenskaug 氏は、モデルは知識の表象と定義した。さて、どれが知識だろうか? ドメイン知識の正体は、CRUD 以外の判断 / 加工 / 計算の振る舞いである。 このモデルにはそれがない。本当にないか?重要な 側面を見落としてないだろうか? Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 12 / 40
  7. 14.

    実際のコード表現 参加者の追加 val conference = Conference(id = 1L, status =

    1, name = "Scala 関西Summit 2018", limit = 5000, participants = Seq.empty) val newConfernece = conference.copy( participants = conference.participants :+ 10L /* userAccountId */ ) なんとかならないか。これがDDD の真骨頂? Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 13 / 40
  8. 15.

    実際のコード表現 参加者の追加 val conference = Conference(id = 1L, status =

    1, name = "Scala 関西Summit 2018", limit = 5000, participants = Seq.empty) val newConfernece = conference.copy( participants = conference.participants :+ 10L /* userAccountId */ ) なんとかならないか。これがDDD の真骨頂? ちがいます !!! Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 13 / 40
  9. 16.

    ドメインモデルに知識を集約する ユースケースからCRUD 以外の判断/ 加工/ 計算の関心事を抜き出し割り当てる // 作成するはインスタンスを作成する? case class Conference(id:

    ConferenceId, name: ConferenceName, ... ) { // 非公開と公開で何が違うのか?状態が違うだけか? def publish: Conference = ... // 参加するの定義とは? def addParticipants(...): Conference = ... // キャンセルじゃなくて、参加者の削除? def removeParticipants(...): Conference = ... // " の" で接続する用語は集約の属性になることが多い def getParticipants: Participants = ... } 曖昧な部分がかなりある… 。 保持する値は値オブジェクトとして実装。 case class ConferenceId(value: Long) { require(value > 0L) // コード表現に変換できる def asCode: Code = Code(value) } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 14 / 40
  10. 21.

    メリット 関連の増大を軽減できる 参加者上限を厳密に守ることができる デメリット 大量の参加者を扱うことは現実的ではない 大きい集約はロックも大きくなる // カンファレンス集約 case class

    Conference(id: ConferenceId, status: ConferenceStatus, name: ConferenceName, limit: ParticipantLimit, participants: Set[Participat]) { // 不変条件の表明 require(limit.isLessThan(participants)) // 閉じた操作によって、コンストラクタで不変条件を検証する def addParticipants(otherParticipants: Set[Participant]): Conference = copy(participants = participants ++ otherParticipants) } 参加者を含むカンファレンス集約 Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 19 / 40
  11. 22.

    メリット ID を用いて単独で検索できる 参加者の件数が多い場合でも対応できる デメリット 弱い整合性になるため、カンファレンスが読めるときに、参加者が読める とは限らない。その逆も。 厳密な参加者上限を設けることができない( チェック・ゼン・アクト操作に 割り込み発生するなど)

    // カンファレンス集約 case class Conference(id: ConferenceId, status: ConferenceStatus, name: ConferenceName, limit: ParticipantLimit) { // ... } // 参加者集約 case class Participant(id: ParticipantId, ConferenceId: ConferenceId, userAccountId: UserAccount) { // ... } 参加者は独立した集約 Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 20 / 40
  12. 23.

    ロックの衝突が起きやすくなる ①→ ②→ ③ ②: ①のロックと衝突・更新の失敗 ②→ ③→ ① ③①:

    ②のロックの衝突・更新の失敗 ③→ ①→ ② ②: ③と①ロックの衝突・更新の失敗 複数の集約を更新する操作をRDB などのトランザクション で束ねることは、結局巨大な集約を定義しているに等し い。また、境界定義もユースケースによって変動してしま うため、モデルの知識だけでは整合性について把握するこ とが難しくなる 対策についてはアンカンファレンスで話しましょう (笑 FYI: こんなユースケースは要注意 Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 21 / 40
  13. 24.

    カンファレンスの状態 カンファレンスの下書き状態 もしくは 公開状態か 公開した後に、もう一度非公開にすることもある… 状態には、非公開と公開の状態があればいいことがわかった 整理できていないなら、状態遷移図を書くといいかも case class Conference(id:

    ConferenceId, status: ConferenceStatus, limit: ParticipantLimit, name: ConferenceName, ...) { // ... } import enumeratum._ sealed trait ConferenceStatus extends EnumEntry object ConferenceStatus extends Enum[ConferenceStatus] { val values = findValues case object Private extends ConferenceStatus case object Public extends ConferenceStatus } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 22 / 40
  14. 25.

    以下のように集約の型( トップレベル) を別々にしてしまうと、同一集合と して扱えなくなります。 case class PrivateConference(id: PrivateConferenceId, ...) case

    class PublicConference(id: PublicConferenceId, ...) リポジトリは集約の型ごとに定義されるので、 class PrivateConferenceRepository { ... } class PublicConferenceRepository { ... } 別々の集合を扱うことになる val privateConferences = privateRepository.resolveByOwnerId(ownerId) val publicConferences = publicRepository.resolveByOwnerId(ownerId) 問題 : 状態ごとに集約の型を定義すると ... Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 23 / 40
  15. 26.

    集約のトップレベルの型を変えずに、サブ型を導入する sealed trait Conference { ... } case class PrivateConference

    extends Conference { ... } case class PublicConference extends Conference { ... } // トップレベルに対するリポジトリを定義する class ConferenceRepository { ... } すべてひとつの集合として扱うことができる val conferences: Seq[Conference] = conferenceRepository .resolveByOwnerId(ownerId) // 部分集合を取得する問い合わせメソッドがあってもよい val conferences: Seq[PrivateConference] = conferenceRepository .resolvePrivateByOwnerId(ownerId) Conferences.foreach { case Conference: PrivateConference => // ... case Conference: PublicConference => // ... } 解決 : 集約の型にサブ型を導入する Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 24 / 40
  16. 27.

    VO で暗黙的な概念を明示的にする ドメイン知識の正体は、判断/ 加工/ 計算の処理。以下のような判断を値オブジェクトの責務にする require(limit >= 1) require(limit >=

    participants.size) ↓ 参加者上限という、暗黙的な概念を明示的にするリファクタリング require(limit.isLessThen(participants)) 参加者の上限についてはParticipantLimit の中に集約できる(DDD 仕様パターン) case class ParticipantLimit(value: Int) { require(value >= 1) def isLessThen(participants: Seq[Participant]): Boolean = if (value >= participants.size) true else false } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 25 / 40
  17. 28.

    コレクション操作の設計を改善する コレクションを操作する処理は、複雑・冗長・見通しが悪くなりがち。設計の意図が汲み取りづらい // 非ファーストクラスコレクション case class Item(id: Long, price: Long)

    val items: Set[Item] = Set(...) val totalPrice: Long = items.foldLeft(0L)(_ + _.price) val filteredByPrice: Set[Item] = items.filter(_.price > 10) ファーストクラスコレクションに集約し、意図を明確にする // ファーストクラスコレクション case class Items(values: Seq[Item]) { def totalPrice: Long = values.foldLeft(0L)(_ + _.price) def filteredByPrice(price: Long): Seq[Item] = values.filter(_.price > price) } val items: Items = Items(...) val totalPrice = items.totalPrice val filteredByPrice = items.filteredByPrice(10L) Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 26 / 40
  18. 30.

    問題 :Getter によるロジックの流出・分散 プリミティブ型をGet して操作するから、ドメイン知識がドメインオブジェクトから流出・分散する case class Conference(id: CategoryId, category:

    String, ...) val conference = Conference(id = ..., category = "IT/Database/HBase", ...) ちらばったドメインロジックはドメイン層から離れたところに… 。 val categorySplits = conference.category.split("/") val root = categorySplits.headOption // ルートカテゴリの取得? val parent = categorySplits(categorySplits.size - 1) // 親カテゴリの取得? 無用に複雑で意図も汲み取りにくい Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 28 / 40
  19. 31.

    解決 : 値オブジェクトへのロジックの集約 プリミティブ型をGetter で返さない Get して操作するロジック( 判断/ 加工/ 計算)

    をドメインオブジェクトに集約する case class Category(path: String, parent: Option[Category] = None) { require(path.nonEmpty) require(path.size <= 255) def root: Option[Category] = { @tailrec def loop(current: Category): Option[Category] = current.parent match { case None => Some(current) case Some(p) => loop(p) } loop(this) } def asString: String = parent.map(_.asString).fold(path)(v => s"$v/$path") } case class Conference(..., category: Category, ...) val root = conference.category.root // ルートカテゴリを返す val parent = conference.category.parent // 親カテゴリを返す val path = conference.category.asString // ルートカテゴリを返す Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 29 / 40
  20. 32.

    Getter を使わざるを得ない場合 JSON に変換するなど用途でどうしてもGetter が必要な場合がある プロパティに長い名前を使うことでロジックの流出を感知できるようにする case class Items(breachEncapsulationOfValues: Seq[Item])

    { private val values = breachEncapsulationOfValues def totalPrice: Long = values.foldLeft(0L)(_ + _.price) def filteredByPrice(price: Long): Seq[Item] = values.filter(_.price > price) } Getter を使うコードがあれば、リファクタリングのチャンス items.breachEncapsulationOfValues.filter(_.price == 100L).foldLeft(0L)(_ + _.price) ↓ def totalPrice(price: Long): Long = values.filter(_.price > price).foldLeft(0L)(_ + _.price) Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 30 / 40
  21. 33.

    FYI: エンティティの責務と構造 エンティティの主たる責務は、同一性・連続性。値を保持することが主ではない 次の例は、責務にふさわしくない trait Employee { val id: EmployeeId

    val name: EmployeeName val emailAddress: EmailAddress val pref: Pref val cityName: CityName val addressName: AddressName val buildingName: Option[BuildingName] } 値オブジェクトに集約することが望ましい。振る舞いも値オブジェクトに移動しよう trait Employee { val id: EmployeeId val profile: EmployeeProfile } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 31 / 40
  22. 34.

    最終的なカンファレンスの実装 // 参加者 case class Participant(userAccountId: UserAccountId, createdAt: ZonedDateTime) {

    // equals, hashCode は userAccountId のみに基づく } // ファーストクラスコレクションとしての参加者の集合 case class Participants(breachEncapsulationOfValues: Set[Participant]) { require(values.nonEmpty) private val values = breachEncapsulationOfValues // 閉じた操作。他の型と結合できなくなるパターン def add(other: Participants): Participants = copy(values = values ++ other.values) def size: Int = values.size } // カンファレンス case class Conference(id: ConferenceId, status: ConferenceStatus, category: Category, name: ConferenceName, limit: ParticipantLimit, participants: Participatns) { // 不変条件の検証 require(limit.isLessThen(participants)) // 閉じた操作 def addParticipants(participants: Participant*): Conference = copy(participants = participants.add(participants)) } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 32 / 40
  23. 35.

    実際のコード表現 改善前 val conference = Conference(id = 1L, status =

    1, name = "Scala 関西Summit 2018", limit = 5000, participants = Seq.empty) val newConfernece = conference.copy(participants = conference.participants.copy(participants = conference.participants + 10L /* userAccountId */) ) ) 改善後 val conference = Conference(id = ConferenceId(1L), status = ConferenceStatus.Private, name = ConferenceName("Scala 関西Summit 2018", en = "Scala Kansai Summit 2018), limit = PerticipantLimit(5000), paticipants = Participants.empty) val newConference = conference.addParticipants(Particitpant(userAccountId = 10L)) Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 33 / 40
  24. 39.

    副作用のない関数 (2/2) Scala ではデフォルトが不変なので意識しなくても実践できるが、mutable コレクションなどの可変オブジェクトや、Actor を使 う場合は要注意 class EmployeActor(id: EmployeeId)

    extends Actor { private var state: Option[Employee] = None override def receive: Receive = { // ... case UpdateDeptId(id, deptId) if this.id == id && state.nonEmpty => // 状態の可変は最小限に留める state = state.map(_.withDeptId(deptId)) // ... } } case class Employee(id: EmployeeId, name: EmployeName, deptId: DeptId, ...) { // 副作用のない関数 def withDeptId(value: DeptId): EmployId = copy(deptId = value) // copy メソッドも隠蔽したい場合は、case class やめる… 。 } Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 37 / 40
  25. 40.

    表明 不変条件の表明を行う { INV & P } A { INV

    & Q } Immutable の場合は INV は、コンストラクタで行う case class Conference(id: ConferenceId, status: ConferenceStatus, category: Category, name: ConferenceName, limit: ParticipantLimit, participants: Participatns) { // 不変条件の検証 require(limit.isLessThen(participants)) def addParticipants(participants: Participant*): Conference = { // ここでのINV チェックは不要 copy(participants = participants.add(participants)) // ここでのINV チェックは不要 } } コンパイル時の表明として、型で不変条件を表現する方法もある final case class NonEmptyList[+A](file:///Users/j5ik2o/Source/slides/domain-modeling-in-scala-scala-ks-2018/head: A, tail: List[A]) .. Scala でのドメインモデリングのやり方 Scala 関西Summit 2018 38 / 40