はじめに
ビジネスロジックを処理する関数を見て、「これ、やりすぎじゃないか?」と思ったことはありませんか?データベースへの保存、メール送信、ログ記録、ポイント付与、外部API呼び出し——全部が一か所に詰め込まれている状態です。
これは tight coupling(密結合) のサインです。各部分が強く結びついているため、小さな機能追加でも該当関数を開いて全体を読み直し、「何も壊さなければいいけど…」と祈りながら修正するはめになります。
Event-Driven Architecture(EDA) は、まさにこの問題を解決するためのコード設計アプローチです。
この記事では、概念の説明から動作の仕組み、PHPによる実践例まで順を追って解説します。マイクロサービスや分散システムの知識は必要ありません。
EDAとは何か
イベント駆動アーキテクチャとは、システムの各コンポーネントが互いを直接呼び出す代わりに、イベント(event) を介してやり取りするアーキテクチャパターンです。
イベントとは、「システム内で何かが起きた」という通知です。たとえば:
-
UserRegistered— ユーザーが新規登録した -
TicketBooked— チケットの予約が完了した -
PaymentCompleted— 決済処理が完了した
処理の発生元は、後続のすべての処理を直接呼び出す必要はありません。イベントを発行するだけでいい。反応が必要なコンポーネントは自分でそのイベントを購読(subscribe)します。
EDAを構成する3つのコアコンポーネント:
| コンポーネント | 役割 |
|---|---|
| Event Producer | 何かが起きたときにイベントを発行する |
| Event Bus | イベントを受け取り、適切な場所に配信する仲介役 |
| Event Consumer | イベントを購読し、独自のビジネスロジックで処理する |
イベント名は常に過去形で命名します:TicketBooked、UserRegistered、OrderShipped。イベントは「すでに起きたこと」を表すものであり、誰かへの命令ではありません。
EDAを使うべき場面
EDAが適しているのは、次のような状況です:
✅ 1つのアクションが多くの後続処理を引き起こす場合
例:チケット予約 → メール送信 + ポイント付与 + ログ記録 + プッシュ通知。後続処理の数は時間とともに増えていく可能性があります。
✅ 後続処理をリアルタイムで完了させる必要がない場合
メール送信、アナリティクスの記録、レポート更新などは、ユーザーへのレスポンスを返す前に完了させる必要はありません。
✅ 要件が頻繁に変わる場合
今週はバウチャー機能を追加、来週は新しいパートナー連携を追加——そのたびにコアロジックを修正するのはリスクが高すぎます。
✅ 各コンポーネントを独立させたい場合
一部が壊れても全体は止まらない。各部分を独立して開発・テスト・デプロイできるようにしたい。
❌ EDAを使わないほうがいい場面:
- ロジックがシンプルで変更が少ない場合 — Event Busの層を追加するだけでコードが読みにくくなる
- 次のステップの結果に依存して処理を続ける必要がある場合 — EDAは互いに依存しないフローに向いている
- 小規模チームや立ち上げ期のコードベース — まずはシンプルに始め、本当に必要になったらリファクタリングする
メリット
Loose coupling(疎結合)— コンポーネントの分離
ProducerはConsumerが誰で何をするかを知る必要がありません。新しいConsumerを追加しても、ProducerやほかのConsumerに影響はありません。
機能の拡張が容易
新機能を追加する = 新しいConsumerを追加する。既存コードを修正する必要も、現在のロジックを読み返す必要もありません。
コードの可読性と責任の明確化
各Consumerは1つのことだけをする。コアの関数は本来のビジネスロジックだけを処理する。
テストしやすい
システム全体をセットアップしなくても、各Consumerを独立してテストできます。
デメリット
処理の流れが線形でなくなる
通常のコードは上から下へ読めば流れがわかります。EDAでは、どのConsumerがどのイベントを購読しているかを追わなければならず、プロジェクト初参加のメンバーには特に難しく感じられます。
デバッグが難しくなる
エラーが発生した場合、1か所を読むだけでなく、どのConsumerで発生したかを特定する必要があります。
実行順序が保証されない
複数のConsumerが同じイベントを購読している場合、呼び出し順序は登録順に依存します。あるConsumerが別のConsumerの結果に依存していると、微妙なバグを引き起こしやすくなります。
初期の複雑さが増す
Event Bus層の追加、ペイロードの設計、イベント名の命名が必要です。シンプルなシステムでは不要なオーバーヘッドになります。
EDAは特定の問題を解決するためのツールであり、すべてのシステムに適用すべき「黄金律」ではありません。本当に必要なときに使いましょう。「なんとなくかっこいいから」という理由で使ってはいけません。
実践例:PHPで映画チケット予約システムを作る
題材の説明
ユーザーがチケットを予約すると、システムは次のことを行う必要があります:
- 確認メールを送信する
- ポイントを付与する
- アナリティクスを記録する
- プッシュ通知を送信する
そして、このリストは今後も増え続けていきます。
従来のやり方(とその問題点)
class BookingService
{
public function bookTicket(string $userId, string $movieId, string $seatId): array
{
// 予約を保存
$booking = $this->db->save([
'user_id' => $userId,
'movie_id' => $movieId,
'seat_id' => $seatId,
'status' => 'confirmed',
]);
// メール送信
$user = $this->db->getUser($userId);
$this->emailService->send($user['email'], '予約完了のお知らせ', "座席 $seatId のご予約が確定しました。");
// ポイント付与
$this->loyaltyService->addPoints($userId, 10);
// アナリティクス記録
$this->analytics->track('ticket_booked', ['movie_id' => $movieId]);
// プッシュ通知送信
$this->pushService->send($userId, 'チケットが確定しました!');
return $booking;
}
}
この関数は今後どんどん膨らんでいきます。新しい要件が来るたびに開いて修正し、予約ロジックを壊してしまうリスクを負い続けることになります。
Event Busを実装する
<?php
class EventBus
{
private array $listeners = [];
public function subscribe(string $eventName, callable $listener): void
{
$this->listeners[$eventName][] = $listener;
}
public function publish(string $eventName, array $payload): void
{
$handlers = $this->listeners[$eventName] ?? [];
foreach ($handlers as $handler) {
try {
call_user_func($handler, $payload);
} catch (Throwable $e) {
// エラーをログに記録するが、他のhandlerの処理は止めない
error_log("[EventBus] '$eventName' のhandlerでエラー発生: " . $e->getMessage());
}
}
}
}
BookingServiceをリファクタリングする
<?php
class BookingService
{
public function __construct(private EventBus $bus) {}
public function bookTicket(string $userId, string $movieId, string $seatId): array
{
// コアロジック:本来の責務だけを担う
$booking = $this->db->save([
'user_id' => $userId,
'movie_id' => $movieId,
'seat_id' => $seatId,
'status' => 'confirmed',
]);
$this->db->markSeatUnavailable($seatId);
// イベントを発行 — 誰が処理するかは関知しない
$this->bus->publish('TicketBooked', [
'booking_id' => $booking['id'],
'user_id' => $userId,
'movie_id' => $movieId,
'seat_id' => $seatId,
]);
return $booking;
}
}
bookTicket() はただ1つのこと、チケットの予約だけを行います。メール、ポイント、アナリティクスが何者かを知る必要はありません。
独立したListenerを実装する
<?php
// 確認メールを送信
$bus->subscribe('TicketBooked', function (array $payload) use ($emailService, $db) {
$user = $db->getUser($payload['user_id']);
$emailService->send(
$user['email'],
'予約完了のお知らせ 🎬',
"座席 {$payload['seat_id']} のご予約が確定しました。映画をお楽しみください!"
);
});
// ポイントを付与
$bus->subscribe('TicketBooked', function (array $payload) use ($loyaltyService) {
$loyaltyService->addPoints($payload['user_id'], 10);
});
// アナリティクスを記録
$bus->subscribe('TicketBooked', function (array $payload) use ($analytics) {
$analytics->track('ticket_booked', [
'movie_id' => $payload['movie_id'],
'seat_id' => $payload['seat_id'],
]);
});
// プッシュ通知を送信
$bus->subscribe('TicketBooked', function (array $payload) use ($pushService) {
$pushService->send($payload['user_id'], 'チケットが確定しました!');
});
新機能の追加:割引バウチャーを配布する
プロダクトオーナーから「次回購入時に使える20%割引バウチャーを配布したい」という要望が来ました。EDAなら、BookingService には手を触れずに対応できます:
<?php
// 新しいListenerを追加するだけ — BookingServiceは一切変更なし
$bus->subscribe('TicketBooked', function (array $payload) use ($voucherService) {
$voucher = $voucherService->create([
'user_id' => $payload['user_id'],
'discount_percent' => 20,
'expires_in_days' => 30,
]);
echo "ユーザー {$payload['user_id']} にバウチャー {$voucher['code']} を発行しました\n";
});
すべてをindex.phpでつなげる
<?php
require_once 'EventBus.php';
require_once 'BookingService.php';
$bus = new EventBus();
$bookingService = new BookingService($bus);
// すべてのListenerを登録
require_once 'Listeners.php';
require_once 'VoucherListener.php';
// 動作確認
$result = $bookingService->bookTicket('user_001', 'movie_inception', 'B7');
echo "予約完了: {$result['id']}\n";
実行結果:
ユーザー user_001 に確認メールを送信しました
ユーザー user_001 に 10 ポイントを付与しました
アナリティクスを記録しました: ticket_booked
ユーザー user_001 にプッシュ通知を送信しました
ユーザー user_001 にバウチャー SAVE20_XYZ を発行しました
予約完了: booking_abc
処理の流れをまとめる
実装時の注意点
イベント名は明確かつ一貫したルールで命名する
過去形を使い、起きたことを正確に表現しましょう。TicketBooked は BookingDone や HandleBooking より優れています。
ペイロードは「ちょうど十分」な設計にする
ConsumerがそれだけでADditional APIコールなしに処理できる情報を含めましょう。ただし、詰め込みすぎも禁物です。本当に必要なものだけにします。
ConsumerどうしをListener実行順に依存させない
Consumer Bがconsumer Aの結果を必要とするなら、設計を見直すサインです。2つの別々のイベントと処理フローに分割することを検討してください。
Event Busには必ずエラーハンドリングを実装する
あるConsumerでエラーが発生しても、残りのConsumerチェーンを止めてはいけません。上記の EventBus.php の例のように、各handlerをtry/catchで囲み、ログを残しましょう。
Listenerファイルは担当するビジネスロジックの近くに置く
すべてを1つの巨大な listeners.php に詰め込まないようにしましょう。たとえば app/Listeners/Booking/SendConfirmationEmail.php のような構造にすると、見つけやすく、不要になったときも削除しやすくなります。
まとめ
EDAは1つのアクションが多くの後続処理を引き起こすとき、コードが硬直化して保守しにくくなるという、非常に現実的な問題を解決します。
中心的なアイデアはシンプルです——直接呼び出す代わりにイベントを発行し、必要な側が自分で購読する。これにより責務が分離され、既存のコードを壊す心配なく機能を追加できるようになります。
覚えておくべきポイント:
- イベントは「起きたこと」の通知であり、命令ではない
- ProducerはConsumerを知らず、ConsumerもProducerを知らない
- 機能追加 = Listenerの追加、コアコードの修正ではない
- トレードオフとして、デバッグが難しくなり、処理の流れが追いにくくなる
PHPでは、この記事で紹介したシンプルな EventBus クラスだけで始められます。システムが大きくなり、非同期処理や複数プロセスへの対応が必要になってきたら、LaravelのEvents/Listenersシステムや、Redis Pub/SubといったMessage Brokerの導入を検討しましょう。