[C#] How to Explicitly Control Transactions in Entity Framework Core

目次

Overview

In Entity Framework Core (EF Core), you may want to group multiple SaveChanges calls into a single atomic operation. By using Database.BeginTransactionAsync, you can ensure that if an error occurs mid-process, all changes are rolled back. This maintains data integrity.

Specifications (Input/Output)

  • Input: User registration info (name) and initial bonus points.
  • Output: The result of the transaction (committed on success, rolled back on failure).
  • Prerequisites: .NET 6.0 or higher. A relational database (like SQLite) is required. Note that the InMemory provider ignores transactions, so this example uses SQLite.

Basic Usage

Start a transaction with Database.BeginTransactionAsync(). If all processes succeed, call CommitAsync(). If an exception occurs, the transaction is automatically rolled back when the using block ends.

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

try
{
    // Process A (includes Save)
    await context.Users.AddAsync(user);
    await context.SaveChangesAsync();

    // Process B (includes Save)
    await context.Points.AddAsync(point);
    await context.SaveChangesAsync(); // If this fails, Process A is also undone

    // Commit if everything is successful
    await transaction.CommitAsync();
}
catch
{
    // Error handling (rollback happens automatically)
    Console.WriteLine("Changes were rolled back.");
}

Full Code Example

This scenario demonstrates writing to two tables—”Users” and “PointLogs”—as a single transaction. You will need the Microsoft.EntityFrameworkCore.Sqlite NuGet package.

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()
    {
        // Use SQLite in-memory mode
        var connectionString = "DataSource=:memory:";
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite(connectionString)
            .Options;

        // Setup Database
        using var context = new AppDbContext(options);
        await context.Database.OpenConnectionAsync(); // Required to keep in-memory SQLite alive
        await context.Database.EnsureCreatedAsync();

        var worker = new RegistrationWorker(context);

        // Case 1: Successful registration
        Console.WriteLine("--- Case 1: Normal Registration ---");
        await worker.RegisterUserWithBonusAsync("User_A", 500, forceError: false);
        worker.ShowResults();

        // Case 2: Error occurs (Verify Rollback)
        Console.WriteLine("\n--- Case 2: Error Occurs (Rollback Check) ---");
        await worker.RegisterUserWithBonusAsync("User_B", 1000, forceError: true);
        worker.ShowResults();
    }
}

// Business logic class
public class RegistrationWorker
{
    private readonly AppDbContext _context;

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

    public async Task RegisterUserWithBonusAsync(string userName, int bonusPoints, bool forceError)
    {
        // Start transaction
        // using ensures disposal and rollback if not committed
        using var transaction = await _context.Database.BeginTransactionAsync();

        try
        {
            Console.WriteLine($"Starting process for: {userName}");

            // 1. Register user information
            var newUser = new User { Name = userName, CreatedAt = DateTime.Now };
            _context.Users.Add(newUser);
            await _context.SaveChangesAsync();
            Console.WriteLine(" -> User table saved.");

            // (For testing: Force an error)
            if (forceError)
            {
                throw new Exception("A forced system error occurred!");
            }

            // 2. Register point history
            // Uses the user ID generated above
            var pointLog = new PointLog 
            { 
                UserId = newUser.Id, 
                Points = bonusPoints, 
                Note = "Welcome Bonus" 
            };
            _context.PointLogs.Add(pointLog);
            await _context.SaveChangesAsync();
            Console.WriteLine(" -> Point table saved.");

            // 3. Commit only if all steps succeed
            await transaction.CommitAsync();
            Console.WriteLine(" -> ★ Transaction committed.");
        }
        catch (Exception ex)
        {
            Console.WriteLine($" -> × Error caught: {ex.Message}");
            Console.WriteLine(" -> × The process will be rolled back.");
            // Explicit transaction.RollbackAsync() is optional because using handles it
        }
        
        // Clear tracker for display purposes
        _context.ChangeTracker.Clear();
    }

    public void ShowResults()
    {
        Console.WriteLine("[Current Database State]");
        foreach (var u in _context.Users)
        {
            Console.WriteLine($" - User: {u.Id}:{u.Name}");
        }
        if (!_context.Users.Any()) Console.WriteLine(" - No user data.");
    }
}

// Entity definitions
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 definition
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options) { }

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

Example Output

--- Case 1: Normal Registration ---
Starting process for: User_A
 -> User table saved.
 -> Point table saved.
 -> ★ Transaction committed.
[Current Database State]
 - User: 1:User_A

--- Case 2: Error Occurs (Rollback Check) ---
Starting process for: User_B
 -> User table saved.
 -> × Error caught: A forced system error occurred!
 -> × The process will be rolled back.
[Current Database State]
 - User: 1:User_A

In Case 2, although the log shows “User table saved,” the final database state does not contain User_B because the transaction was not committed.

Customization Points

  • Specify Isolation Level: You can specify the transaction isolation level as an argument in BeginTransactionAsync.C#// Example: Allow dirty reads await context.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadUncommitted);
  • Joining Existing Transactions: If you want to use an externally created DbTransaction with EF Core, use context.Database.UseTransaction(dbTxn).

Important Notes

  • Single SaveChanges: If you only call SaveChangesAsync once, EF Core automatically creates and commits a transaction internally. Use explicit BeginTransaction only when you need to group multiple SaveChangesAsync calls.
  • Use Async Methods: Always use BeginTransactionAsync and CommitAsync to avoid blocking threads.
  • InMemory Provider: The UseInMemoryDatabase provider does not support transactions. It will not cause an error, but it also will not perform rollbacks. Use UseSqlite for testing transactions.

Advanced Application

You can also use the standard .NET TransactionScope for broader control, such as managing multiple DbContext instances or other non-database resources.

using System.Transactions;

// TransactionScopeAsyncFlowOption.Enabled is required for async support
using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await context.Users.AddAsync(user);
    await context.SaveChangesAsync();

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

    // Call Complete to commit the transaction
    scope.Complete();
}

Conclusion

Leveraging the using syntax prevents accidental failures to roll back when exceptions occur.

Use BeginTransaction for operations within a single DbContext. Consider TransactionScope if you need wider control.

Database.BeginTransactionAsync is essential for maintaining consistency across multiple update processes.

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

この記事を書いた人

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

目次