Event sourcingとは何か(そして、ただのログじゃない)
Event sourcingではイベントが唯一の真実だ。オブジェクトの「最終状態」を保存するんじゃなく、起きたこと全部を保存する。あとはイベントを再生して状態を作る。そう、リプレイだ。で、ログと違う。ログは物語、イベントは事実。
超シンプルな例:balance = 120を保存する代わりに、MoneyDeposited(200)とMoneyWithdrawn(80)を記録する。残高は結果であって、どこかから湧いた魔法の数字じゃない。
なんでそんな面倒を?
未来の自分が助かるからだ。
- 監査で「誰が何をいつやったか」を追いたいとき
- デバッグで現実をリプレイしたいとき
- ビジネス変更後にモデルを再構築したいとき
- **投影(read model)**を作ってDBをぶっ壊さずに読みたいとき
要するに、今ちょっと複雑さを払って、後で即席ポストモーテムをやめる。
主要な部品(煙なし)
- イベント:過去の不変な事実。
OrderPlacedであってPlaceOrderじゃない。
- ストリーム:1つの集約のイベント列。
- 集約:イベントを適用して状態を復元するドメインオブジェクト。
- 投影:読み取り用の派生ビュー(SQL/Elastic/キャッシュなど)。
使う場面(避ける場面)
使うべきケース:
- ドメインがよく変わる
- 履歴が命
- 鉄壁のトレーサビリティが必要
避けるべきケース:
- CRUDがただの事務作業
- 監査が不要
- チームがまだリポジトリに慣れてない
Symfonyでのミニ入門例
シンプルにいく。Cart集約、イベント、インメモリのイベントストア。CQRSフル装備でもKafkaでもない。コアだけ。
1) イベント定義
1<?php
2
3namespace App\Cart\Domain\Event;
4
5final class ItemAdded
6{
7 public function __construct(
8 public readonly string $cartId,
9 public readonly string $sku,
10 public readonly int $quantity,
11 ) {
12 }
13}
2) イベントを再生する集約
1<?php
2
3namespace App\Cart\Domain;
4
5use App\Cart\Domain\Event\ItemAdded;
6
7final class Cart
8{
9 private array $items = [];
10 private array $recordedEvents = [];
11
12 public static function reconstitute(array $events): self
13 {
14 $cart = new self();
15 foreach ($events as $event) {
16 $cart->apply($event);
17 }
18 return $cart;
19 }
20
21 public function addItem(string $cartId, string $sku, int $quantity): void
22 {
23 $event = new ItemAdded($cartId, $sku, $quantity);
24 $this->record($event);
25 }
26
27 public function recordedEvents(): array
28 {
29 return $this->recordedEvents;
30 }
31
32 private function record(object $event): void
33 {
34 $this->apply($event);
35 $this->recordedEvents[] = $event;
36 }
37
38 private function apply(object $event): void
39 {
40 if ($event instanceof ItemAdded) {
41 $this->items[$event->sku] = ($this->items[$event->sku] ?? 0) + $event->quantity;
42 }
43 }
44}
3) 最小のインメモリEvent Store
1<?php
2
3namespace App\Cart\Infrastructure;
4
5final class InMemoryEventStore
6{
7 private array $streams = [];
8
9 public function append(string $streamId, array $events): void
10 {
11 $this->streams[$streamId] ??= [];
12 $this->streams[$streamId] = array_merge($this->streams[$streamId], $events);
13 }
14
15 public function load(string $streamId): array
16 {
17 return $this->streams[$streamId] ?? [];
18 }
19}
4) Symfonyサービスでの使い方
1<?php
2
3namespace App\Cart\Application;
4
5use App\Cart\Domain\Cart;
6use App\Cart\Infrastructure\InMemoryEventStore;
7
8final class AddItemToCart
9{
10 public function __construct(private InMemoryEventStore $eventStore)
11 {
12 }
13
14 public function __invoke(string $cartId, string $sku, int $quantity): void
15 {
16 $events = $this->eventStore->load($cartId);
17 $cart = Cart::reconstitute($events);
18
19 $cart->addItem($cartId, $sku, $quantity);
20
21 $this->eventStore->append($cartId, $cart->recordedEvents());
22 }
23}
以上。ORMなし、キャッシュなし、パレードなし。中心の考え方だけ:イベントが真実、状態は計算されたビュー。必要なら投影やSymfony Messengerのリスナーを後から付ければいい。
まとめ(説明用)
- Event sourcingは履歴を保存する。結果だけじゃない。
- イベントを再生して状態を作る。
- 監査や変化するドメインには最適、退屈なCRUDには不要。
- Symfonyは全部はやってくれないが、きれいに構成できる。
重いと感じるなら正常だ。短期の快適さと引き換えに長期の明快さを買っている。今払うか、未来の自分が払うか。選べ。