🐈

NestJSトランザクション処理:Query Runner vs Transaction Function 完全ガイド

に公開

はじめに:なぜトランザクション処理方法を知る必要があるのか?

バックエンド開発をしていると、「ユーザー作成と同時にプロフィールも作成する必要があるが、途中で失敗したらどうしよう?」という悩みに直面することがあります。このような時に必要なのがトランザクションです。

NestJSでは主に2つの方法でトランザクションを処理できますが、それぞれの特性を正しく理解していないと、以下のような問題に遭遇する可能性があります:

  • 不必要に複雑なコードによるメンテナンスコストの増加
  • トランザクション漏れによるデータベースパフォーマンスの低下
  • 予期しないデータ不整合問題
  • チームメンバー間のコードスタイルの不一致

この記事では、各方法の長所と短所を明確にし、状況に応じた正しい選択方法を紹介したいと思います。

トランザクションとは?基本概念の再確認

トランザクションはデータベース操作の論理的単位で、**「すべての操作が成功するか、すべて失敗する」**原子性(Atomicity)を保証します。

例えば、オンラインショッピングモールで注文を処理する際:

  1. 在庫の減算
  2. 決済処理
  3. 注文情報の保存
  4. 配送情報の生成

これらすべての過程が一つのトランザクションとして結び付けられる必要があります。もし3番で失敗した場合、1番と2番もすべてキャンセルされなければなりません。

NestJSでトランザクションを処理する2つの方法

NestJSとTypeORMを使用する際のトランザクション処理方法は大きく2つあります:

1. Query Runner

  • 直接制御方式:開発者がトランザクションの開始、コミット、ロールバックを直接管理
  • 細かい制御:複雑なビジネスロジックや外部API呼び出しが含まれる場合に有用
  • より多くのコード:トランザクション管理のためのボイラープレートコードが必要

2. Transaction Function

  • 自動管理方式:フレームワークがトランザクションを自動的に管理
  • 簡潔なコードmanager.transaction()または@Transaction()デコレータを使用
  • 安全性:うっかりトランザクションを閉じ忘れるリスクが少ない

各方法はそれぞれ異なる長所と短所を持っており、状況に応じて適切な選択が必要です。それでは、それぞれを詳しく見ていきましょう。

Query Runner:細かい制御が必要な時

Query Runnerとは?

Query RunnerはTypeORMで提供される低レベルAPIで、トランザクションの開始から終了まで開発者が直接制御できる方法です。

Query Runner の例

@Injectable()
export class OrderService {
  constructor(private dataSource: DataSource) {}

  async createOrderWithPayment(orderData: CreateOrderDto) {
    const queryRunner = this.dataSource.createQueryRunner();
    
    await queryRunner.connect();
    await queryRunner.startTransaction();
    
    try {
      // 1. 在庫確認と減算
      const product = await queryRunner.manager.findOne(Product, {
        where: { id: orderData.productId }
      });
      
      if (product.stock < orderData.quantity) {
        throw new Error('在庫不足');
      }
      
      product.stock -= orderData.quantity;
      await queryRunner.manager.save(product);
      
      // 2. 注文作成
      const order = await queryRunner.manager.save(Order, orderData);
      
      // 3. 外部決済API呼び出し
      const paymentResult = await this.paymentService.process(orderData.payment);
      
      if (!paymentResult.success) {
        throw new Error('決済失敗');
      }
      
      // 4. 注文ステータス更新
      order.status = 'PAID';
      await queryRunner.manager.save(order);
      
      await queryRunner.commitTransaction();
      return order;
      
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release(); // 重要!
    }
  }
}

Query Runnerの長所と注意点

長所:

  • 複雑なビジネスロジックでの細かい制御が可能
  • 外部API呼び出しと一緒にトランザクション処理
  • パフォーマンス最適化のための柔軟な構造

注意点:

  • finallyブロックで必ずrelease()を呼び出す必要がある
  • コードが複雑になり、反復的なパターンが発生

Transaction Function:すっきりと効率的な処理

Transaction Functionとは?

NestJSで提供される@Transaction()デコレータやmanager.transaction()メソッドを使用する方法で、トランザクション管理をフレームワークに委任します。

2つの方法の違い

manager.transaction()

  • 関数型アプローチ
  • トランザクション範囲が明確に見える
  • コールバック関数内でのみトランザクションが維持される
  • 一般的により推奨される方式

@Transaction()デコレータ

  • メソッド全体がトランザクションとして処理
  • より簡潔なコード作成が可能
  • 依存性注入でEntityManagerを渡す
  • メソッド単位でトランザクション管理

Transaction Function の例

@Injectable()
export class UserService {
  constructor(private dataSource: DataSource) {}

  // 方法1:manager.transaction()使用
  async createUserWithProfile(userData: CreateUserDto) {
    return await this.dataSource.transaction(async manager => {
      // 1. ユーザー作成
      const user = await manager.save(User, {
        email: userData.email,
        password: userData.password
      });
      
      // 2. プロフィール作成
      const profile = await manager.save(Profile, {
        userId: user.id,
        nickname: userData.nickname
      });
      
      return { user, profile };
    });
  }

  // 方法2:@Transaction()デコレータ使用
  @Transaction()
  async updateUserPoints(
    userId: number, 
    points: number,
    @TransactionManager() manager?: EntityManager
  ) {
    const user = await manager.findOne(User, { where: { id: userId } });
    user.points += points;
    
    // ポイント履歴記録
    await manager.save(PointHistory, {
      userId: user.id,
      points: points,
      type: points > 0 ? 'EARN' : 'USE'
    });
    
    return await manager.save(user);
  }
}

Transaction Functionの長所と短所

長所:

  • 簡潔で読みやすいコード
  • 自動ロールバック/コミットでミスを防止
  • フレームワークがリソース管理

短所:

  • 条件付きコミット/ロールバックが困難
  • 複雑なビジネスロジックには制約

注意事項

@Transaction()デコレータ使用時:

  • メソッド呼び出しごとに新しいトランザクションが開始される
  • 他のメソッドから呼び出しても別々のトランザクションとして処理
  • ネストしたトランザクションが必要な場合はmanager.transaction()使用を推奨

manager.transaction()使用時:

  • コールバック関数内でのみトランザクション維持
  • 明示的な範囲により、より予測可能な動作
  • 複雑なビジネスロジックでより安全

実務での選択基準:いつ何を使うべきか?

Query Runnerを選択すべき時

// 外部システム統合が必要な場合
async processComplexPayment(orderId: number) {
  const queryRunner = this.dataSource.createQueryRunner();
  
  try {
    await queryRunner.connect();
    await queryRunner.startTransaction();
    
    // DB作業
    const order = await queryRunner.manager.findOne(Order, { where: { id: orderId } });
    
    // 外部API呼び出し
    const paymentResult = await this.externalPaymentAPI.charge(order.amount);
    
    if (paymentResult.requiresVerification) {
      // 条件付き処理が必要な場合
      order.status = 'VERIFICATION_REQUIRED';
      await queryRunner.manager.save(order);
      await queryRunner.commitTransaction();
      return { status: 'pending' };
    }
    
    order.status = 'COMPLETED';
    await queryRunner.manager.save(order);
    await queryRunner.commitTransaction();
    
  } catch (error) {
    await queryRunner.rollbackTransaction();
    throw error;
  } finally {
    await queryRunner.release();
  }
}

Transaction Functionを選択すべき時

// 一般的なCRUD作業
async transferPoints(fromUserId: number, toUserId: number, amount: number) {
  return await this.dataSource.transaction(async manager => {
    // 送信者のポイント減算
    const fromUser = await manager.findOne(User, { where: { id: fromUserId } });
    fromUser.points -= amount;
    await manager.save(fromUser);
    
    // 受信者のポイント増加
    const toUser = await manager.findOne(User, { where: { id: toUserId } });
    toUser.points += amount;
    await manager.save(toUser);
    
    // 取引履歴記録
    await manager.save(Transaction, {
      fromUserId,
      toUserId,
      amount,
      type: 'TRANSFER'
    });
    
    return { success: true };
  });
}

まとめ:正しい選択のためのチェックリスト

トランザクション処理方法を選択する際は、以下の質問を考慮してみてください:

  1. 外部システムとの統合が必要か? → Query Runner
  2. トランザクション途中で条件付きロジックが必要か? → Query Runner
  3. 大量のデータをバッチで処理するか? → Query Runner
  4. シンプルなCRUD作業か? → Transaction Function
  5. コードの可読性と保守性が重要か? → Transaction Function

結論

NestJSでトランザクションを処理する方法は状況に応じて選択する必要があります。Transaction Functionはほとんどの一般的な状況で十分であり、コードをすっきりと保つことができます。Query Runnerはより複雑な要件がある時に強力なツールとなります。

重要なのは各方法の特性を理解し、プロジェクトの要件に合った適切な選択をすることです。どちらか一方が無条件に推奨されるパターンとなって、その内容だけを使用すべきというより、状況に合ったパターンを使用して柔軟に対応することが正しいと考えます。

Discussion