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

メッセージングを利用して時間的結合を分離しよう #phperkaigi

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

メッセージングを利用して時間的結合を分離しよう #phperkaigi

PHPerKaigi2026の登壇資料です。

①「時間的結合」という概念を知る
② メッセージングで分離する方法を知る
③ 非同期処理による注意点と解決パターンを知る

https://fortee.jp/phperkaigi-2026/proposal/5ea64c6c-3726-4324-ab59-3c321f1a86ae

Avatar for Takuma Kajikawa

Takuma Kajikawa

March 22, 2026
Tweet

More Decks by Takuma Kajikawa

Other Decks in Programming

Transcript

  1. ふるまい的結合と時間的結合 ふるまい的(Behavioural)結合 コードの静的な依存 import や継承、呼び出しによる依存関係 例: OrderService が PaymentAPI と

    MailAPI に依存 時間的(Temporal)結合 実行タイミングの依存 外部 API の完了を待つ 例: 決済 API が遅れるとユーザーも待つ Temporal and Behavioural Coupling - Ian Robinson https://iansrobinson.com/2009/04/27/temporal-and-behavioural-coupling/ 9/44
  2. 時間的結合(Temporal Coupling) 厳格に順番どおりの処理が必要な場合や処理結果が後の処理へ繰り越される場合、 リクエストに対する反応が返ってくるまで後続の処理が開始できない。 function placeOrder(OrderRequest $request): OrderResponse { $order

    = $this->createOrder($request); // 注文作成 $this->paymentService->charge($order); // 決済API(外部: 1〜3秒) $this->mailService->sendConfirmation($order); // メール送信(外部: 0.5〜2秒) $this->pdfService->generateReceipt($order); // PDF生成(重い: 1〜3秒) return new OrderResponse($order); // 合計: 3〜10秒...ユーザーは画面の前で待っている } 11/44
  3. Before(全部同期) function placeOrder($req): OrderResponse { $order = $this->createOrder($req); $this->inventory->reserve($order); $this->payment->charge($order);

    // ↓ ここから先がユーザーを待たせている $this->mail->sendConfirmation($order); $this->pdf->generateReceipt($order); $this->notification->notifyAdmin($order); return new OrderResponse($order); } 6 つの処理が全部直列。ユーザーは外部 API の完了を待つ 18/44
  4. After(メッセージング導入) function placeOrder($req): OrderResponse { $order = $this->createOrder($req); $this->inventory->reserve($order); $this->payment->charge($order);

    // 「注文を確定したイベント」をメッセージ $this->eventPublisher->publish( new OrderConfirmed($order) ); return new OrderResponse($order); // ← ここで即座にレスポンス } 19/44
  5. イベントによる、ふるまい的結合の解消 イベントの発行と副作用のテストを分けることができ、テストが書きやすくなる #[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); } 23/44
  6. Subscriber Publisher Repository Domain Model Usecase Subscriber Publisher Repository Domain

    Model Usecase インスタンス作成 イベント作成 Publisher の登録 永続化処理 ドメインイベントを取得 イベントの発⾏ イベントを受け取る ドメインイベントの 発行と処理の流れ 1. ドメインオブジェクトで ドメインイベントを作成と保持 2. 永続化した後にドメインイベントを 取り出し、パブリッシャーで発行 3. サブスクライバーでイベントの 通知を受け取って処理 29/44
  7. ドメインイベントを作成する 「ユーザー作成時」に「ユーザーが作成された」イベントを作成し、保持する 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; } } 30/44
  8. パブリッシャーとサブスクライバー ドメインイベントをサブスクライバーに発行する処理を実装 final class UserCreatedEventPublisher { public function __construct( WelcomeMailSubscriber

    $mailSubscriber, CreateBillingAccountSubscriber $billingSubscriber, SendChatNotificationSubscriber $notificationSubscriber, ) { // コンストラクタで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()); } } 31/44
  9. 永続化のタイミングでイベント発行 リポジトリでドメインイベントをパブリッシュしなければいけないということを明示的にする 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); } } } 32/44
  10. 課題1: 整合性のズレ 何らかの理由でロールバックしてしまった際に、 データは無いのにイベントだけ発行されてしまう... 「ドメインの状態更新」と「外部への通知」が 別タイミングになる DB::transaction(function () use ($user)

    { $this->userRepository->save($user); // DB保存 成功 }); // トランザクションの外 $this->messageQueue->publish( // 送信 失敗! new UserCreated($user) ); // → ユーザーは保存されたが、ウェルカムメールが永久に届かない 35/44
  11. Transactional Outbox パターン 同じトランザクション = 両方とも成功 or 失敗 DB::transaction(function ()

    use ($user) { // ユーザーの保存 $this->userRepository->save($user); // 同じトランザクション内にメッセージを保存 $this->outboxRepository->store( new OutboxMessage( eventType: 'UserCreated', payload: json_encode(['userId' => $user->id, ...]), status: 'pending', ) ); }); 36/44
  12. Consumer Queue Publisher Consumer Queue Publisher 「未処理」と判断 結果: メールが2 通届く

    ポイントが⼆重付与される [OrderConfirmed] 配信 処理成功 ✅ ACK 失敗 再配信 🔁 2 回⽬の処理... 課題2: メッセージの重複 ネットワーク障害後の再送で、同じ メッセージが複数回届く 結果: メールが 2 通届く、ポイントが 二重付与される... 38/44
  13. 処理済みかどうかをマークする function handleUserCreated(UserCreated $event): void { // 冪等性キー(イベントID)で処理済みチェック if ($this->processedEvents->exists($event->eventId))

    { return; // 既に処理済み → スキップ } $this->mailService->sendWelcomeMail($event); $this->processedEvents->markAsProcessed($event->eventId); } 同じ処理を何度実行しても結果が変わらない (冪等性) 39/44
  14. 非同期処理での注意点と解決パターン 課題 具体例 解決パターン 整合性のズレ DBは保存できたのに メッセージが送れない Transactional Outbox 同一トランザクションで保存

    重複実行 再送でメールが2通届く 冪等性キー 処理済みチェックで重複スキップ 順序の乱れ 有効化メールが ウェルカムメールより先に届く 順序非依存の設計 その他にも、メッセージ発行後に元の処理を取り消したい場合などは、 補償トランザクションなどが必要になる。サービスによっては、サポートしてる機能が あるので、そちらも利用する 42/44