Node.jsにおけるイベントソーシングとCQRSの実装:耐障害性に優れたプロジェクションと状態管理

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

背景と導入の理由

本番環境で稼働する多くのWebアプリケーションは、依然としてリレーショナルデータベースに対する直接的なCRUD操作に依存しています。請求サービスやEコマースにおいて、口座残高を調整する処理は通常次のようになります。

UPDATE accounts SET balance = balance - 50.00, updated_at = NOW() WHERE id = 'acc-102';

この1行のクエリは、過去の履歴を音もなく破壊してしまいます。半年後に財務部門から監査を求められたり、2つのバックグラウンドワーカーが同時に引き落としを実行したりした際、基本的な調査事項に答えることができません。なぜ残高が減ったのか? 誰が承認したのか? 自動サブスクリプション課金なのか、手動の手数料修正なのか、それとも返金処理なのか?

この欠陥は、状態の可変性(ミュータビリティ)に起因します。書き込みモデルと読み取りモデルを兼用している場合、テーブルの行には最新のスナップショットしか残りません。タイムライン全体が破棄されてしまうのです。秒間数千件の書き込みを処理する高スループットアーキテクチャでは、この構成が行ロックの競合やデッドロック、サービス間でのサイレントなデータ同期の不整合を引き起こします。

イベントソーシング(Event Sourcing)とコマンドクエリ責務分離(CQRS)を組み合わせることで、書き込みと読み取りを分離し、この問題を解決できます。

  • イベントソーシング: 状態の変更は、AccountOpened(口座開設)やMoneyDeposited(入金)といった、追記専用(append-only)のイミュータブルなドメインイベントとして記録されます。現在の残高は、そのイベントストリームに対する純粋な畳み込み(fold)処理によって算出されます。
  • CQRS: コマンドハンドラーが入力を検証し、追記専用ログにイベントをコミットします。独立したプロジェクションハンドラーがそれらのイベントを読み取り、クエリしやすい非正規化された読み取り専用ビューを維持します。

私たちのチームが毎秒1,400件のトランザクションが集中するコア決済台帳をこのアーキテクチャに移行した際、二重支払い(二重決済)の不整合はゼロに激減しました。コンプライアンスチームは、過去の任意の時点におけるユーザーの財務状態を正確に再構築できる、検証可能な監査ログを手に入れることができました。

インストールと環境構築

大規模なKafkaやPostgreSQLクラスターを構築することなく、コードをシンプルかつ再現しやすくするために、Node.js、Express、そして同期処理で低レイテンシなディスク書き込みを実現するbetter-sqlite3を使用したインプロセスエンジンを構築します。

新しいプロジェクトディレクトリを作成し、必要なパッケージをインストールします。

mkdir node-cqrs-eventsourcing
cd node-cqrs-eventsourcing
npm init -y
npm install express better-sqlite3 uuid
npm install --save-dev nodemon

ネイティブのESモジュール(ESM)インポートを有効にするため、package.jsonに"type": "module"を追加します。

{
  "name": "node-cqrs-eventsourcing",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

構成と実装

このシステムは、追記専用の「イベントストア(Event Store)」、ビジネスロジックを担う「コマンド集約(Command Aggregate)」、読み取りビューを生成する「リードプロジェクター(Read Projector)」という3つの独立したコンポーネントで構成されます。

1. ストレージスキーマのセットアップ

database.js内で、追記専用のイベントログ用テーブルと、読み取りに最適化されたプロジェクションビュー用テーブルの2つを初期化します。

import Database from 'better-sqlite3';

const db = new Database('system.db');

// 並行読み取りと書き込みのためにSQLiteを最適化
db.pragma('journal_mode = WAL');

db.exec(`
  CREATE TABLE IF NOT EXISTS event_store (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    stream_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    payload TEXT NOT NULL,
    version INTEGER NOT NULL,
    created_at TEXT NOT NULL,
    UNIQUE(stream_id, version)
  );

  CREATE TABLE IF NOT EXISTS read_account_balances (
    account_id TEXT PRIMARY KEY,
    owner TEXT NOT NULL,
    balance REAL NOT NULL,
    last_version INTEGER NOT NULL
  );
`);

export default db;

UNIQUE(stream_id, version)に注目してください。このデータベース制約が、決定論的な楽観的並行性制御(Optimistic Concurrency Control)を実現します。同じストリームに対して2つのリクエストが同時にバージョン3を追加しようとした場合、SQLiteは2つ目の書き込みを即座に拒否します。

2. イベントストアとプロジェクションエンジン

次に、イベントの永続化、ストリームの再構築(Hydration)、およびリードモデルへのディスパッチを処理するeventEngine.jsを作成します。

import db from './database.js';

export class EventStore {
  static getEvents(streamId) {
    const rows = db.prepare(
      'SELECT event_type, payload, version FROM event_store WHERE stream_id = ? ORDER BY version ASC'
    ).all(streamId);

    return rows.map(r => ({
      type: r.event_type,
      payload: JSON.parse(r.payload),
      version: r.version
    }));
  }

  static append(streamId, expectedVersion, eventType, payload) {
    const newVersion = expectedVersion + 1;
    const insertEvent = db.prepare(`
      INSERT INTO event_store (stream_id, event_type, payload, version, created_at)
      VALUES (?, ?, ?, ?, ?)
    `);

    try {
      insertEvent.run(streamId, eventType, JSON.stringify(payload), newVersion, new Date().toISOString());
      
      // 今回のローカル環境構成では、コミット直後にプロジェクションを実行
      Projector.handle({
        streamId,
        type: eventType,
        payload,
        version: newVersion
      });
    } catch (err) {
      if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
        throw new Error(`ConcurrencyConflictException: ストリーム ${streamId} は他のプロセスによって更新されました。`);
      }
      throw err;
    }
  }
}

export class Projector {
  static handle(event) {
    const { streamId, type, payload, version } = event;

    if (type === 'AccountOpened') {
      db.prepare(`
        INSERT INTO read_account_balances (account_id, owner, balance, last_version)
        VALUES (?, ?, ?, ?)
      `).run(streamId, payload.owner, payload.initialBalance, version);
    } else if (type === 'MoneyDeposited') {
      db.prepare(`
        UPDATE read_account_balances
        SET balance = balance + ?, last_version = ?
        WHERE account_id = ?
      `).run(payload.amount, version, streamId);
    } else if (type === 'MoneyDebited') {
      db.prepare(`
        UPDATE read_account_balances
        SET balance = balance - ?, last_version = ?
        WHERE account_id = ?
      `).run(payload.amount, version, streamId);
    }
  }
}

3. 集約の状態再構築とコマンド処理

コマンド側がビジネスロジックの判断を下すために、読み取りプロジェクションをクエリしてはなりません。リードモデルは結果整合性(Eventual Consistency)であり、遅延が発生する可能性があるためです。代わりに、コマンドはイベントストアから直接履歴をロードし、メモリ上で内部状態を再構築した上で、不変条件(Invariant)の検証を行い、新たなイベントを生成します。

accountAggregate.jsに集約の境界(Aggregate Boundary)を実装します。

import { EventStore } from './eventEngine.js';

export class BankAccount {
  constructor(id) {
    this.id = id;
    this.balance = 0;
    this.version = 0;
    this.isOpened = false;
  }

  loadFromHistory() {
    const events = EventStore.getEvents(this.id);
    for (const evt of events) {
      this.apply(evt.type, evt.payload);
      this.version = evt.version;
    }
  }

  apply(type, payload) {
    switch (type) {
      case 'AccountOpened':
        this.balance = payload.initialBalance;
        this.isOpened = true;
        break;
      case 'MoneyDeposited':
        this.balance += payload.amount;
        break;
      case 'MoneyDebited':
        this.balance -= payload.amount;
        break;
    }
  }

  open(owner, initialBalance) {
    if (this.isOpened) throw new Error('口座は既に存在します。');
    if (initialBalance < 0) throw new Error('初回入金額を負の数にすることはできません。');
    EventStore.append(this.id, this.version, 'AccountOpened', { owner, initialBalance });
  }

  deposit(amount) {
    if (!this.isOpened) throw new Error('口座が存在しません。');
    if (amount <= 0) throw new Error('入金額は0より大きい必要があります。');
    EventStore.append(this.id, this.version, 'MoneyDeposited', { amount });
  }

  debit(amount) {
    if (!this.isOpened) throw new Error('口座が存在しません。');
    if (amount <= 0) throw new Error('無効な出金額です。');
    if (this.balance < amount) throw new Error(`残高不足です。現在の残高: ${this.balance}、要求額: ${amount}`);
    EventStore.append(this.id, this.version, 'MoneyDebited', { amount });
  }
}

4. CQRS APIの公開

server.jsでHTTPサーバーを構築し、書き込みコマンドと読み取りクエリのルートを明確に分離します。

import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from './database.js';
import { BankAccount } from './accountAggregate.js';

const app = express();
app.use(express.json());

// コマンド側(書き込み): ビジネスルールを検証しイベントを追加
app.post('/api/accounts', (req, res) => {
  try {
    const accountId = uuidv4();
    const account = new BankAccount(accountId);
    account.open(req.body.owner, Number(req.body.initialBalance) || 0);
    res.status(201).json({ accountId, message: '口座ストリームが確立されました。' });
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

app.post('/api/accounts/:id/debit', (req, res) => {
  try {
    const account = new BankAccount(req.params.id);
    account.loadFromHistory();
    account.debit(Number(req.body.amount));
    res.json({ message: '出金が正常に記録されました。' });
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// クエリ側(読み取り): 事前集計されたプロジェクションから直接読み取り
app.get('/api/accounts/:id', (req, res) => {
  const row = db.prepare('SELECT * FROM read_account_balances WHERE account_id = ?').get(req.params.id);
  if (!row) return res.status(404).json({ error: '口座が見つかりません。' });
  res.json(row);
});

app.listen(3000, () => {
  console.log('CQRS Nodeサービスがポート3000でリッスンしています');
});

動作確認とモニタリング

サーバーを起動し、実際のワークロードを想定してコマンド処理、状態の再構築、プロジェクションのリプレイを検証します。

node server.js

1. 機能検証

初期入金額200ドルで新規口座を開設します。

curl -s -X POST http://localhost:3000/api/accounts \
  -H "Content-Type: application/json" \
  -d '{"owner": "Alex M.", "initialBalance": 200}'

サービスから一意のストリームIDが返されます。

{"accountId":"7f3a8f90-1c05-4f2b-8a8b-1e240b3cb1a4","message":"口座ストリームが確立されました。"}

次に、そのストリームに対して75ドルの出金コマンドを発行します。

curl -s -X POST http://localhost:3000/api/accounts/7f3a8f90-1c05-4f2b-8a8b-1e240b3cb1a4/debit \
  -H "Content-Type: application/json" \
  -d '{"amount": 75}'

プロジェクションを取得し、計算された読み取り状態を確認します。

curl -s http://localhost:3000/api/accounts/7f3a8f90-1c05-4f2b-8a8b-1e240b3cb1a4

プロジェクションから、正しい残高とシーケンス番号が返されます。

{"account_id":"7f3a8f90-1c05-4f2b-8a8b-1e240b3cb1a4","owner":"Alex M.","balance":125,"last_version":2}

続いて、500ドルの出金を試みてドメイン境界のバリデーションをテストします。コマンドはaccountAggregate.js内で即座に失敗し、HTTP 400と{"error": "残高不足です。現在の残高: 125、要求額: 500"}が返されます。イベントは一切書き込まれず、リードプロジェクションはバージョン2のまま保持されます。

2. ディザスタリカバリ: プロジェクションの再構築(リプレイ)

リードモデルは使い捨て可能なキャッシュのようなものです。バグによってプロジェクションテーブルのデータが破損したり、スキーマ要件が変更されたりしても、データベースのバックアップから復元する必要はありません。プロジェクションを破棄し、未加工のイベントログをバージョン1から再生(リプレイ)するだけで済みます。

replay.jsに復旧用の運用ツールを作成します。

// replay.js
import db from './database.js';
import { Projector } from './eventEngine.js';

console.log('プロジェクションテーブルをクリア中...');
db.exec('DELETE FROM read_account_balances;');

const events = db.prepare('SELECT stream_id, event_type, payload, version FROM event_store ORDER BY id ASC').all();

console.time('Replay Duration');
for (const row of events) {
  Projector.handle({
    streamId: row.stream_id,
    type: row.event_type,
    payload: JSON.parse(row.payload),
    version: row.version
  });
}
console.timeEnd('Replay Duration');

console.log(`${events.length} 件のイベントをプロジェクションビューに正常に再構築しました。`);

スクリプトを実行して、リードモデルをゼロから再構築します。

node replay.js

これで、/api/accounts/:idをクエリすると完全に同一の状態が返されるようになります。ベンチマークテストでは、一般的なNVMeストレージ環境において、SQLiteは1秒あたり約45,000件のリプレイイベントを処理しました。

3. 本番運用のモニタリング指標

一般的なCPUやメモリ使用率の監視だけでは、イベントソーシング特有のボトルネックを把握できません。代わりに、以下の3つの運用メトリクスを監視してください。

  • プロジェクション遅延(Projection Lag): event_storeのcreated_atタイムスタンプと、リードビューへの書き込みタイムスタンプの差分を追跡します。KafkaやRabbitMQを使用した非同期システムでは、コンシューマーの遅延が500ミリ秒を超えるか、未処理メッセージが2,500件に達した際にアラートを発報するように設定します。
  • ストリームバージョンの連続性: バージョンが厳密に+1ずつ増加していることを検証します。シーケンスの飛び(バージョン2から直接バージョン4に飛ぶなど)が発生した場合、メッセージの欠落やパーティションコンシューマーの順序不整合を示しています。
  • 楽観的ロックの衝突率: ConcurrencyConflictExceptionの急増を監視します。高負荷時に偶発的な競合が発生することは想定内ですが、総書き込み量の2%を超える場合はホットパーティション(特定ストリームへの集中)の問題が疑われます。クライアント側でのエクスポネンシャルバックオフや、ロードバランサーでのストリームIDに基づくスティッキールーティングによって解決します。
Share: