ドメイン駆動と言っても、データを永続化している以上、ドメインレイヤがインフラレイヤを活用する必要があり、ドメインがインフラに依存するアーキテクチャはごく自然のようですが、DIP(dependency inversion principle)を実現したアーキテクチャでは、インフラがドメインに依存するようになり、従来のアーキテクチャとは依存関係が逆になります。
- 伝統的なアーキテクチャ → ドメインレイヤがインフラレイヤに依存
- DIPを実現したアーキテクチャ → インフラレイヤがドメインレイヤに依存
この逆転のアーキテクチャをどう実装したらいいか疑問に思っている人も多いのではないでしょうか?Amazon.co.jp: 実践ドメイン駆動設計 (Object Oriented Selection): ヴァーン・ヴァーノン, 高木 正弘: 本で紹介されている実装方法をScalaで説明します。
実装のポイント
- ドメインレイヤとインフラレイヤに2つリポジトリを作る。
- ドメインレイヤのリポジトリはインターフェイス。
- インフラレイヤのリポジトリはドメインレイヤのリポジトリを継承&実装。
ドメインレイヤにはリポジトリのインターフェイスを作ります。また、ビジネスロジックがあれば、ドメインレイヤのリポジトリに記述します。
インフラレイヤのリポジトリはドメインレイヤのリポジトリを継承し、エンティティをどのように永続化するか?どのようにストレージから取り出すか?どのようにストレージからエンティティを削除するか?など技術的な課題を解決するために、具体的な実装をコーディングします。例えば、RDBMSを使ったリポジトリなら、テーブル名やカラム名、SELECT文やINSERT文が現れるのは、インフラレイヤのリポジトリです。ドメインレイヤにはこういった情報は一切出ません。
サンプルコード
私は、DDDでテスト自動化サービスのShouldBee - テスト作業を限りなくゼロにを開発しています。テストを行うため、テストに関するドメインモデルを構築しています。その中から、テストスクリプトに関するモデルを抜粋してサンプルコードとしてお示ししたいと思います。
ドメインレイヤのリポジトリやValue Object
package shouldbee.testing.domain.model
import java.util.UUID
import org.joda.time.DateTime
import shouldbee.testing.domain.model.common.TesterId
import shouldbee.testing.domain.model.plan.ProjectId
package object development {
case class TestScriptId(id: UUID)
sealed trait Event
case class TestScriptCreated(testScriptId: TestScriptId, projectId: ProjectId, script: String, comment: String, occurredOn: DateTime, occurredBy: TesterId) extends Event
case class TestScriptUpdated(testScriptId: TestScriptId, script: String, comment: String, occurredOn: DateTime, occurredBy: TesterId) extends Event
case class TestScriptRemoved(testScriptId: TestScriptId, comment: String, occurredOn: DateTime, occurredBy: TesterId) extends Event
// リポジトリのインターフェイス
trait TestScriptRepository {
type Events = Seq[Event]
def nextIdentity: TestScriptId
def testScriptOfId(id: TestScriptId): Option[(TestScript, Events)]
def save(id: TestScriptId, events: Events, oldEvents: Events): Unit
}
}
ドメインレイヤのエンティティ
package shouldbee.testing.domain.model.development
import java.util.UUID
import org.joda.time.DateTime
import shouldbee.testing.domain.model.common.TesterId
import shouldbee.testing.domain.model.plan.ProjectId
import shouldbee.testing.lang.AssertionConcern._
object TestScript {
def create(testScriptId: TestScriptId, projectId: ProjectId, script: String, comment: String, createdBy: TesterId) = {
assertArgumentNotEmpty(script, "The script must be provided.")
assertArgumentNotEmpty(projectId.id.toString, "The project id must be provided.")
assertArgumentNotEmpty(createdBy.id, "The tester id must be provided.")
new TestScript & TestScriptCreated(testScriptId, projectId, script, comment, new DateTime, createdBy)
}
}
case class TestScript(testScriptId: TestScriptId, projectId: ProjectId, script: String, removed: Boolean) {
def this() = this(TestScriptId(UUID.randomUUID), ProjectId(UUID.randomUUID), "", false)
def update(script: String, comment: String, updatedBy: TesterId) = {
assertStateFalse(removed, "The script is already removed.")
assertArgumentNotEmpty(updatedBy.id, "The tester id must be provided.")
this & TestScriptUpdated(testScriptId, script, comment, new DateTime, updatedBy)
}
def remove(comment: String, removedBy: TesterId) = {
assertStateFalse(removed, "The script is already removed.")
assertArgumentNotEmpty(removedBy.id, "The tester id must be provided.")
this & TestScriptRemoved(testScriptId, comment, new DateTime, removedBy)
}
private def &[A](e: A): (TestScript, A) = (this + e, e)
private[shouldbee] def +[A](event: A) = event match {
case e: TestScriptCreated => copy(testScriptId = e.testScriptId, projectId = e.projectId, script = e.script)
case e: TestScriptUpdated => copy(script = e.script)
case e: TestScriptRemoved => copy(removed = true)
}
}
インフラレイヤのリポジトリ
パッケージ名がport.adapterになっていますが、これはヘキサゴナルアーキテクチャに由来する命名です。ざっくり言うとinfrastractureと同じ意味です。
ShouldBeeではイベントソーシングという方法で、Entityを永続化していますが、重要なのはそこではなく、EventStoreTestScriptRepository
がドメインレイヤのTestScriptRepository
を継承しているところです。
package shouldbee.testing.port.adapter.persistence.repository
import shouldbee.testing.domain.model.development.{ TestScript, TestScriptId, TestScriptRepository }
import shouldbee.testing.domain.model.development.Event
import shouldbee.testing.port.adapter.persistence.event.sourcing.{ AppendMessages, EventDispatcherProvider, EventStoreProvider }
import shouldbee.testing.port.adapter.persistence.serialization.DevelopmentJsonSerialization
// MySQLなら MySQLTestScriptRepository という名前にすることになるでしょう。
object EventStoreTestScriptRepository extends TestScriptRepository with EventStoreProvider with EventDispatcherProvider {
override def nextIdentity: TestScriptId = TestScriptId(java.util.UUID.randomUUID)
// 具体的な保存のしかた。ShouldBeeではイベントソーシングという方法でEntityを永続化しています。
// RDBMSならここでINSERT文やUPDATE文を実行する処理が書かれます。
override def save(id: TestScriptId, events: Events, oldEvents: Events): Unit = {
val messages = new AppendMessages[Event](oldEvents.length, "application/json", DevelopmentJsonSerialization.serialize)
events.foreach(messages.add)
eventStore.batchAppend(id.id.toString, messages.toSeq)
eventDispatcher.update
}
// 具体的な再構築のしかた。ShouldBeeではイベントソーシングという方法でEntityを永続化しているので、
// イベントを適用して、Entityを再構築していますが、ステートソーシングのRDBMSを使う場合は、
// ここにSELECT文を実行する処理が書かれます。
override def testScriptOfId(id: TestScriptId): Option[(TestScript, Events)] = {
val optionEvents = eventStore.stream(id.id.toString).map { rawEvent =>
DevelopmentJsonSerialization.deserialize(rawEvent.eventType, rawEvent.body)
}
val events = optionEvents.collect { case Some(event) => event }
events.size match {
case 0 => None
case _ => Some((events.foldLeft(new TestScript)(_ + _), events))
}
}
}
なぜインフラがドメインに依存したほうがいいか?
Entityをどういう方法で永続化するかを後で決められるのが大きいです。ひと通りドメインモデルを構築してから、MySQLで保存しようか、NoSQLでやろうか、それともイベントストアにしようか、といった選択が後でできます。ドメインにとって最適なインフラ技術を選ぶことができます。
質問などありましたら、コメントくださいm(_ _)m