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
InMemoryprovider 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
DbTransactionwith EF Core, usecontext.Database.UseTransaction(dbTxn).
Important Notes
- Single SaveChanges: If you only call
SaveChangesAsynconce, EF Core automatically creates and commits a transaction internally. Use explicitBeginTransactiononly when you need to group multipleSaveChangesAsynccalls. - Use Async Methods: Always use
BeginTransactionAsyncandCommitAsyncto avoid blocking threads. - InMemory Provider: The
UseInMemoryDatabaseprovider does not support transactions. It will not cause an error, but it also will not perform rollbacks. UseUseSqlitefor 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.
