Scalaの型について(応用編その3)

タム

2024.01.31

4

こんにちは。タムです。

前回は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のインスタンス生成時にイベントが生成されるようにしたいです。

しかし、ここで鬼門なのは、MeetingCreatedusersを受け取る必要があるということです。

逆にそれ以外のパラメータに関しては、Meetingインスタンス化の引数で受け取るため問題ないです。

どうしたらいいでしょうか?


以下に思いつく限り対応案を書き出してみます。

  1. userRepository.getByIdsを先に実行してからインスタンス化の引数として渡す
  2. インスタンス化処理の中でuserRepository.getByIdsを実行する
  3. そもそもMeetingCreatedusersを受け取るのをやめる。その代わり通知処理で必要になるため各Observer内でuserRepository.getByIdsを実行する
  4. そもそもObserverがメールアドレスを必要としない形にする

1は手頃ですが、インスタンス化に関係のないものをパラメータに追加したくないです。

2はドメイン層をクリーンな状態にしておきたいので却下です。インスタンス化の引数にrepositoryを渡さないのであればテストが極端に難しくなります。

3はうまくやればいい感じの実装になりそうな気がします。

4については考えがあるので後で説明することになると思います。

というわけで一旦3の方針で実装してみます。

Observer内でRepositoryにアクセスする実装

@@ -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オブジェクト自体が持っているorganizerIdparticipantIdsをイベントの属性に含めるようにしました。

後はObserverUserRepositoryを持って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)


この記事をシェアする