Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ドメインイベントを活用したPHPコードのリファクタリング

 ドメインイベントを活用したPHPコードのリファクタリング

Takuma Kajikawa

March 21, 2025
Tweet

More Decks by Takuma Kajikawa

Other Decks in Technology

Transcript

  1. 肥大化していくコード ユーザー時に色々やりたい 1クラスに多くの責務が集中 テストも複雑になりがち(副作用の数だけモックが必要) 責務が混在し、変更の影響範囲が予測しづらい class UserController { public function

    create($userData) { // DB に保存 // 副作用1 メール送信 // 副作用2 運営に通知 // 副作用3 契約プランの作成 // 副作用4 招待コード利用処理 // 副作用5 関連サービスへの連携 // etc... } } 3/34
  2. 実装すると... 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
  3. ユースケース (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
  4. ユースケースとドメインイベント (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
  5. テストも書きやすく // ユースケースでは実行時にイベント発行されるかをテストする 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
  6. ドメインモデルがドメインイベントを作成する まだ実行はされない 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
  7. 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
  8. DomainEventSubscriberには切り出した処理の内容を書 く class WelcomeMailSubscriber { public function handle(UserCreatedEvent $event): void

    { // イベントに応じた処理を実行 $this->mailService->sendWelcomeMail($event->email()); } } 24/34
  9. メッセージングサービスへの保存 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
  10. バッチ処理の実装 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
  11. メッセージングサービスからのイベント処理 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