Slide 1

Slide 1 text

1/34

Slide 2

Slide 2 text

梶川 琢馬 (@kajitack) 株式会社TechBowlに2023年に中途入社 趣味はトライアスロン 2/34

Slide 3

Slide 3 text

肥大化していくコード ユーザー時に色々やりたい 1クラスに多くの責務が集中 テストも複雑になりがち(副作用の数だけモックが必要) 責務が混在し、変更の影響範囲が予測しづらい class UserController { public function create($userData) { // DB に保存 // 副作用1 メール送信 // 副作用2 運営に通知 // 副作用3 契約プランの作成 // 副作用4 招待コード利用処理 // 副作用5 関連サービスへの連携 // etc... } } 3/34

Slide 4

Slide 4 text

実装すると... class UserController { private $userRepository; private $mailService; private $notificationService; private $planService; private $invitationService; private $externalService; public function create($userData) { try { // バリデーション $this->validateUserData($userData); // DB 保存 $user = new User(); $user->setEmail($userData['email']); $user->setName($userData['name']); $user->setPassword(password_hash($userData['password'], PASSWORD_DEFAULT)); $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 (ValidationException $e) { throw new UserCreateException(' 入力データが不正です: ' . $e->getMessage()); } catch (DatabaseException $e) { // メール送信のロールバックなど throw new UserCreateException(' ユーザー作成に失敗しました: ' . $e->getMessage()); } catch (Exception $e) { // その他のエラーハンドリング throw new UserCreateException(' 予期せぬエラーが発生しました: ' . $e->getMessage()); } } private function validateUserData($userData) { if (empty($userData['email']) || !filter_var($userData['email'], FILTER_VALIDATE_EMAIL)) { throw new ValidationException(' メールアドレスが不正です'); } if (empty($userData['name']) || strlen($userData['name']) < 3) { throw new ValidationException(' 名前は3 文字以上で入力してください'); } if (empty($userData['password']) || strlen($userData['password']) < 8) { throw new ValidationException(' パスワードは8 文字以上で入力してください'); } } 4/34

Slide 5

Slide 5 text

コードの肥大化による弊害 サービスクラスの肥大化 1つのクラスに多くの責務が集中 コードの見通しが悪くなり、バグの温床に 密結合による変更の困難さ 1つの変更が予期せぬ副作用を引き起こす 変更の影響範囲が予測しづらい テストの複雑化 副作用の数だけモックが必要 テストケースが複雑になり、保守が困難 → これを解決する糸口が「ドメインイベント」の活用です 5/34

Slide 6

Slide 6 text

ドメインイベントを使うことで 副作用をわかりやすく整理 システムをシンプルに テストが書きやすく 結果整合性を用いたシステム構築が容易に 複雑なバッチ処理が不要になる可能性 トランザクション範囲を最小限に保ちながら、複数の集約を更新可能 6/34

Slide 7

Slide 7 text

ドメインイベントとは? ドメインイベントは、ビジネスドメインで発生する「出来事」を表現するモデル。 7/34

Slide 8

Slide 8 text

ドメインイベントの特徴 1. ドメインエキスパートの言葉を反映 2. ビジネス的な文脈を持つ 3. 状態変化の伝達 8/34

Slide 9

Slide 9 text

1. ドメインエキスパートの言葉を反映 ビジネス上の重要な出来事を表現 「ユーザーが登録された」「注文が確定した」など 「〜した時に」「〜の場合」という表現に注目 9/34

Slide 10

Slide 10 text

2. ビジネス的な文脈を持つ 技術的な出来事ではなく、ビジネス的な意味を持つ 例:「ユーザーがメールアドレスを変更した」→ ドメインイベント 例:「メール送信に失敗した」→ インフライベント 技術的実装から独立 10/34

Slide 11

Slide 11 text

3. 状態変化の伝達 イベントは不変の事実として記録 システムの状態変化を表現 他のコンポーネントへの通知手段 11/34

Slide 12

Slide 12 text

12/34

Slide 13

Slide 13 text

ドメインイベントの実装の流れを見ていきましょう! 13/34

Slide 14

Slide 14 text

まずはモデリング 重要なビジネスイベントを洗い出し、コンテキス トごとにまとめる 図でまとめると分かりやすい https://speakerdeck.com/hiroki_nakamura/techbowlliu-domeinmoderingu-2025-02-28 14/34

Slide 15

Slide 15 text

モデリング例 「ユーザーが作成された時」に「ウェルカムメールを送りたい」 「ユーザーが作成された時」に「運営に通知したい」 「ユーザーが作成された時」に「契約プランを作成したい」 実際に議論や実装してみると、別のタイミングのほうが良いなどのパターンもある 15/34

Slide 16

Slide 16 text

イベント発行から処理するまでの流れ Publisher(イベントを発行する側)、Subscriber(イベント発行時に処理を実行する側)で管理する 『実践ドメイン駆動設計』Vaughn Vernon著 16/34

Slide 17

Slide 17 text

Publisherへの登録をどこでやるかを検討 ドメインモデル(Aggregate)内作成したイベントをどうやってPublisherに登録するか ドメインモデルで発行する 実装としては自然な流れだが、ドメインモデルにPublisherをもたせる必要がある ドメインサービスで発行する サービスクラスがたくさん増えて、責務が分かりづらくなってしまう 社内ではなるべく使わない方針 アプリケーションサービス(ユースケース)で発行する ドメインモデルはシンプルになるため、こちらを採用 17/34

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

ユースケース (Before) // ユーザー作成メソッドの一部 class UserCreateUsecase { public function create(Request $request) { // 1) ユーザー情報をDB に保存 $user = new UserModel(); $user->id = Uuid::generate(); $user->email = $request->input('email'); $user->save(); // 2) ウェルカムメール送信 $mailService = new MailService(); $mailService->sendWelcomeMail($user->email); // 3) 初期ポイント付与 $pointService = new PointService(); $pointService->addInitialPoints($user->id); // その他の処理がどんどん増える... return response()->json(['status' => 'success']); } } 19/34

Slide 20

Slide 20 text

ユースケースとドメインイベント (After) class UserCreateUsecase { public function exec(UserId $userId, Email $email): void { // ドメインモデルを生成(ここでイベントが内部的に作成される) $user = User::create($userId, $email); DB::transaction(function () use ($user) { // リポジトリに保存 $this->userRepository->create($user, $eventPublisher); }); } } class UserRepository { public function create(User $user, UserCreatedEventPublisher $eventPublisher): void { // 永続化 // ドメインイベントを発行 foreach ($user->pullDomainEvents() as $event) { $this->eventPublisher->publish($event); } } } 20/34

Slide 21

Slide 21 text

テストも書きやすく // ユースケースでは実行時にイベント発行されるかをテストする final class UserCreateUsecaseTest extends TestCase { #[Test] public function ユーザーを作成する() { // イベント発行をモック $this->mock(DomainEventPublisher::class) ->expects($this->once()) ->method('publish'); // 切り出した処理のみに集中してテストする final class WelcomeMailSubscriberTest extends TestCase { #[Test] public function ユーザー作成時にウェルカムメールを送信する(): void { $mailService = $this->mock(MailService::class); $mailService->expects($this->once()) ->method('sendWelcomeMail') ->with($this->equalTo(new Email('user@example.com'))); $event = new UserCreatedEvent(new UserId('123'), new Email('user@example.com')); 21/34

Slide 22

Slide 22 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; } } 22/34

Slide 23

Slide 23 text

DomainEventPublisher パブリッシャーにはサブスクライバ(処理したい内容)とドメインイベントを発行する機能を持たせる ここでpublishすると実行される final class UserCreatedEventPublisher { public function __construct( SendWelcomeMailSubscriber $mailSubscriber, CreateStripeCustomerSubscriber $stripeSubscriber, SendSlackMessageSubscriber $slackSubscriber, ) { // コンストラクタでsubscribers に追加する } public function publish(DomainEvent $event): void { foreach ($this->subscribers as $subscriber) { $subscriber->handle($event); } } } 23/34

Slide 24

Slide 24 text

DomainEventSubscriberには切り出した処理の内容を書 く class WelcomeMailSubscriber { public function handle(UserCreatedEvent $event): void { // イベントに応じた処理を実行 $this->mailService->sendWelcomeMail($event->email()); } } 24/34

Slide 25

Slide 25 text

ここまででドメインイベントの同期処理の流れはok 25/34

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

非同期処理をするために気をつけること トランザクション完了後にイベントを発行 冪等性の確保(イベントIDによる重複チェック) 27/34

Slide 28

Slide 28 text

非同期処理での全体像 メッセージングサービスを活用した非同期処理の手法は他にもあるので一例として 1. ドメインモデルがドメインイベントを作成 2. アプリケーションレイヤーでドメインイベントをPublish 3. PublishされたイベントをDBに保存 4. バッチ処理で未処理のドメインイベントをメッセージングサービスにPublish 5. メッセージングサービスからSubscribeしたイベントに対応する処理を行う 非同期になってもドメインイベント自体の処理は変わらない 28/34

Slide 29

Slide 29 text

メッセージングサービスへの保存 class EventStore { public function save(DomainEvent $event): void { $stmt = $this->pdo->prepare( 'INSERT INTO event_store ( event_id, event_type, aggregate_id, payload, status, created_at ) VALUES (?, ?, ?, ?, ?, ?)' ); $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(string $eventId): void {} } 29/34

Slide 30

Slide 30 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']); } } 30/34

Slide 31

Slide 31 text

メッセージングサービスからのイベント処理 1. イベントを受け取るエンドポイント class UserEventController { public function onCreated(Request $request): Response { $payload = $request->json()->all(); // イベントの復元 $event = $this->restoreEvent($payload); // イベントに対応するハンドラを取得 $handler = $this->resolver->resolve($event); // イベントの処理を実行 $handler->handle($event); // 処理済みとしてマーク $this->processedEventStore->markAsProcessed($eventId); } } 31/34

Slide 32

Slide 32 text

まとめ ドメインイベントはビジネスロジックにおける「出来事」を表現 リファクタリングによって疎結合な設計になり、テスト容易性もあがった 実装の際はどこでドメインイベントを発行して処理するかを検討する トランザクションの境界やロールバック時のイベント発行に注意 32/34

Slide 33

Slide 33 text

Vibe Coding / AIに運転席を譲れ AIがプログラミングを支援する時代でもドメイン知識に基づいた設計を行うことは大切 バイブス高くコーディングするためにもドメインイベントを活用していこう 33/34

Slide 34

Slide 34 text

34/34