【C#】EF Coreでトランザクションを明示的に制御する方法

目次

概要

Entity Framework Core (EF Core) において、複数の SaveChanges 呼び出しをひとつの不可分な処理(アトミックな操作)としてまとめ上げる方法です。 Database.BeginTransactionAsync を使用することで、途中でエラーが発生した場合に全ての変更をロールバックし、データの整合性を保つことができます。

仕様(入出力)

  • 入力: ユーザー登録情報(名前)と初期付与ポイント数。
  • 出力: トランザクション処理の結果(成功時はコミット、失敗時はロールバックされた旨)。
  • 前提: .NET 6.0以上。リレーショナルデータベース(SQLiteなど)が必要です。
    • ※InMemoryプロバイダはトランザクションを無視するため、本コードではSQLiteを使用します。

基本の使い方

Database.BeginTransactionAsync() でトランザクションを開始し、処理が全て成功したら CommitAsync() を呼び出します。例外が発生した場合は、using ブロックを抜ける際に自動的にロールバック(破棄)されます。

using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // 処理A (Save含む)
    await context.Users.AddAsync(user);
    await context.SaveChangesAsync();

    // 処理B (Save含む)
    await context.Points.AddAsync(point);
    await context.SaveChangesAsync(); // ここで失敗しても処理Aは取り消される

    // 全て成功したらコミット
    await transaction.CommitAsync();
}
catch
{
    // エラーログ出力など(ロールバックは自動)
    Console.WriteLine("ロールバックしました");
}

コード全文

ここでは「ユーザー登録」と「入会特典ポイントの付与」という2つのテーブルへの書き込みを、1つのトランザクションとして実行するシナリオです。 NuGetパッケージ Microsoft.EntityFrameworkCore.Sqlite が必要です。

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;

public class Program
{
    public static async Task Main()
    {
        // SQLiteを利用(ファイルとして保存せずオンメモリモードで動作させる設定)
        var connectionString = "DataSource=:memory:";
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(connectionString)
            .Options;

        // DB準備
        using var context = new AppDbContext(options);
        await context.Database.OpenConnectionAsync(); // In-Memory SQLiteは接続維持が必要
        await context.Database.EnsureCreatedAsync();

        var worker = new RegistrationWorker(context);

        // 正常系のテスト
        Console.WriteLine("--- ケース1: 正常登録 ---");
        await worker.RegisterUserWithBonusAsync("User_A", 500, forceError: false);
        worker.ShowResults();

        // 異常系のテスト(2つ目の処理でエラー発生)
        Console.WriteLine("\n--- ケース2: エラー発生(ロールバック確認) ---");
        await worker.RegisterUserWithBonusAsync("User_B", 1000, forceError: true);
        worker.ShowResults();
    }
}

// 業務ロジッククラス
public class RegistrationWorker
{
    private readonly AppDbContext _context;

    public RegistrationWorker(AppDbContext context)
    {
        _context = context;
    }

    public async Task RegisterUserWithBonusAsync(string userName, int bonusPoints, bool forceError)
    {
        // トランザクション開始
        // IDbContextTransaction は IDisposable なので using で破棄(=ロールバック)を保証する
        using var transaction = await _context.Database.BeginTransactionAsync();

        try
        {
            Console.WriteLine($"処理開始: {userName}");

            // 1. ユーザー情報の登録
            var newUser = new User { Name = userName, CreatedAt = DateTime.Now };
            _context.Users.Add(newUser);
            await _context.SaveChangesAsync();
            Console.WriteLine(" -> ユーザーテーブル保存完了");

            // (テスト用: 強制エラー発生)
            if (forceError)
            {
                throw new Exception("強制的なシステムエラーが発生しました!");
            }

            // 2. ポイント履歴の登録
            // 先ほどのユーザーIDを使用
            var pointLog = new PointLog 
            { 
                UserId = newUser.Id, 
                Points = bonusPoints, 
                Note = "入会特典" 
            };
            _context.PointLogs.Add(pointLog);
            await _context.SaveChangesAsync();
            Console.WriteLine(" -> ポイントテーブル保存完了");

            // 3. 全て成功した場合のみコミット(確定)
            await transaction.CommitAsync();
            Console.WriteLine(" -> ★トランザクションをコミットしました");
        }
        catch (Exception ex)
        {
            Console.WriteLine($" -> ×エラー捕捉: {ex.Message}");
            Console.WriteLine(" -> ×処理はロールバックされます");
            // 明示的な transaction.RollbackAsync() は不要(usingを抜ける際に実行されるため)
        }
        
        // 状態をクリア(表示用)
        _context.ChangeTracker.Clear();
    }

    public void ShowResults()
    {
        Console.WriteLine("[現在のDB状態]");
        foreach (var u in _context.Users)
        {
            Console.WriteLine($" - User: {u.Id}:{u.Name}");
        }
        if (!_context.Users.Any()) Console.WriteLine(" - ユーザーデータなし");
    }
}

// エンティティ定義
public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

public class PointLog
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public int Points { get; set; }
    public string Note { get; set; } = string.Empty;
}

// DbContext定義
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

    public DbSet<User> Users => Set<User>();
    public DbSet<PointLog> PointLogs => Set<PointLog>();
}

実行結果例

--- ケース1: 正常登録 ---
処理開始: User_A
 -> ユーザーテーブル保存完了
 -> ポイントテーブル保存完了
 -> ★トランザクションをコミットしました
[現在のDB状態]
 - User: 1:User_A

--- ケース2: エラー発生(ロールバック確認) ---
処理開始: User_B
 -> ユーザーテーブル保存完了
 -> ×エラー捕捉: 強制的なシステムエラーが発生しました!
 -> ×処理はロールバックされます
[現在のDB状態]
 - User: 1:User_A

※ケース2では「ユーザーテーブル保存完了」まで進んでいますが、コミットされなかったため、最終的なDB状態には User_B が存在しません(正しくロールバックされています)。

カスタムポイント

  • 分離レベルの指定: BeginTransactionAsync の引数でトランザクション分離レベルを指定できます。C#// ダーティリードを許可する場合など await context.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadUncommitted);
  • 既存トランザクションへの参加: 外部で生成した DbTransaction を EF Core で利用したい場合は context.Database.UseTransaction(dbTxn) を使用します。

注意点

  1. 単一Save時の不要論: SaveChangesAsync を1回だけ呼ぶ場合、EF Core は内部で自動的にトランザクションを作成・コミットします。明示的な BeginTransaction は「複数の SaveChangesAsync をまとめる場合」にのみ使用してください。
  2. 非同期の統一: BeginTransaction ではなく BeginTransactionAsyncCommit ではなく CommitAsync を使用し、スレッドブロックを避けてください。
  3. InMemoryプロバイダ: テストでよく使われる UseInMemoryDatabase はトランザクションをサポートしていません(エラーにはなりませんが、ロールバックもされません)。トランザクションのテストには UseSqlite(SQLite)などを使用してください。

応用

TransactionScope (.NET標準機能) を使用するパターンです。複数のDbContextや、DB以外のリソースも巻き込んで制御する場合に使用されます。

using System.Transactions;

// TransactionScopeAsyncFlowOption.Enabled が必須(非同期対応のため)
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await context.Users.AddAsync(user);
    await context.SaveChangesAsync();

    await context.Logs.AddAsync(log);
    await context.SaveChangesAsync();

    // 完了通知(これを呼ばないとDispose時にロールバックされる)
    scope.Complete();
}

まとめ

EF Core の機能だけで完結する場合は BeginTransaction、より広範囲な制御が必要な場合は TransactionScope を検討してください。

複数の更新処理(SaveChangesAsync)の整合性を保つには Database.BeginTransactionAsync が不可欠です。

using 構文を活用することで、例外発生時のロールバック漏れを防ぐことができます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次