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

副作用と戦う PHP リファクタリング ─ ドメインイベントでビジネスロジックを解きほぐす

副作用と戦う PHP リファクタリング ─ ドメインイベントでビジネスロジックを解きほぐす

PHPカンファレンス関西2025登壇資料

副作用が絡み合うロジックを触るたび「どこで何が起きるか分からない」――そんな不安をドメインイベントで断ち切ります!

ドメインイベントは「〇〇が起きた」という事実をクラス化し、処理の流れを“出来事”をベースに組み立て直すことで、依存を一方向に整えることができます。

本トークでは実務でのリファクタリングを題材に、

- 従来のメソッド分割との違いや限界を体感
- 同期イベント導入
- 非同期化
の3ステップでリファクタリング前と後のコードで比較しながら順に解説します。

チームでドメインイベントをわいわい議論できるようになるきっかけを提供します!

https://fortee.jp/phpcon-kansai2025/proposal/f1dec853-4070-44ad-8eda-ef8b127dda3f

Avatar for Takuma Kajikawa

Takuma Kajikawa

July 18, 2025
Tweet

More Decks by Takuma Kajikawa

Other Decks in Programming

Transcript

  1. 梶川 琢馬 / 𝕏 @kajitack 株式会社 TechBowl / プロダクトエンジニア 複数のプロダクトで

    PHP を使った開発を経験してきました。 運動不足解消のために始めたトライアスロンにハマってます。 初 PHPカンファレンス関西! 初 神戸! 3/35
  2. シンプルにデータを永続化する class UserController { public function create($userData) { $user =

    new User(); $user->setEmail($userData['email']); $user->setName($userData['name']); $this->userRepository->save($user); } } 6/35
  3. 副作用と共に肥大化していくコード 実際はデータの保存と一緒に色々な副作用が発生する 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
  4. 実装してみると... コードの見通しが悪くなる 変更の影響範囲が予測しづらい テストケースが複雑になり、保守が困難 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
  5. テスト時に副作用の数だけモックが必要 #[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
  6. イベントの発行と副作用のテストを分けることができ... テストが書きやすくなる #[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
  7. Subscriber Publisher Repository Domain Model Usecase Subscriber Publisher Repository Domain

    Model Usecase インスタンス作成 イベント作成 Publisher の登録 永続化処理 ドメインイベントを取得 イベントの発⾏ イベントを受け取る ドメインイベントの発行 と処理の流れ 1. ドメインオブジェクトでドメインイベントを作成 と保持 2. 永続化した後にドメインイベントを取り出し、パ ブリッシャーで発行 3. サブスクライバーでイベントの通知を受け取って 処理 19/35
  8. ドメインモデルがドメインイベントを作成する 「ユーザー作成時」に「ユーザーが作成された」イベントを作成し、保持する 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
  9. パブリッシャーとサブスクライバー ドメインイベントをサブスクライバーに発行する処理を実装 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
  10. 永続化のタイミングでイベント発行 リポジトリでドメインイベントをパブリッシュしなければいけないということを明示的にする 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
  11. 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
  12. メッセージングサービスへの保存 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
  13. バッチ処理の実装 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
  14. イベント処理 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