イベントソーシングとの6ヶ月:本番環境での振り返り
1日5万件以上のトランザクションを処理する金融システムを管理してきた経験から、説明責任が不可欠な場面では従来のCRUD(Create, Read, Update, Delete)がしばしば力不足であることを学びました。顧客から「なぜ一晩で残高が50ドル減ったのか」と問われた際、現在の状態だけを示すデータベースの1行は何の役にも立ちません。必要なのはタイムラインです。その結果に至るまでの、すべての意図と行動の記録なのです。
これらのパターンを習得することは、基本的なアプリ開発から、数百万ドルを難なく扱うシステムの設計へとステップアップするための架け橋となります。NestJSは、@nestjs/cqrsパッケージによってこの移行を容易にしてくれます。これは関心を分離するための構造化された手法を提供します。私のチームが、理論的な例を超えて安定した本番環境へと移行するために使用した具体的なパターンをここに記録しました。
5分でできるセットアップ
NestJSでのCQRSのセットアップは非常に簡単です。まず、コアライブラリが必要です。
npm install @nestjs/cqrs
フローを実演するために、軽量なトランザクションシステムを構築してみましょう。まず、機能モジュール内でCqrsModuleを登録し、コマンドバスとイベントバスを使用可能にします。
// transactions.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TransactionController } from './transaction.controller';
import { CreateTransactionHandler } from './commands/handlers/create-transaction.handler';
@Module({
imports: [CqrsModule],
controllers: [TransactionController],
providers: [CreateTransactionHandler],
})
export class TransactionsModule {}
次に、Command(コマンド)を定義します。これは、システムを変更しようとするユーザーの意図を表すシンプルなDTOです。
// create-transaction.command.ts
export class CreateTransactionCommand {
constructor(
public readonly accountId: string,
public readonly amount: number,
) {}
}
最後に、Handler(ハンドラー)を作成します。ここにビジネスロジックを記述します。ハンドラーはコマンドをキャッチし、操作を実行します。
// create-transaction.handler.ts
import { CommandHandler, ICommandHandler, EventPublisher } from '@nestjs/cqrs';
import { CreateTransactionCommand } from '../impl/create-transaction.command';
@CommandHandler(CreateTransactionCommand)
export class CreateTransactionHandler implements ICommandHandler<CreateTransactionCommand> {
constructor(private readonly publisher: EventPublisher) {}
async execute(command: CreateTransactionCommand) {
const { accountId, amount } = command;
// ロジック: アカウントの検証、制限のチェックなど
return { success: true };
}
}
状態のメカニズム:なぜCQRSとイベントソーシングを組み合わせるのか?
CQRSは、書き込みロジックと読み取りロジックを分離します。私たちの環境では、これにより書き込み側をデータ整合性に集中させて軽量に保ちつつ、複雑なレポート作成のためにSQLのリードレプリカを最適化することができました。これは、目的に適したツールを使用するという考え方に基づいています。
信頼できる唯一の情報源:イベントストア
従来のシステムは現在の残高を保存します。イベントソーシングを採用したシステムはその逆で、履歴を保存します。「残高:130ドル」を保存するのではなく、Deposit($100)(入金)、Withdraw($20)(出金)、Deposit($50)(入金)という履歴を保存するのです。現在の残高は、単にこれらのイベントの合計に過ぎません。これにより、ネイティブな監査ログが作成されます。計算のバグが見つかった場合でも、履歴全体を再生(リプレイ)することで、いつ状態が乖離したのかを正確に特定できます。
データの流れ
- コマンド: ユーザーが「500ドルを送金」とリクエストします。
- ハンドラー: システムがユーザーに十分な資金があるかを確認します。
- イベント:
MoneyTransferredEventがイベントストアに書き込まれます。 - プロジェクター: 非同期リスナーが読み取り専用テーブルを更新し、UIに新しい残高が即座に表示されるようにします。
この疎結合化により、水平スケーリングが可能になりました。コアとなるトランザクションロジックに触れることなく、UIのデータ要件を変更できるようになったのです。
高度なパターン:Sagaとスナップショット
現実世界のワークフローが1ステップで終わることは稀です。銀行振込には、一方の口座からの引き出しともう一方への預け入れが含まれます。2番目のステップが失敗した場合は、ロールバックする方法が必要です。ここでSaga(サガ)の出番です。
Sagaによるワークフローの調整
Sagaは、イベントをリッスンして新しいコマンドをトリガーする、長時間実行されるプロセスです。NestJSでは、SagaはRxJSのObservableを使用してこれらの複雑なストリームを管理します。
@Injectable()
export class TransactionSagas {
@Saga()
transactionCreated = (events$: Observable<any>): Observable<ICommand> => {
return events$.pipe(
ofType(MoneyWithdrawnEvent),
map((event) => new DepositMoneyCommand(event.targetId, event.amount)),
);
}
}
スナップショットによるパフォーマンスの最適化
単一の残高を計算するために10,000件のイベントを再生するのは、タイムアウトの原因になります。私たちはこれをスナップショットを実装することで解決しました。100イベントごとに「状態のチェックポイント」を保存します。残高を計算する際は、最新のスナップショットをロードし、その後に発生した数件のイベントのみを再生します。これにより、集計のロード時間が2.5秒から40ミリ秒未満に短縮されました。
現場から学んだ教訓
これらのシステムを構築することで、ドキュメントでは語られないいくつかの教訓を得ました。
1. イベントは不変(Immutable)である
永続化されたイベントを決して変更しないでください。誤った利率を適用してしまった場合でも、データベースを編集してはいけません。代わりに、残高を修正するための補償イベント(Compensating Event)を発行します。これにより、監査証跡の誠実さと法的コンプライアンスが維持されます。
2. 早いうちからイベントのバージョニングを行う
ビジネス要件は急速に変化します。来月にはTransactionCreatedEventにtaxIdフィールドが必要になるかもしれません。常にイベントスキーマにバージョン番号を含めてください。更新中に本番環境を壊さないよう、コードはv1とv2のイベントを同時に処理できるように設計する必要があります。
3. オーバーエンジニアリングを避ける
CQRSはボイラープレート(定型コード)を増やします。シンプルなブログやCRUDベースの内部ツールを構築しているのであれば、これは過剰です。このパターンは、データの履歴が厳格な要件であるコアドメインにのみ使用してください。単純なUPDATE文で済むなら、それを使ってください。しかし、値がどのように

