概要
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)を使用します。
注意点
- 単一Save時の不要論:
SaveChangesAsyncを1回だけ呼ぶ場合、EF Core は内部で自動的にトランザクションを作成・コミットします。明示的なBeginTransactionは「複数のSaveChangesAsyncをまとめる場合」にのみ使用してください。 - 非同期の統一:
BeginTransactionではなくBeginTransactionAsync、CommitではなくCommitAsyncを使用し、スレッドブロックを避けてください。 - 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 構文を活用することで、例外発生時のロールバック漏れを防ぐことができます。
