背景と導入の理由
本番環境で稼働する多くの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に基づくスティッキールーティングによって解決します。

