ドメイン駆動設計では、Value ObjectはImmutable、EntityはMutableという雰囲気があるように思うが、ScalaでDDDを実践しようとなると、EntityがMutableでは逆に実装が複雑になることが多い。僕がDDDを始めた2013年頃は、ImmutableなEntityの実装に関する情報がほとんどなく実装方法を試行錯誤していた。その中で、個人的にImmutable Entityの実装方法が落ち着いてきたので、僕がどのように実装しているかについて紹介したい。
なお、ここで紹介するScalaコードはGitHubのsuin/scala-playgroundで公開しているので、コンパイル・実行など試してみたい場合はご活用ください。
Entityのふたつの実装方法
Entityを実装する方法は2つある。ひとつはState Sourcing、もうひとつはEvent Sourcingだ。どちらもEntityの永続化の方法で、State SourcingはEntityの状態を永続化する。EntityをORMでマッピングしてMySQLなどのDBに保存する方法だ。取り出すときもORMを介して、Entityのインスタンスを再構築する。一方のEvent Sourcingは、Entityの状態は保存せず、Domain Eventだけを時系列で永続化する。取り出すときは初期状態のEntityにこのDomain Eventを適用していくことで、最新の状態まで復元する。
ここでは、State SourcingとEvent Sourcingそれぞれを念頭においたEntityの実装方法を紹介する。紹介するに当たって、ホテルの部屋予約ドメインを例にする。
ホテル部屋予約ドメイン
ここでのホテル部屋予約ドメインとは、宿泊客が滞在したい部屋の予約やキャンセルができ、ホテルのスタッフが部屋の予約状況やチェックイン・チェックアウトを管理するドメインのことを言う。実際は、もっと複雑な業務があるが、サンプルでは複雑にならないよう簡略化して説明する。
主な動詞
ドメインモデルの設計で真っ先に着目するのはドメインの動詞だ。部屋予約ドメインの主な動詞を整理すると、ざっくり4つの動詞が記述できる。
- 部屋を予約する(reserve)
- 宿泊客がチェックインする(check-in)
- 宿泊客がチェックアウトする(check out)
- 予約をキャンセルする(cancel)
この動詞をヒントに、Entityの操作やDomain Eventを実装していく。
主な名詞
ドメインの動詞が決まると自ずと名詞が決まる。名詞はEntityやValue Objectの名前になる。
- 部屋(room)
- 宿泊客(guest)
- 予約(reservation)
- チェックイン予定日(expected check-in date)
- チェックアウト予定日(expected check out date)
State Sourcingでの実装方法
話がドメインの分析にそれてしまったが、ここからは本題のState SourcingでのImmutableなEntityの実装方法を紹介する。
まず、分析した名詞をたよりにValue Objectを定義する。
import org.joda.time.DateTime
case class ReservationId(id: Int) extends AnyVal
case class RoomId(id: Int) extends AnyVal
case class GuestId(id: Int) extends AnyVal
sealed trait ReservationState
object ReservationState {
case object Scheduled extends ReservationState
case class Staying(checkInDate: DateTime) extends ReservationState
case class CheckedOut(checkInDate: DateTime, checkOutDate: DateTime) extends ReservationState
case class Canceled(canceledOn: DateTime) extends ReservationState
}
次に予約のEntityを定義する。
object Reservation {
def reserve(
reservationId: ReservationId,
roomId: RoomId,
guestId: GuestId,
expectedCheckInDate: DateTime,
expectedCheckOutDate: DateTime
): Reservation = ???
}
case class Reservation(
reservationId: ReservationId,
roomId: RoomId,
guestId: GuestId,
expectedCheckInDate: DateTime,
expectedCheckOutDate: DateTime,
state: ReservationState
) {
def checkIn(checkInDate: DateTime): Reservation = ???
def checkOut(checkOutDate: DateTime): Reservation = ???
def cancel(canceledOn: DateTime): Reservation = ???
}
Entityはcase classにしておくと、ScalikeJDBCやSlickなどのORMとの相性が良くなる。case classにしてあるので、インスタンスの生成はScalaではval r = Reservation(...)
で作ることができるが、ユビキタスランゲージを強調するためにあえてコンパニオンオブジェクトにreserve
というFactory Methodを定義している。
宿泊客や部屋もEntityと考えられるが、予約からは他のEntityを直接参照せず、identityで参照するようにする。なお、今回は予約以外のEntityの実装は割愛する。
EntityをImmutableにするために、reserve
, checkIn
, checkOut
, cancel
の各関数は新しいReservation
を返せるインターフェイスにする。各関数の実装は次のようになる。
object Reservation {
def reserve(...): Reservation = Reservation(
reservationId,
roomId,
guestId,
expectedCheckInDate,
expectedCheckOutDate,
ReservationState.Scheduled
)
}
case class Reservation(...) {
def checkIn(checkInDate: DateTime): Reservation = state match {
case ReservationState.Scheduled =>
copy(state = ReservationState.Staying(checkInDate))
case _ =>
throw new AssertionError("Invalid state")
}
def checkOut(checkOutDate: DateTime): Reservation = state match {
case ReservationState.Staying(checkInDate) =>
copy(state = ReservationState.CheckedOut(checkInDate, checkOutDate))
case _ =>
throw new AssertionError("Invalid state")
}
def cancel(canceledOn: DateTime): Reservation = state match {
case ReservationState.Scheduled =>
copy(state = ReservationState.Canceled(canceledOn))
case _ =>
throw new AssertionError("Invalid state")
}
}
以上が、State SourcingのEntityを実装する方法だ。ドメインのクライアントコードでは、次のようにEntityをImmutableとして扱うことができる。
val reservation1 = Reservation.reserve(ReservationId(1), RoomId(1101), GuestId(432), new DateTime("2016-02-01"), new DateTime("2016-02-03"))
val reservation2 = reservation1.checkIn(new DateTime("2016-02-01T16:00:00"))
val reservation3 = reservation2.checkOut(new DateTime("2016-02-03T11:30:00"))
Event Sourcingでの実装方法
ここからはEvent SourcingなEntityの実装方法を紹介する。
イベントソーシングでも同様にValue Objectを定義する。
case class ReservationId(id: String) extends AnyVal
case class RoomId(id: Int) extends AnyVal
case class GuestId(id: Int) extends AnyVal
sealed trait ReservationState
object ReservationState {
// ステートソーシングと違い、チェックイン日等はCQRSのQuery Modelとして表現するため不要
case object Scheduled extends ReservationState
case object Staying extends ReservationState
case object CheckedOut extends ReservationState
case object Canceled extends ReservationState
}
次に、ドメインの動詞から対応するDomain Eventを定義する。
sealed trait ReservationEvent
case class RoomReserved(reservationId: ReservationId, roomId: RoomId, guestId: GuestId, expectedCheckInDate: DateTime, expectedCheckOutDate: DateTime, occurredOn: DateTime) extends ReservationEvent
case class GuestCheckedIn(reservationId: ReservationId, guestId: GuestId, roomId: RoomId, occurredOn: DateTime) extends ReservationEvent
case class GuestCheckedOut(reservationId: ReservationId, guestId: GuestId, roomId: RoomId, occurredOn: DateTime) extends ReservationEvent
case class ReservationCanceled(reservationId: ReservationId, roomId: RoomId, occurredOn: DateTime) extends ReservationEvent
続いて予約Entityを定義する。インターフェイスを先にお見せすると、次のようになる。
object Reservation {
def reserve(
reservationId: ReservationId,
roomId: RoomId,
guestId: GuestId,
expectedCheckInDate: DateTime,
expectedCheckOutDate: DateTime
): (Reservation, ReservationEvent) = ??? // ... (1)
}
case class Reservation(
reservationId: ReservationId,
roomId: RoomId,
guestId: GuestId,
expectedCheckInDate: DateTime,
expectedCheckOutDate: DateTime,
state: ReservationState
) {
def this() = this(
ReservationId(""),
RoomId(0),
GuestId(0),
DateTime.now,
DateTime.now,
ReservationState.Scheduled
) // ... (2)
def checkIn(checkInDate: DateTime): (Reservation, ReservationEvent) = ??? // ... (1)
def checkOut(checkOutDate: DateTime): (Reservation, ReservationEvent) = ??? // ... (1)
def cancel(canceledOn: DateTime): (Reservation, ReservationEvent) = ??? // ... (1)
def +(event: ReservationEvent): Reservation = ??? // ... (3)
def &(event: ReservationEvent): (Reservation, ReservationEvent) = (this + event, event) // ... (4)
}
State SourcingではEntityへのコマンド関数は新しい自Entityを返すだけだったが、Event SourcingではDomain Eventの生成が必須になるため、上コードの(1)の部分では新しい自EntityとDomain Eventをタプルで返すようなインターフェイスになる。
Event SourcingなEntityでは、新しい自Entityの生成は、全てイベントを適用するのを経て行われる。したがって、どうしても空っぽのEntityを一時的にでも作れるようなインターフェイスが必要になる1。(2)の部分はそのためにある。例えば、Repositoryで空っぽのReservation
を作り、RoomReserved
・GuestCheckedIn
・GuestCheckedOut
の順でイベントを適用していき、最新のReservation
を復元するような処理を書くときに使う。
object EventStoreReservationRepository {
def reservationOfId(reservationId: ReservationId): (Reservation, Seq[ReservationEvent]) = {
val events: Seq[ReservationEvent] = ??? // Event StoreからSeq[ReservationEvent]を取り出す
val reservation = events.foldLeft[Reservation](new Reservation)(_ + _)
(reservation, events)
}
}
より具体的なリポジトリの実装は、DDD - インフラレイヤがドメインレイヤのリポジトリに依存する実装方法 - Qiitaでちょろっと見せてます。
(3)はEntityにDomain Eventを適用するAPIだ。メソッド名はdef applyEvent(...)
のようなネーミングでも構わないが、+
にすることで、
-
Entity = Entity + Event
といった式が書きやすくなる - 「EntityにEventを足したら、新しいEntityができる」という意味合いがコードに表現されて直感的
- 記号であるためドメインモデルに余計な単語が出現せず、結果的にドメインモデルがドメインにフォーカスされる
という理由から+
を好んで使う。
(4)は見てのとおり、EntityにEventを適用して、EntityとEventのタプルを返す関数だ。これは、Entity内でコピペコードを減らす目的で実装する。この関数は必須ではないがあると便利である。
// ないと2行以上になるが
def checkIn(...): (Reservation, ReservationEvent) = {
val event = GuestCheckedIn(...)
(this + event, event)
}
// あると1行で済む
def checkIn(...): (Reservation, ReservationEvent) = this & GuestCheckedIn(...)
なお、&
をメソッド名にしているのも、+
同様にドメインモデルに余計な単語を出さない目的があるのと、メソッドを定義したときに、式がdef command = Entity and Event
になり、「EntityとEventのセットなんだな」とコードが表現的になるためこの記号をよく使う。
Entityの実装を埋めると、次のようになる。
object Reservation {
def reserve(
reservationId: ReservationId,
roomId: RoomId,
guestId: GuestId,
expectedCheckInDate: DateTime,
expectedCheckOutDate: DateTime
): (Reservation, ReservationEvent) =
new Reservation() & RoomReserved(reservationId, roomId, guestId, expectedCheckInDate, expectedCheckOutDate, DateTime.now)
}
case class Reservation(...) {
def this() = ...
def checkIn(checkInDate: DateTime): (Reservation, ReservationEvent) = {
assert(state == ReservationState.Scheduled)
this & GuestCheckedIn(reservationId, guestId, roomId, DateTime.now)
}
def checkOut(checkOutDate: DateTime): (Reservation, ReservationEvent) = {
assert(state == ReservationState.Staying)
this & GuestCheckedOut(reservationId, guestId, roomId, DateTime.now)
}
def cancel(canceledOn: DateTime): (Reservation, ReservationEvent) = {
assert(state == ReservationState.Scheduled)
this & ReservationCanceled(reservationId, roomId, DateTime.now)
}
def +(event: ReservationEvent): Reservation = event match {
case RoomReserved(reservationId, roomId, guestId, checkInDate, checkOutDate, _) =>
copy(reservationId, roomId, guestId, checkInDate, checkOutDate, ReservationState.Scheduled)
case e: GuestCheckedIn =>
copy(state = ReservationState.Staying)
case e: GuestCheckedOut =>
copy(state = ReservationState.CheckedOut)
case e: ReservationCanceled =>
copy(state = ReservationState.Canceled)
}
def &(event: ReservationEvent): (Reservation, ReservationEvent) = (this + event, event)
}
以上でEvent SourcingなEntityの実装が完了となる。Entityを操作する側のコードは次のようになる。
val (reservation1, event1) = Reservation.reserve(ReservationId("UUID"), RoomId(1101), GuestId(432), new DateTime("2016-02-01"), new DateTime("2016-02-03"))
val (reservation2, event2) = reservation1.checkIn(new DateTime("2016-02-01T16:00:00"))
val (reservation3, event3) = reservation2.checkOut(new DateTime("2016-02-03T11:30:00"))
おわり
ScalaでImmutableなEntityを実装する方法を、State SourcingとEvent Sourcingにわけて紹介した。自分はこんな風に実装しているよ、というのがあればご紹介いただければと思います。
-
もしこれを省略できる方法があればご教授くださいm(_ _)m ↩