🔒

AIがトランザクション境界を破るので、ガードレールで強制した

に公開

はじめに

前回の記事で「横のガードレール」の基本を紹介しました。レイヤー境界、OpenAPI整合性、Result<T>強制。どれも単一集約の話でした。

今回は 複数集約をまたぐ操作 の話をします。

Claude Code に「注文作成のユースケースを追加して」と頼むと、こんなコードが返ってきます。

async function createOrder(cartId: string) {
  // 注文を作成
  await orderRepository.save(new Order(cartId));
  // 在庫を引き当て
  await inventoryRepository.reserve(cartId);
  // 購入者に通知
  await notificationService.sendOrderConfirmation(cartId);
}

一見よさそうに見えますが、2行目で失敗した瞬間に整合性が壊れます。

注文だけ作成されて、在庫が引き当てられない。 二重販売や欠品のリスクです。

このパターンでトランザクション境界がないと、途中で失敗した瞬間にデータ不整合が起きます。「本番で起きたら致命的」なので、UoW導入を決めました。

トランザクション境界とは

複数のデータ操作を「全部成功」か「全部失敗」にする境界線です。

// ❌ トランザクション境界がない
await repoA.save(entityA);  // 成功
await repoB.save(entityB);  // 失敗 → entityAだけ残る

// ✅ トランザクション境界がある
await transaction(async () => {
  await repoA.save(entityA);  // 成功
  await repoB.save(entityB);  // 失敗 → 両方ロールバック
});

AIはこの境界を意識しません。「動くコード」は書けても「壊れないコード」は書けない。

クリーンアーキテクチャだと起きやすい

シンプルな3層アーキテクチャなら、サービス層が直接DBコンテキストを持ち、@Transactional 一発で済むことが多いです。

クリーンアーキテクチャでは話が違います。リポジトリはインターフェース(domain層)と実装(infrastructure層)に分離され、ユースケース層は実装詳細を知りません。各リポジトリ呼び出しが独立し、トランザクションを自然に共有しない。だからUoWパターンが必要になります。

Unit of Work(UoW)パターン

トランザクション境界を明示するパターンとして、UoWを採用しています。

interface OrderUnitOfWork {
  execute<T>(handler: (context: OrderContext) => Promise<T>): Promise<T>;
}

interface OrderContext {
  orderRepository: OrderRepository;
  inventoryRepository: InventoryRepository;  // 関連集約もコンテキストに含む
}

ユースケースはこう書きます。

async createOrder(dto: CreateOrderRequest) {
  return this.unitOfWork.execute(async (context) => {
    // この中はすべて同一トランザクション
    const order = Order.create(dto);
    await context.orderRepository.save(order);
    await context.inventoryRepository.reserve(dto.items);
    return order;
  });
}

execute の中は全部成功するか、全部失敗するか。AIが途中で処理を追加しても、トランザクション境界は守られます。

Result<T>とUoWの連携

第1回で「業務エラーだけをResultで返す」と書きました。

  • 業務エラー(重複、状態遷移違反など) → Result.fail() で返却
  • インフラエラー(DB障害など) → 例外としてスロー

この分類をどう決めるかはプロダクト次第です。重要なのは、決めたルールがSSOT(単一の情報源)となるよう型で強制すること。AIがエラーハンドリングで迷うことがなくなります。

ESLintでは守れない理由

「UoWを使え」とドキュメントに書いても、AIは読み飛ばします。レビューで毎回指摘するのも限界がある。

ESLintで「executeを使っていなければエラー」は書けますが、executeの中身が本当にトランザクションを張っているかは検査できません。

// ❌ 形だけのUoW(pass-through)
class FakeUnitOfWork implements OrderUnitOfWork {
  async execute<T>(handler: (context) => Promise<T>): Promise<T> {
    // トランザクションなしでそのまま実行
    return handler(this.context);
  }
}

これがすり抜けると、「UoWを使っているのに注文だけ残る」という最悪のパターンになります。

ガードレール実装

これらのガードレールは正規表現とAST解析(TypeScript Compiler API)を使い分けています。ファイル単位の存在チェックは正規表現で、スコープの判定が必要な場合はAST解析で。完璧ではなく、誤検知や取りこぼしはあり得ます。初手で「90%以上を自動で検出できる」なら十分です。取りこぼしが見つかったら、それが次のガードレール追加のきっかけになる。

例1: UoWのトランザクション境界検査

UoWの execute 内に runInTransactionScope があるかを検査します。

/**
 * @what infrastructure/unit-of-work 実装がトランザクション境界を持つか検査
 * @why UoWがpass-throughだと一貫性とリトライ安全性が担保できないため
 * @failure execute内にrunInTransactionScopeがない場合に非0終了
 */

async function findPassThroughUoW(root: string) {
  const files = await glob('server/src/infrastructure/unit-of-work/*.ts');
  const violations = [];

  for (const file of files) {
    const source = await readFile(file, 'utf8');

    // executeメソッドがあるか
    if (!/execute\s*<[^>]*>\s*\(/.test(source)) continue;

    // トランザクション境界の痕跡があるか
    const hasTransaction =
      /runInTransactionScope/.test(source) ||
      /withTransaction/.test(source);

    if (!hasTransaction) {
      violations.push({ file, message: 'execute does not establish transaction boundary' });
    }
  }

  return violations;
}

実行すると、違反があればこう出ます。

❌ Transaction boundary missing in UoW:
 - server/src/infrastructure/unit-of-work/RelationalOrderUnitOfWork.ts:
   execute does not establish transaction boundary

Wrap repository mutations in transaction-aware execute.

例2: 複数書き込みのトランザクション強制

これが核心のガードレールです。 冒頭の「注文だけ残る」問題を直接防ぎます。

ユースケース内で 書き込み操作(save, delete等)が2つ以上 あるのに、トランザクション境界がなければREDにします。

/**
 * @what 複数の書き込みを行うUseCaseがトランザクションで保護されているか検査
 * @why 複数書き込みを非トランザクションで行うと部分的コミットが発生するため
 * @failure 書き込み>=2 かつトランザクションなし(免除タグなし)のメソッドを検出した場合に非0終了
 */

const WRITE_METHODS = new Set([
  'save', 'delete', 'bulkInsert', 'bulkUpdate', 'upsert', ...
]);

const TRANSACTION_METHODS = new Set([
  'runInTransactionScope', 'withTransaction', 'executeInUnitOfWork', ...
]);

function analyzeFunction(node, sourceFile, sourceText) {
  let writes = 0;
  let hasTransaction = false;
  const isExempt = hasExemptAnnotation(node, sourceText);  // @transaction-exempt

  // 再帰的にASTを走査
  function visit(child) {
    // ネストした関数定義は別スコープなのでスキップ
    if (isFunctionLike(child) && child !== node) return;

    if (ts.isCallExpression(child)) {
      if (isTransactionCall(child)) hasTransaction = true;
      if (isWriteCall(child)) writes += 1;
    }
    ts.forEachChild(child, visit);
  }

  ts.forEachChild(node, visit);
  return { writes, hasTransaction, isExempt, isViolation: writes >= 2 && !hasTransaction && !isExempt };
}

実行すると、違反があればこう出ます。

❌ Multi-write operations detected outside transaction boundaries:
 - src/usecase/services/order/OrderCommandService.ts:42:3
   createOrder (writes=2)

runInTransactionScope でまとめるか、@transaction-exempt コメントで例外理由を明記してください。

複数リポジトリかどうかではなく、書き込みが複数あるかどうかで判定するのがポイントです。

例3: 複数集約+外部呼び出しのSaga要求

複数のリポジトリを使い、かつ外部呼び出し(イベント発行、API呼び出し等)を含むユースケースには、Saga/補償の設計痕跡を要求します。

/**
 * @what 複数リポジトリ+外部呼び出しにSaga/UoWマーカーがあるか検査
 * @why 集約をまたぐ外部呼び出しは二重送信や整合性崩壊のリスクがあるため
 * @failure Saga/compensation/UnitOfWork記述がない場合に非0終了
 */

const EXTERNAL_PATTERNS = [
  /eventBus\.publish/i,
  /httpClient/i,
  /gateway/i,
];

const SAGA_HINT_PATTERNS = [
  /Saga/i,
  /compensation/i,
  /UnitOfWork/i,
];

async function findViolations(root: string) {
  const files = await glob('server/src/usecase/services/**/*.ts');

  for (const file of files) {
    const source = await readFile(file, 'utf8');

    // 複数リポジトリを使っているか
    const repoCount = (source.match(/\b\w+Repository\b/g) ?? []).length;
    if (repoCount < 2) continue;

    // 外部呼び出しがあるか
    if (!EXTERNAL_PATTERNS.some(p => p.test(source))) continue;

    // Saga/UoWの痕跡があるか
    if (SAGA_HINT_PATTERNS.some(p => p.test(source))) continue;

    violations.push({ file, reason: `${repoCount} repos + external calls without Saga markers` });
  }
}

このガードレールが面白いのは、「Sagaを実装しろ」ではなく「Sagaを意識しろ」 と要求している点です。// Saga: 補償はイベント経由で実行 というコメントでも通る。設計判断の痕跡があればOK。

なぜSagaではなくUoW+Outboxか

「複数集約をまたぐならSagaでしょ」と思うかもしれません。

確かにオーケストレーター型のSagaは強力です。でも、単一DBで外部連携も限られている段階では過剰でした。

手法 複雑さ 向いているケース
UoW(同一DB) 複数集約が同一DBにある場合
Outbox + イベント 結果整合性で許容できる場合
オーケストレーター型Saga 複数サービス・補償ロジックが必要な場合

この時点では単一DBなので、UoWで十分。集約間の連携はOutboxパターンでイベントを永続化し、別プロセスで配送しています。

Outboxパターンの流れ

  1. トランザクション内で「集約の永続化」と「Outboxテーブルへの書き込み」を同時に行う
  2. 別プロセスがOutboxを監視し、イベントを外部に配送する
// Outboxパターン: イベント永続化もトランザクション内
await unitOfWork.execute(async (context) => {
  await context.orderRepository.save(order);
  // eventBus.publish は「Outboxテーブルへの INSERT」を行う
  // 実際の外部送信は別プロセスが担当
  await context.outboxEventBus.publish([new OrderCreatedEvent(order)]);
});

ポイントは、outboxEventBus.publishイベントを送信するのではなく、Outboxテーブルに書き込むこと。同一トランザクション内なので、集約の永続化とイベントの記録は必ず一緒に成功/失敗します。「注文は作成されたが通知イベントは飛ばなかった」が防げます。

このパターンもガードレールで強制しています。

/**
 * @what eventBus.publishがトランザクション内か検査
 * @why 非トランザクションで送出すると永続化とイベント配送の整合性が壊れるため
 * @failure UnitOfWork.execute/runInTransactionScopeの痕跡がない場合に警告
 */

const EVENT_PATTERN = /(eventBus\.publish|pullDomainEvents\s*\()/;
const TRANSACTION_PATTERN = /(UnitOfWork\.execute|runInTransactionScope)/;

async function findViolations(root: string) {
  const files = await glob('server/src/usecase/services/application/**/*.ts');

  for (const file of files) {
    const source = await readFile(file, 'utf8');
    if (!EVENT_PATTERN.test(source)) continue;
    if (TRANSACTION_PATTERN.test(source)) continue;
    violations.push(file);
  }
}

Sagaが必要になるフェーズ

UoW+Outboxで対応できるのは、同一DB内で完結し、外部連携が非同期で許容されるケースです。

Sagaを検討するのは以下の場合。

  • 外部サービスへの同期呼び出しで補償が必要(決済→返金など)
  • 複数サービス間の整合性が必要(マイクロサービス化した場合)
状況 選択
同一DB、外部連携なし UoWのみ
同一DB、外部連携あり(非同期OK) UoW + Outbox
外部連携あり(同期・補償必要) Saga検討
複数サービス間の整合性 Saga必須

Sagaを導入したら、ガードレールも進化します。「補償メソッドがあるか」「冪等性マーカーがあるか」を検査するガードレールが追加される。これも「共進化」の一部です。

今のシステムが「同一DB、外部連携あり(非同期OK)」なら、UoW+Outboxで十分。決済連携や複数サービス化が見えてきたら、Sagaを検討してください。

成長ストーリー

最初からこの設計ではありませんでした。

ガードレールは「最初から完成形を設計する」ものではありません。問題が起きるたびに追加し、コードベースと一緒に成長させる。立法が行政を律し、行政の失敗が立法を育てる。これがnoteで記事化している「共進化」です。

つまりアーキテクチャが進化すれば、ガードレールも進化します。単一DBからマイクロサービス化へ、同期処理から非同期API化へ。フェーズが変われば、あるべき立法の姿も変わる。今日のUoW検査が、明日はSagaの補償検査になるかもしれない。

try-catch地獄の時代

async createOrder(dto) {
  try {
    await orderRepository.save(order);
    try {
      await inventoryRepository.reserve(...);
    } catch (e) {
      // ロールバック...どうやって?
      await orderRepository.delete(order.id);
    }
  } catch (e) {
    // ...
  }
}

手動ロールバック。注文・在庫・通知の3つをまたいだ時点で破綻が訪れます。

UoWを導入したが、浸透しない

UoWパターンを導入。でも「UoWを使ってね」とドキュメントに書いても、AIは無視。新しいユースケースが追加されるたびに「あ、これもUoW使ってない」とレビューで指摘する日々。

ガードレールで強制する

UoWの存在をガードレールで強制。さらに「UoWがpass-throughじゃないか」も検査。

第1回で書いたのと同じ話ですが、AIは新しいセッションを開くたびに同じ間違いをします。でもガードレールがあれば、prepush で RED。pushできない。AIがエラーを見て「UnitOfWorkを使います」と修正する。

私が毎回教えなくても、ガードレールが教えてくれる。 コンテキストが空でも品質が維持される。それが自律駆動の本質です。

まとめ

「注文だけ作成されて、在庫が引き当てられない」。この恐怖を二度と味わいたくないなら、ガードレールで強制するしかありません。

  • AIはトランザクション境界を意識しない。動くコードと壊れないコードは違う
  • UoWパターンでトランザクション境界を明示する
  • ESLintでは「UoWがpass-throughでないか」は検査できない
  • ガードレールで強制すれば、AIが書くコードでも「注文だけ残る」は起きない
  • Sagaは過剰な場合が多い。UoW+Outboxで十分なケースを見極める

次回は「ガードレールが機能すると何が起きるか」を書く予定です。AIを監視しなくても品質が維持される世界の話。


思想的背景

この実装の思想的背景は note で書いています。

Discussion