タム
2024.01.28
22
こんにちは。タムです。
前回は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)
現状の実装の何が問題でしょうか。
例えば、以下のような仕様変更があったとします。
いずれの場合も複数箇所を修正する必要があります。
今はミーティングサービスだけしか見ていませんが、他のサービスを考えると更に多くの箇所を修正しなくてはなりません。
影響範囲の特定・修正に時間がかかりますし、リグレッションにも注意しなければなりません。
行き当りばったりのリファクタではなく、根本的な設計を考え直してみます。
この設計でリファクタし直してみましょう。
@@ -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))