タム
2024.01.31
31
こんにちは。タムです。
前回はScalaの型応用編の2回目ということで、複数箇所に散らばった通知系のロジックを整理するため、
イベント発行者と観測者という形でオブジェクトをモデリングしました。
今回は更にコードの改良を進めていきます。
アプリケーションの中で発生するイベントは、ドメインオブジェクトに対する何らかのアクションに起因すると考えられるため、
イベント自体はアクションに紐付けたいです。
現状はというと、アプリケーションサービス内でアクションの「直後に」発生する形になっています。
val meeting = // アクション(ミーティングを作成する)
Meeting(
organizerId,
participantIds,
startAt,
endAt,
title,
detail
)
meetingRepository.save(meeting)
val users = userRepsitory.getByIds(organizerId :: participantIds)
publisher.publish(
MeetingCreated(meeting.id, users, title, detail, startAt, endAt) // イベント発生(ミーティングが作成された)
)
上記の場合、Meeting
のインスタンス生成時にイベントが生成されるようにしたいです。
しかし、ここで鬼門なのは、MeetingCreated
はusers
を受け取る必要があるということです。
逆にそれ以外のパラメータに関しては、Meeting
インスタンス化の引数で受け取るため問題ないです。
どうしたらいいでしょうか?
以下に思いつく限り対応案を書き出してみます。
userRepository.getByIds
を先に実行してからインスタンス化の引数として渡すuserRepository.getByIds
を実行するMeetingCreated
がusers
を受け取るのをやめる。その代わり通知処理で必要になるため各Observer
内でuserRepository.getByIds
を実行するObserver
がメールアドレスを必要としない形にする1は手頃ですが、インスタンス化に関係のないものをパラメータに追加したくないです。
2はドメイン層をクリーンな状態にしておきたいので却下です。インスタンス化の引数にrepositoryを渡さないのであればテストが極端に難しくなります。
3はうまくやればいい感じの実装になりそうな気がします。
4については考えがあるので後で説明することになると思います。
というわけで一旦3の方針で実装してみます。
@@ -58,6 +58,7 @@ trait UserRepository:
class MeetingId(val value: Int) extends AnyVal
class Meeting(
+ val id: MeetingId,
val organizerId: UserId,
val participantIds: List[UserId],
val startAt: LocalDateTime,
@@ -65,19 +66,44 @@ class Meeting(
val title: String,
val detail: String
):
- val id = MeetingId(1)
+ def delete: MeetingDeleted =
+ MeetingDeleted(id, organizerId, participantIds)
+
+object Meeting:
+ def create(
+ organizerId: UserId,
+ participantIds: List[UserId],
+ startAt: LocalDateTime,
+ endAt: LocalDateTime,
+ title: String,
+ detail: String
+ ): (Meeting, MeetingCreated) =
+ val id = MeetingId(1)
+ val meeting =
+ Meeting(id, organizerId, participantIds, startAt, endAt, title, detail)
+ val event = MeetingCreated(
+ id,
+ organizerId,
+ participantIds,
+ title,
+ detail,
+ startAt,
+ endAt
+ )
+ (meeting, event)
trait MeetingRepository:
def save(meeting: Meeting): Unit
def get(meetingId: MeetingId): Meeting
- def delete(meetingId: MeetingId): Unit
+ def delete(meetingId: MeetingId, event: MeetingDeleted): Unit
trait DomainEvent:
val occuredOn = LocalDateTime.now()
class MeetingCreated(
val meetingId: MeetingId,
- val users: List[User],
+ val organizerId: UserId,
+ val participantIds: List[UserId],
val title: String,
val detail: String,
val startAt: LocalDateTime,
@@ -86,7 +112,8 @@ class MeetingCreated(
class MeetingDeleted(
val meetingId: MeetingId,
- val users: List[User]
+ val organizerId: UserId,
+ val participantIds: List[UserId]
) extends DomainEvent
trait DomainEventObserver:
@@ -97,22 +124,32 @@ class DomainEventPublisher(obs: DomainEventObserver*):
obs.foreach(_.respondTo(event))
// インフラストラクチャ層
-class EmailSender extends DomainEventObserver:
+class EmailSender(
+ userRepsitory: UserRepository
+) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
- e.users.foreach(u => send(u.email, e.title, e.detail))
+ val users =
+ userRepsitory.getByIds(e.organizerId :: e.participantIds)
+ users.foreach(u => send(u.email, e.title, e.detail))
case e: MeetingDeleted =>
- e.users.foreach(u => send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました"))
+ val users =
+ userRepsitory.getByIds(e.organizerId :: e.participantIds)
+ users.foreach(u => send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました"))
def send(to: Email, subject: String, body: String): Unit =
println(s"email send to $to")
-class EmailReserver extends DomainEventObserver:
+class EmailReserver(
+ userRepsitory: UserRepository
+) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
- e.users.foreach(u => {
+ val users =
+ userRepsitory.getByIds(e.organizerId :: e.participantIds)
+ users.foreach(u => {
reserve(u.email, e.title, e.detail, e.startAt)
reserve(u.email, e.title, e.detail, e.endAt)
})
@@ -130,7 +167,10 @@ class MeetingService(
meetingRepository: MeetingRepository,
userRepsitory: UserRepository
):
- private val publisher = DomainEventPublisher(EmailSender(), EmailReserver())
+ private val publisher = DomainEventPublisher(
+ EmailSender(userRepsitory),
+ EmailReserver(userRepsitory)
+ )
def reserve(
organizerId: UserId,
@@ -140,8 +180,8 @@ class MeetingService(
title: String,
detail: String
): Unit =
- val meeting =
- Meeting(
+ val (meeting, event) =
+ Meeting.create(
organizerId,
participantIds,
startAt,
@@ -151,9 +191,7 @@ class MeetingService(
)
meetingRepository.save(meeting)
val users = userRepsitory.getByIds(organizerId :: participantIds)
- publisher.publish(
- MeetingCreated(meeting.id, users, title, detail, startAt, endAt)
- )
+ publisher.publish(event)
def reserveWithFollowed(
organizerId: UserId,
@@ -164,8 +202,8 @@ class MeetingService(
): Unit =
val organizer = userRepsitory.get(organizerId)
val followers = organizer.followed
- val meeting =
- Meeting(
+ val (meeting, event) =
+ Meeting.create(
organizerId,
followers.map(_.id),
startAt,
@@ -175,13 +213,12 @@ class MeetingService(
)
meetingRepository.save(meeting)
- val users = userRepsitory.getByIds(organizerId :: followers.map(_.id))
- publisher.publish(
- MeetingCreated(meeting.id, users, title, detail, startAt, endAt)
- )
+ publisher.publish(event)
def remove(meetingId: MeetingId): Unit =
val meeting = meetingRepository.get(meetingId)
- meetingRepository.delete(meetingId)
+ val event = meeting.delete
+ meetingRepository.delete(meetingId, event)
- val users =
- userRepsitory.getByIds(meeting.organizerId :: meeting.participantIds)
- publisher.publish(MeetingDeleted(meeting.id, users))
+ publisher.publish(event)
Meeting
のコンストラクタの中でイベントも生成するのは違和感があったため、
コンパニオンオブジェクトのメソッド呼び出しにしました。
ドメインオブジェクト潔癖症なので副作用を持ち込まないようタプルでイベントも返すようにしています。
users
の代わりにMeeting
オブジェクト自体が持っているorganizerId
とparticipantIds
をイベントの属性に含めるようにしました。
後はObserver
がUserRepository
を持ってusers
にマッピングするようにしています。
この実装には少々問題点がありまして、それは、イベントの生成自体は約束されるのですが、
イベントを発行するのを忘れていたとしてもエラーにならないということです。
Golangであれば未使用変数があればエラーになるのですが、Scalaも取り入れてくれませんかね。
とはいえ普通の人であればまあ気づくと思うので、これはこれで良しとしましょう。
あともう一つ、ミーティングの削除に関してはドメイン側に削除イベント生成メソッドを実装しても
そもそも呼び出す必然性がなかったため、meetingRepository.delete()
の引数にイベントを追加しました。
(本来全く必要ないのですが・・・。この辺もっといい実装があれば教えてほしいです。)
さて、一応ドメインイベントをドメインオブジェクトが生成するようにする実装は一通り完了したのですが、
個人的に気に食わないポイントが一つあります。
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u => send(u.email, e.title, e.detail))
case e: MeetingDeleted =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u => send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました"))
このあたりです。
なんというか全然インフラ層っぽくないです。
もともとインフラ層とは技術的な込み入ったコードを書く場所だったはずです。
なのにこれは、「ミーティングが作成されたら、主催者と参加者全員に対してメールを送信する(etc)」と読めて、
ゴリゴリに業務ロジックっぽい感じがします。
では、これはどこに実装すればいいでしょうか、
ドメイン知識というよりアプリケーションに対する要求って感じな気がするので、一旦アプリケーションサービスに実装します。
@@ -124,25 +124,40 @@ class DomainEventPublisher(obs: DomainEventObserver*):
obs.foreach(_.respondTo(event))
// インフラストラクチャ層
-class EmailSender(
- userRepsitory: UserRepository
+class EmailSender:
+ def send(to: Email, subject: String, body: String): Unit =
+ println(s"email send to $to")
+
+class EmailReserver:
+ def reserve(
+ to: Email,
+ subject: String,
+ body: String,
+ when: LocalDateTime
+ ): Unit =
+ println(s"email reserved sending to $to when $when")
+
+// アプリケーション層
+class EmailSendService(
+ userRepsitory: UserRepository,
+ sender: EmailSender
) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
- users.foreach(u => send(u.email, e.title, e.detail))
+ users.foreach(u => sender.send(u.email, e.title, e.detail))
case e: MeetingDeleted =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
- users.foreach(u => send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました"))
+ users.foreach(u =>
+ sender.send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました")
+ )
- def send(to: Email, subject: String, body: String): Unit =
- println(s"email send to $to")
-
-class EmailReserver(
- userRepsitory: UserRepository
+class EmailReserveService(
+ userRepsitory: UserRepository,
+ reserver: EmailReserver
) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
@@ -150,26 +165,17 @@ class EmailReserver(
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u => {
- reserve(u.email, e.title, e.detail, e.startAt)
- reserve(u.email, e.title, e.detail, e.endAt)
+ reserver.reserve(u.email, e.title, e.detail, e.startAt)
+ reserver.reserve(u.email, e.title, e.detail, e.endAt)
})
- def reserve(
- to: Email,
- subject: String,
- body: String,
- when: LocalDateTime
- ): Unit =
- println(s"email reserved sending to $to when $when")
-
-// アプリケーション層
class MeetingService(
meetingRepository: MeetingRepository,
userRepsitory: UserRepository
):
private val publisher = DomainEventPublisher(
- EmailSender(userRepsitory),
- EmailReserver(userRepsitory)
+ EmailSendService(userRepsitory, EmailSender()),
+ EmailReserveService(userRepsitory, EmailReserver())
)
def reserve(
すっきりしました。責任範囲がちゃんと分離できた気がします。
依存関係に関してはアプリケーションサービスがインフラを参照してしまっていますが、
些細な問題なので今日のところはここまでにしておきます。
今回はドメインイベントの発行主体をドメインオブジェクトにする修正と、
インフラ層からアプリケーションロジックを切り離すリファクタを行いました。
現時点でのコード全容を添付しておきます。
// ドメイン層
class UserId(val value: Int) extends AnyVal
class Email(val value: String) extends AnyVal
class User(val id: UserId, val email: Email):
def followed: List[User] =
List(
User(UserId(1), Email("hoge1@example.com")),
User(UserId(2), Email("hoge2@example.com"))
)
trait UserRepository:
def get(id: UserId) =
User(id, Email("hoge@example.com"))
def getByIds(ids: List[UserId]) =
ids.map(User(_, Email("hoge@example.com")))
class MeetingId(val value: Int) extends AnyVal
class Meeting(
val id: MeetingId,
val organizerId: UserId,
val participantIds: List[UserId],
val startAt: LocalDateTime,
val endAt: LocalDateTime,
val title: String,
val detail: String
):
def delete: MeetingDeleted =
MeetingDeleted(id, organizerId, participantIds)
object Meeting:
def create(
organizerId: UserId,
participantIds: List[UserId],
startAt: LocalDateTime,
endAt: LocalDateTime,
title: String,
detail: String
): (Meeting, MeetingCreated) =
val id = MeetingId(1)
val meeting =
Meeting(id, organizerId, participantIds, startAt, endAt, title, detail)
val event = MeetingCreated(
id,
organizerId,
participantIds,
title,
detail,
startAt,
endAt
)
(meeting, event)
trait MeetingRepository:
def save(meeting: Meeting): Unit
def get(meetingId: MeetingId): Meeting
def delete(meetingId: MeetingId, event: MeetingDeleted): Unit
trait DomainEvent:
val occuredOn = LocalDateTime.now()
class MeetingCreated(
val meetingId: MeetingId,
val organizerId: UserId,
val participantIds: List[UserId],
val title: String,
val detail: String,
val startAt: LocalDateTime,
val endAt: LocalDateTime
) extends DomainEvent
class MeetingDeleted(
val meetingId: MeetingId,
val organizerId: UserId,
val participantIds: List[UserId]
) extends DomainEvent
trait DomainEventObserver:
def respondTo[T <: DomainEvent](event: T): Unit
class DomainEventPublisher(obs: DomainEventObserver*):
def publish[T <: DomainEvent](event: T): Unit =
obs.foreach(_.respondTo(event))
// インフラストラクチャ層
class EmailSender:
def send(to: Email, subject: String, body: String): Unit =
println(s"email send to $to")
class EmailReserver:
def reserve(
to: Email,
subject: String,
body: String,
when: LocalDateTime
): Unit =
println(s"email reserved sending to $to when $when")
// アプリケーション層
class EmailSendService(
userRepsitory: UserRepository,
sender: EmailSender
) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u => sender.send(u.email, e.title, e.detail))
case e: MeetingDeleted =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u =>
sender.send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました")
)
class EmailReserveService(
userRepsitory: UserRepository,
reserver: EmailReserver
) extends DomainEventObserver:
override def respondTo[T <: DomainEvent](event: T): Unit =
event match
case e: MeetingCreated =>
val users =
userRepsitory.getByIds(e.organizerId :: e.participantIds)
users.foreach(u => {
reserver.reserve(u.email, e.title, e.detail, e.startAt)
reserver.reserve(u.email, e.title, e.detail, e.endAt)
})
class MeetingService(
meetingRepository: MeetingRepository,
userRepsitory: UserRepository
):
private val publisher = DomainEventPublisher(
EmailSendService(userRepsitory, EmailSender()),
EmailReserveService(userRepsitory, EmailReserver())
)
def reserve(
organizerId: UserId,
participantIds: List[UserId],
startAt: LocalDateTime,
endAt: LocalDateTime,
title: String,
detail: String
): Unit =
val (meeting, event) =
Meeting.create(
organizerId,
participantIds,
startAt,
endAt,
title,
detail
)
meetingRepository.save(meeting)
publisher.publish(event)
def reserveWithFollowed(
organizerId: UserId,
startAt: LocalDateTime,
endAt: LocalDateTime,
title: String,
detail: String
): Unit =
val organizer = userRepsitory.get(organizerId)
val followers = organizer.followed
val (meeting, event) =
Meeting.create(
organizerId,
followers.map(_.id),
startAt,
endAt,
title,
detail
)
meetingRepository.save(meeting)
publisher.publish(event)
def remove(meetingId: MeetingId): Unit =
val meeting = meetingRepository.get(meetingId)
val event = meeting.delete
meetingRepository.delete(meetingId, event)
publisher.publish(event)