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

タム

2024.01.28

1

こんにちは。タムです。

前回はScalaの型応用編の1回目ということで、例として用いるミーティングサービスの仕様と初回実装を行いました。

今回は、前回の実装をリファクタリングしていきたいと思います。

ドメインイベントの導入

複数箇所で発生するイベント

まず最初に前回のおさらいとして前回の実装の一部を再掲します。

// サービス層
class MeetingService(
    meetingRepository: MeetingRepository,
    userRepsitory: UserRepository
):
  def reserve(
      organizerId: UserId,
      participantIds: List[UserId],
      startAt: LocalDateTime,
      endAt: LocalDateTime,
      title: String,
      detail: String
  ): Unit =
    val meeting =
      Meeting(organizerId, participantIds, startAt, endAt, title, detail)
    meetingRepository.save(meeting)

    def notify(user: User) =
      EmailSender().send(user.email, title, detail)
      EmailReserver().reserve(user.email, title, detail, startAt)
      EmailReserver().reserve(user.email, title, detail, endAt)

    val users = userRepsitory.getByIds(organizerId :: participantIds)
    users.foreach(notify)


現時点では特に問題のあるコードのようには思えません。

では、(少々こじつけ感がありますが)以下のような仕様を追加します。

  • このシステムには別のユーザをフォローする機能も他にある
  • 主催者は参加者を選んでミーティングを作成することの他に、参加者を自分で直接選ばずミーティングを作成することもできる
    • その場合、参加者は自分をフォローしているユーザに設定される


雑に実装するとこんな感じでしょうか。

-class User(val id: UserId, val email: Email)
+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")))
 
@@ -97,3 +105,24 @@ class MeetingService(
 
     val users = userRepsitory.getByIds(organizerId :: participantIds)
     users.foreach(notify)
+
+  def reserveWithFollowed(
+      organizerId: UserId,
+      startAt: LocalDateTime,
+      endAt: LocalDateTime,
+      title: String,
+      detail: String
+  ): Unit =
+    val organizer = userRepsitory.get(organizerId)
+    val followers = organizer.followed
+    val meeting =
+      Meeting(organizerId, followers.map(_.id), startAt, endAt, title, detail)
+    meetingRepository.save(meeting)
+
+    def notify(user: User) =
+      EmailSender().send(user.email, title, detail)
+      EmailReserver().reserve(user.email, title, detail, startAt)
+      EmailReserver().reserve(user.email, title, detail, endAt)
+
+    val users = userRepsitory.getByIds(organizerId :: followers.map(_.id))
+    users.foreach(notify)


こうなってくると、流石にリファクタしたくなってきます。

とりあえず一旦通知関連の処理を内部メソッドに切り出しましょう。

@@ -98,13 +98,14 @@ class MeetingService(
       Meeting(organizerId, participantIds, startAt, endAt, title, detail)
     meetingRepository.save(meeting)
 
-    def notify(user: User) =
-      EmailSender().send(user.email, title, detail)
-      EmailReserver().reserve(user.email, title, detail, startAt)
-      EmailReserver().reserve(user.email, title, detail, endAt)
-
-    val users = userRepsitory.getByIds(organizerId :: participantIds)
-    users.foreach(notify)
+    notifyMeetingCreated(
+      organizerId,
+      participantIds,
+      title,
+      detail,
+      startAt,
+      endAt
+    )
 
   def reserveWithFollowed(
       organizerId: UserId,
@@ -119,10 +120,27 @@ class MeetingService(
       Meeting(organizerId, followers.map(_.id), startAt, endAt, title, detail)
     meetingRepository.save(meeting)
 
+    notifyMeetingCreated(
+      organizerId,
+      followers.map(_.id),
+      title,
+      detail,
+      startAt,
+      endAt
+    )
+
+  private def notifyMeetingCreated(
+      organizerId: UserId,
+      participantIds: List[UserId],
+      title: String,
+      detail: String,
+      startAt: LocalDateTime,
+      endAt: LocalDateTime
+  ): Unit =
     def notify(user: User) =
       EmailSender().send(user.email, title, detail)
       EmailReserver().reserve(user.email, title, detail, startAt)
       EmailReserver().reserve(user.email, title, detail, endAt)
 
-    val users = userRepsitory.getByIds(organizerId :: followers.map(_.id))
+    val users = userRepsitory.getByIds(organizerId :: participantIds)
     users.foreach(notify)


多少はスッキリしました。内部メソッドが大量の引数を受け取っているのが気持ち悪いですが、一旦目を瞑ります。

別のイベント

更に要件が追加され、ミーティング情報を削除した際にも主催者と参加者全員に対してメール通知をすることになったとします。

実装してみます。

@@ -55,17 +55,22 @@ trait UserRepository:
   def getByIds(ids: List[UserId]) =
     ids.map(User(_, Email("hoge@example.com")))
 
+class MeetingId(val value: Int) extends AnyVal
+
 class Meeting(
-    organizerId: UserId,
-    participantIds: List[UserId],
-    startAt: LocalDateTime,
-    endAt: LocalDateTime,
-    title: String,
-    detail: String
+    val id: Option[MeetingId],
+    val organizerId: UserId,
+    val participantIds: List[UserId],
+    val startAt: LocalDateTime,
+    val endAt: LocalDateTime,
+    val title: String,
+    val detail: String
 )
 
 trait MeetingRepository:
   def save(meeting: Meeting): Unit
+  def get(meetingId: MeetingId): Meeting
+  def delete(meetingId: MeetingId): Unit
 
 // インフラストラクチャ層
 class EmailSender:
@@ -95,7 +100,7 @@ class MeetingService(
       detail: String
   ): Unit =
     val meeting =
-      Meeting(organizerId, participantIds, startAt, endAt, title, detail)
+      Meeting(None, organizerId, participantIds, startAt, endAt, title, detail)
     meetingRepository.save(meeting)
 
     notifyMeetingCreated(
@@ -117,7 +122,15 @@ class MeetingService(
     val organizer = userRepsitory.get(organizerId)
     val followers = organizer.followed
     val meeting =
-      Meeting(organizerId, followers.map(_.id), startAt, endAt, title, detail)
+      Meeting(
+        None,
+        organizerId,
+        followers.map(_.id),
+        startAt,
+        endAt,
+        title,
+        detail
+      )
     meetingRepository.save(meeting)
 
     notifyMeetingCreated(
@@ -144,3 +157,14 @@ class MeetingService(
 
     val users = userRepsitory.getByIds(organizerId :: participantIds)
     users.foreach(notify)
+
+  def remove(meetingId: MeetingId): Unit =
+    val meeting = meetingRepository.get(meetingId)
+    meetingRepository.delete(meetingId)
+
+    def notify(user: User) =
+      EmailSender().send(user.email, "ミーティング削除のお知らせ", "ミーティングが削除されました")
+
+    val userIds = meeting.organizerId :: meeting.participantIds
+    val users = userRepsitory.getByIds(userIds)
+    users.foreach(notify)


また個別の通知ロジックが出てきました。

今回はコードの重複を予想し、先に内部メソッドに切り出しておきましょう。

@@ -162,9 +162,15 @@ class MeetingService(
     val meeting = meetingRepository.get(meetingId)
     meetingRepository.delete(meetingId)
 
+    notifyMeetingDeleted(meeting.organizerId, meeting.participantIds)
+
+  private def notifyMeetingDeleted(
+      organizerId: UserId,
+      participantIds: List[UserId]
+  ): Unit =
     def notify(user: User) =
       EmailSender().send(user.email, "ミーティング削除のお知らせ", "ミーティングが削除されました")
 
-    val userIds = meeting.organizerId :: meeting.participantIds
+    val userIds = organizerId :: participantIds
     val users = userRepsitory.getByIds(userIds)
     users.foreach(notify)


問題点と対応方針

現状の実装の何が問題でしょうか。

例えば、以下のような仕様変更があったとします。

  • メール通知の他にプッシュ通知も追加してほしい
  • メール通知の際にHTMLかプレーンテキストかを選択できるようにしたい

いずれの場合も複数箇所を修正する必要があります。

今はミーティングサービスだけしか見ていませんが、他のサービスを考えると更に多くの箇所を修正しなくてはなりません。

影響範囲の特定・修正に時間がかかりますし、リグレッションにも注意しなければなりません。


行き当りばったりのリファクタではなく、根本的な設計を考え直してみます。

  • 「ミーティングが作成された」「ミーティングが削除された」のような「イベント」が存在する
  • メール送信者、メール予約者など、イベントの「観測者」が存在する
  • イベントは任意の場所から発行される。観測者はイベントが発行されるとそのイベントの種類に応じて反応する

この設計でリファクタし直してみましょう。

リファクタリング

@@ -58,26 +58,65 @@ trait UserRepository:
 class MeetingId(val value: Int) extends AnyVal
 
 class Meeting(
-    val id: Option[MeetingId],
     val organizerId: UserId,
     val participantIds: List[UserId],
     val startAt: LocalDateTime,
     val endAt: LocalDateTime,
     val title: String,
     val detail: String
-)
+):
+  val id = MeetingId(1)
 
 trait MeetingRepository:
   def save(meeting: Meeting): Unit
   def get(meetingId: MeetingId): Meeting
   def delete(meetingId: MeetingId): Unit
 
+trait DomainEvent:
+  val occuredOn = LocalDateTime.now()
+
+class MeetingCreated(
+    val meetingId: MeetingId,
+    val users: List[User],
+    val title: String,
+    val detail: String,
+    val startAt: LocalDateTime,
+    val endAt: LocalDateTime
+) extends DomainEvent
+
+class MeetingDeleted(
+    val meetingId: MeetingId,
+    val users: List[User]
+) 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:
+class EmailSender 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))
+      case e: MeetingDeleted =>
+        e.users.foreach(u => send(u.email, "ミーティング削除のお知らせ", "ミーティングが削除されました"))
+
   def send(to: Email, subject: String, body: String): Unit =
     println(s"email send to $to")
 
-class EmailReserver:
+class EmailReserver extends DomainEventObserver:
+  override def respondTo[T <: DomainEvent](event: T): Unit =
+    event match
+      case e: MeetingCreated =>
+        e.users.foreach(u => {
+          reserve(u.email, e.title, e.detail, e.startAt)
+          reserve(u.email, e.title, e.detail, e.endAt)
+        })
+
   def reserve(
       to: Email,
       subject: String,
@@ -91,6 +130,8 @@ class MeetingService(
     meetingRepository: MeetingRepository,
     userRepsitory: UserRepository
 ):
+  private val publisher = DomainEventPublisher(EmailSender(), EmailReserver())
+
   def reserve(
       organizerId: UserId,
       participantIds: List[UserId],
@@ -100,16 +141,18 @@ class MeetingService(
       detail: String
   ): Unit =
     val meeting =
-      Meeting(None, organizerId, participantIds, startAt, endAt, title, detail)
+      Meeting(
+        organizerId,
+        participantIds,
+        startAt,
+        endAt,
+        title,
+        detail
+      )
     meetingRepository.save(meeting)
-
-    notifyMeetingCreated(
-      organizerId,
-      participantIds,
-      title,
-      detail,
-      startAt,
-      endAt
+    val users = userRepsitory.getByIds(organizerId :: participantIds)
+    publisher.publish(
+      MeetingCreated(meeting.id, users, title, detail, startAt, endAt)
     )
 
   def reserveWithFollowed(
@@ -123,7 +166,6 @@ class MeetingService(
     val followers = organizer.followed
     val meeting =
       Meeting(
-        None,
         organizerId,
         followers.map(_.id),
         startAt,
@@ -132,45 +174,14 @@ class MeetingService(
         detail
       )
     meetingRepository.save(meeting)
-
-    notifyMeetingCreated(
-      organizerId,
-      followers.map(_.id),
-      title,
-      detail,
-      startAt,
-      endAt
+    val users = userRepsitory.getByIds(organizerId :: followers.map(_.id))
+    publisher.publish(
+      MeetingCreated(meeting.id, users, title, detail, startAt, endAt)
     )
 
-  private def notifyMeetingCreated(
-      organizerId: UserId,
-      participantIds: List[UserId],
-      title: String,
-      detail: String,
-      startAt: LocalDateTime,
-      endAt: LocalDateTime
-  ): Unit =
-    def notify(user: User) =
-      EmailSender().send(user.email, title, detail)
-      EmailReserver().reserve(user.email, title, detail, startAt)
-      EmailReserver().reserve(user.email, title, detail, endAt)
-
-    val users = userRepsitory.getByIds(organizerId :: participantIds)
-    users.foreach(notify)
-
   def remove(meetingId: MeetingId): Unit =
     val meeting = meetingRepository.get(meetingId)
     meetingRepository.delete(meetingId)
-
-    notifyMeetingDeleted(meeting.organizerId, meeting.participantIds)
-
-  private def notifyMeetingDeleted(
-      organizerId: UserId,
-      participantIds: List[UserId]
-  ): Unit =
-    def notify(user: User) =
-      EmailSender().send(user.email, "ミーティング削除のお知らせ", "ミーティングが削除されました")
-
-    val userIds = organizerId :: participantIds
-    val users = userRepsitory.getByIds(userIds)
-    users.foreach(notify)
+    val users =
+      userRepsitory.getByIds(meeting.organizerId :: meeting.participantIds)
+    publisher.publish(MeetingDeleted(meeting.id, users))


アプリケーションサービスの実装がすっきりし、通知に関するロジックはそれぞれの観測者のイベントごとの実装に収まりました。

(なお、実装はデザインパターンのObserverパターンを使用しているつもりです。)

まだまだ改善の余地はありそうですが、長くなってきたので今回はここまでとします。


そういえば、ようやく出てきましたね、

+  def respondTo[T <: DomainEvent](event: T): Unit


上限境界です。

Pub/Subの実装に気を取られてこのブログの趣旨を忘れてしまっていました(笑)

まとめ

今回は、イベントの発火に関するロジックをまとめるために、Observerパターンを使用してリファクタしました。

現時点でのコードの全容を貼っておきます。

// ドメイン層
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 organizerId: UserId,
    val participantIds: List[UserId],
    val startAt: LocalDateTime,
    val endAt: LocalDateTime,
    val title: String,
    val detail: String
):
  val id = MeetingId(1)

trait MeetingRepository:
  def save(meeting: Meeting): Unit
  def get(meetingId: MeetingId): Meeting
  def delete(meetingId: MeetingId): Unit

trait DomainEvent:
  val occuredOn = LocalDateTime.now()

class MeetingCreated(
    val meetingId: MeetingId,
    val users: List[User],
    val title: String,
    val detail: String,
    val startAt: LocalDateTime,
    val endAt: LocalDateTime
) extends DomainEvent

class MeetingDeleted(
    val meetingId: MeetingId,
    val users: List[User]
) 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 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))
      case e: MeetingDeleted =>
        e.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:
  override def respondTo[T <: DomainEvent](event: T): Unit =
    event match
      case e: MeetingCreated =>
        e.users.foreach(u => {
          reserve(u.email, e.title, e.detail, e.startAt)
          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(), EmailReserver())

  def reserve(
      organizerId: UserId,
      participantIds: List[UserId],
      startAt: LocalDateTime,
      endAt: LocalDateTime,
      title: String,
      detail: String
  ): Unit =
    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)
    )

  def reserveWithFollowed(
      organizerId: UserId,
      startAt: LocalDateTime,
      endAt: LocalDateTime,
      title: String,
      detail: String
  ): Unit =
    val organizer = userRepsitory.get(organizerId)
    val followers = organizer.followed
    val meeting =
      Meeting(
        organizerId,
        followers.map(_.id),
        startAt,
        endAt,
        title,
        detail
      )
    meetingRepository.save(meeting)
    val users = userRepsitory.getByIds(organizerId :: followers.map(_.id))
    publisher.publish(
      MeetingCreated(meeting.id, users, title, detail, startAt, endAt)
    )

  def remove(meetingId: MeetingId): Unit =
    val meeting = meetingRepository.get(meetingId)
    meetingRepository.delete(meetingId)
    val users =
      userRepsitory.getByIds(meeting.organizerId :: meeting.participantIds)
    publisher.publish(MeetingDeleted(meeting.id, users))


この記事をシェアする