Slide 1

Slide 1 text

1/35

Slide 2

Slide 2 text

Goal 既存コードの問題点をドメインイベントで解決する ドメインイベントの実装方法を知る 2/35

Slide 3

Slide 3 text

梶川 琢馬 / 𝕏 @kajitack 株式会社 TechBowl / プロダクトエンジニア 複数のプロダクトで PHP を使った開発を経験してきました。 運動不足解消のために始めたトライアスロンにハマってます。 初 PHPカンファレンス関西! 初 神戸! 3/35

Slide 4

Slide 4 text

4/35

Slide 5

Slide 5 text

ドメインイベント導入前 例:ユーザー登録のフロー 5/35

Slide 6

Slide 6 text

シンプルにデータを永続化する class UserController { public function create($userData) { $user = new User(); $user->setEmail($userData['email']); $user->setName($userData['name']); $this->userRepository->save($user); } } 6/35

Slide 7

Slide 7 text

副作用と共に肥大化していくコード 実際はデータの保存と一緒に色々な副作用が発生する class UserController { public function create($userData) { $user = new User(); $user->setEmail($userData['email']); $user->setName($userData['name']); $this->userRepository->save($user); // 副作用1: メール送信 // 副作用2: 運営に通知 // 副作用3: 契約プランの作成 // 副作用4: 招待コード利用処理 // 副作用5: 関連サービスへの連携 // etc... } } 7/35

Slide 8

Slide 8 text

実装してみると... コードの見通しが悪くなる 変更の影響範囲が予測しづらい テストケースが複雑になり、保守が困難 class UserController { public function create($userData) { try { // DB保存 $user = new User(); $user->setEmail($userData['email']); $user->setName($userData['name']); $this->userRepository->save($user); // メール送信 $this->mailService->sendWelcomeEmail([ 'to' => $user->getEmail(), 'name' => $user->getName() ]); // 運営に通知 $this->notificationService->notifyToAdmin([ 'message' => "新規ユーザー作成: {$user->getName()}", 'channel' => 'user-create' ]); // 契約プランの作成 $plan = $this->planService->createFreePlan($user->getId()); // 招待コードがある場合の処理 if (isset($userData['invitation_code'])) { $this->invitationService->markAsUsed($userData['invitation_code'], $user->getId()); // 紹介者にポイント付与 $inviter = $this->invitationService->getInviter($userData['invitation_code']); $this->planService->addReferralPoints($inviter->getId(), 1000); } // 関連サービスへの連携 $this->externalService->syncUser([ 'user_id' => $user->getId(), 'email' => $user->getEmail(), 'name' => $user->getName() ]); return $user; } catch (Exception $e) { // エラーハンドリング } } } 8/35

Slide 9

Slide 9 text

処理を切り出したところで... ユーザー作成処理が 副作用の処理に依存 どんな処理を行うかを全て知っている必 要がある 9/35

Slide 10

Slide 10 text

テスト時に副作用の数だけモックが必要 #[Test] public function ユーザーを作成する() { $repo = $this->mock(UserRepository::class); $mailService = $this->mock(MailService::class); $notification = $this->mock(NotificationService::class); $planService = $this->mock(PlanService::class); $externalService = $this->mock(ExternalService::class); // 全てのモックに期待値設定... $repo->expects($this->once())->method('save'); $mailService->expects($this->once())->method('sendWelcomeEmail'); // ...etc } 10/35

Slide 11

Slide 11 text

ドメインイベントを導入すると... イベント中心に処理が 独立 ユーザー作成の処理に集中できる 11/35

Slide 12

Slide 12 text

イベントの発行と副作用のテストを分けることができ... テストが書きやすくなる #[Test] public function ユーザーを作成する() { // ユーザー作成時にイベントが発行されることを確認 $this->mock(DomainEventPublisher::class) ->expects($this->once()) ->method('publish'); app()->make(UserCreateUsecase::class)->exec($input); } #[Test] public function ウェルカムメールを送信する(): void { $mailService = $this->mock(MailService::class); $mailService->expects($this->once()) ->method('sendWelcomeMail') ->with($this->equalTo(new Email('[email protected]'))); $event = new UserCreatedEvent( new UserId('123'), new Email('[email protected]') ); app()->make(WelcomeMailSubscriber::class)->handle($event); } 12/35

Slide 13

Slide 13 text

まとめ ドメインイベントを使う メリット 処理に集中できる 副作用を独立できる テストが書きやすくなる 13/35

Slide 14

Slide 14 text

ドメインイベントとは ドメイン = 扱っている事業・業務領域 EC サイト:商品・注文・決済 SNS:ユーザー・投稿・フォロー ドメインで発生する「出来事」を表現するモデル 「ユーザーが登録された」 「注文が確定した」 「在庫が切れた」 14/35

Slide 15

Slide 15 text

イベントの洗い出し 「ユーザーが登録された時」に「ウェルカムメールを送りたい」 「ユーザーが登録された時」に「運営に通知したい」 「ユーザーが登録された時」に「契約プランを作成したい」 → ビジネス側の「〜したら」「〜のとき」という言葉がドメインイベントのヒント 15/35

Slide 16

Slide 16 text

イベントを図にまとめる 重要なビジネスイベントを洗い出し、コンテキス トごとにまとめる 図でまとめると分かりやすい TechBowl流ドメインモデルでの情報設計 https://speakerdeck.com/hiroki_nakamura/techbowlliu-domeinmoderingu-2025-02-28 DDD × Whimsicalで快適モデリングライフ! https://zenn.dev/techtrain_blog/articles/334ac36e79d946 16/35

Slide 17

Slide 17 text

ドメインイベントを使った実装 17/35

Slide 18

Slide 18 text

イベントを中心に実装 Publisher(パブリッシャー) イベントを発行 Subscriber(サブスクライバー) イベント発行時に処理を実行 18/35

Slide 19

Slide 19 text

Subscriber Publisher Repository Domain Model Usecase Subscriber Publisher Repository Domain Model Usecase インスタンス作成 イベント作成 Publisher の登録 永続化処理 ドメインイベントを取得 イベントの発⾏ イベントを受け取る ドメインイベントの発行 と処理の流れ 1. ドメインオブジェクトでドメインイベントを作成 と保持 2. 永続化した後にドメインイベントを取り出し、パ ブリッシャーで発行 3. サブスクライバーでイベントの通知を受け取って 処理 19/35

Slide 20

Slide 20 text

Subscriberはどのタイミングで実行するかを検討 同期的に実行 従来の処理をクラスに切り出すだけなので実装は楽 イベントが増えるたびにトランザクションが長くなる 非同期で実行 結果整合性を保つための工夫が必要 イベントが増えてもユーザーへ早くレスポンスを返すことが出来る まずは同期処理で実装して、非同期にする流れで紹介します! 20/35

Slide 21

Slide 21 text

ドメインモデルがドメインイベントを作成する 「ユーザー作成時」に「ユーザーが作成された」イベントを作成し、保持する class User implements IDomainEventStorable { use DomainEventStorable; public static function create(UserId $userId, Email $email): self { $user = new self($userId, $email); // ドメインイベントを内部に保持 $user->pushDomainEvent(new UserCreatedEvent($userId, $email)); return $user; } } interface IDomainEventStorable { public function pushDomainEvent(DomainEvent $event): void; public function pullDomainEvents(): array; } 21/35

Slide 22

Slide 22 text

パブリッシャーとサブスクライバー ドメインイベントをサブスクライバーに発行する処理を実装 final class UserCreatedEventPublisher { public function __construct( WelcomeMailSubscriber $mailSubscriber, CreateStripeCustomerSubscriber $stripeSubscriber, SendSlackMessageSubscriber $slackSubscriber, ) { // コンストラクタでsubscribersに追加する } public function publish(DomainEvent $event): void { foreach ($this->subscribers as $subscriber) { $subscriber->handle($event); } } } class WelcomeMailSubscriber { public function handle(UserCreatedEvent $event): void { // イベントに応じた処理を実行 $this->mailService->sendWelcomeMail($event->email()); } } 22/35

Slide 23

Slide 23 text

永続化のタイミングでイベント発行 リポジトリでドメインイベントをパブリッシュしなければいけないということを明示的にする class UserCreateUsecase { public function exec(UserId $userId, Email $email): void { // ドメインモデルを生成(ここでイベントが内部的に作成される) $user = User::create($userId, $email); DB::transaction(function () use ($user) { // パブリッシャーの登録 $this->userRepository->create($user, $this->eventPublisher); }); } } class UserRepository { public function create( User $user, UserCreatedEventPublisher $eventPublisher ): void { $this->dao->save($user); // 永続化後にイベントの発行 foreach ($user->pullDomainEvents() as $event) { $eventPublisher->publish($event); } } } 23/35

Slide 24

Slide 24 text

ここまででドメインイベントの同期処理の流れは完了! 24/35

Slide 25

Slide 25 text

非同期処理のメリット ユーザーへのレスポンスが速くなる(ユーザー作成完了だけで OK) 時間のかかるシステムだけスケールさせてパフォーマンスを得られる トランザクションの範囲を狭くすることができる 25/35

Slide 26

Slide 26 text

非同期処理の実装方針 1. ドメインイベントをメッセージングサービスに Publish する 2. メッセージングサービスを Subscribe するエンドポイントを用意する 26/35

Slide 27

Slide 27 text

メッセージングサービス (Pub/Sub型) Google Cloud Pub/Sub など外部のメッセージングサ ービスを使う 送信者と受信者の間の仲介するトピックがあり、お 互いの動作に影響を与えない 27/35

Slide 28

Slide 28 text

非同期処理での注意点 「ドメインの状態更新」と「外部への通知」が 別タイミングになる 何らかの理由でロールバックしてしまった際に、 データは無いのにイベントだけ発行されてしまう... 28/35

Slide 29

Slide 29 text

解決策 イベントを DB に保存 同じトランザクションの中でイベントも保存してお いて、後で取り出す 29/35

Slide 30

Slide 30 text

Subscriber Pub/Sub Batch Event Store (DB) Subscriber Pub/Sub Batch Event Store (DB) 未処理イベントを取得 (status = 'pending') UserCreatedEvent のリスト メッセージを送信 (Topic: user-events) メッセージを送信 (Topic: user-events) イベントを処理済みにマーク (status = 'processed') DBに保存したイベントを バッチ処理でpublish 1. ドメインモデルがドメインイベントを作成 2. イベントをDBに保存 3. バッチ処理で未処理のドメインイベントをメッセ ージングサービスにPublish 4. メッセージングサービスからSubscribeしたイベ ントに対応する処理を行う 30/35

Slide 31

Slide 31 text

メッセージングサービスへの保存 class EventStore { // イベントの保存 public function save(DomainEvent $event): void { $stmt->execute([ Uuid::generate(), get_class($event), $event->aggregateId(), json_encode($event->toArray()), 'pending', // 未処理状態 new DateTimeImmutable() ]); } // 未処理のイベント取得 public function findUnprocessedEvents(int $limit = 100): array {} // 処理済みとして更新 public function markAsProcessed(UUID $eventId): void {} } 31/35

Slide 32

Slide 32 text

バッチ処理の実装 class PublishPendingEventsCommand { public function handle(): void { $events = $this->eventStore->findUnprocessedEvents(); foreach ($events as $event) { // メッセージングサービスにパブリッシュ $this->queueClient->publish( 'user_events', json_encode([ 'id' => $event['event_id'], 'event_type' => $event['event_type'], 'payload' => json_decode($event['payload'], true), 'created_at' => $event['created_at'] ]) ); // 処理済みとしてマーク $this->eventStore->markAsProcessed($event['event_id']); } } 32/35

Slide 33

Slide 33 text

イベント処理 class UserEventController { public function onCreated(Request $request): Response { $payload = $request->json()->all(); $eventId = $payload['id']; // イベントの復元 $event = $this->restoreEvent($payload); // イベントに対応するハンドラを取得 $handler = $this->resolver->resolve($event); // イベントの処理を実行 $handler->handle($event); // 処理済みとしてマーク $this->processedEventStore->markAsProcessed($eventId); } } 33/35

Slide 34

Slide 34 text

実装上のポイント 1. 「何が起きているか」を整理しよう 2. 出来事をコードに落とす 3. 同期処理から始めて、必要に応じて非同期化 4. 段階的にリファクタリングしていく 34/35

Slide 35

Slide 35 text

まとめ ドメインイベントはビジネスロジックにおける 「出来事」を表現 リファクタリングによって コードが「機能の手順」から「出来事」中心に変わ る 疎結合な設計になり、 テスト容易性も向上した 35/35