How do you handle database transactions in Entity Framework Core?
Quick Answer
EF Core wraps each `SaveChanges` in an implicit transaction. For multi-operation atomicity you control transactions explicitly with `Database.BeginTransaction()`/`BeginTransactionAsync()` (commit/rollback), share a transaction across contexts/connections, or use `TransactionScope`. The execution strategy (retries) requires wrapping manual transactions in `Database.CreateExecutionStrategy().Execute(...)` to remain resilient.
Detailed Answer
Database transactions in Entity Framework Core ensure data consistency by grouping multiple operations into a single unit of work. If any operation fails, all changes are rolled back.
1. Automatic Transactions (Default Behavior)
EF Core automatically creates a transaction for each SaveChanges() call:
public class OrderService
{
private readonly AppDbContext _context;
public OrderService(AppDbContext context)
{
_context = context;
}
// Single SaveChanges() = single transaction
public async Task CreateOrderAsync(Order order)
{
_context.Orders.Add(order);
_context.OrderItems.AddRange(order.OrderItems);
// This creates and commits a transaction automatically
await _context.SaveChangesAsync();
}
}
2. Manual Transaction Management
Use BeginTransaction() for explicit transaction control:
public async Task TransferMoneyAsync(int fromAccountId, int toAccountId, decimal amount)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Withdraw from source account
var fromAccount = await _context.Accounts.FindAsync(fromAccountId);
if (fromAccount.Balance < amount)
throw new InsufficientFundsException();
fromAccount.Balance -= amount;
// Deposit to target account
var toAccount = await _context.Accounts.FindAsync(toAccountId);
toAccount.Balance += amount;
// Create transaction record
_context.Transactions.Add(new Transaction
{
FromAccountId = fromAccountId,
ToAccountId = toAccountId,
Amount = amount,
Timestamp = DateTime.UtcNow
});
// Save all changes
await _context.SaveChangesAsync();
// Commit transaction
await transaction.CommitAsync();
}
catch
{
// Rollback happens automatically when transaction is disposed
await transaction.RollbackAsync();
throw;
}
}
3. Transaction with Isolation Levels
Control transaction isolation for different consistency requirements:
public async Task ProcessOrderWithLockAsync(int orderId)
{
using var transaction = await _context.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted);
try
{
// Lock the order row for update
var order = await _context.Orders
.FromSqlRaw("SELECT * FROM Orders WITH (UPDLOCK) WHERE Id = {0}", orderId)
.FirstOrDefaultAsync();
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException("Order already processed");
order.Status = OrderStatus.Processing;
order.ProcessedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
4. Distributed Transactions (Multiple Contexts)
Handle transactions across multiple database contexts:
public async Task ProcessOrderAcrossDatabasesAsync(Order order)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Save to main database
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Save to audit database
using var auditContext = new AuditDbContext();
auditContext.Database.UseTransaction(transaction.GetDbTransaction());
auditContext.AuditLogs.Add(new AuditLog
{
EntityType = "Order",
EntityId = order.Id,
Action = "Created",
Timestamp = DateTime.UtcNow
});
await auditContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
5. Transaction Scope (System.Transactions)
Use TransactionScope for distributed transactions:
public async Task ProcessOrderWithTransactionScopeAsync(Order order)
{
using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
try
{
// Multiple operations that can span different databases
await _context.Orders.AddAsync(order);
await _context.SaveChangesAsync();
// Call external service
await _paymentService.ProcessPaymentAsync(order.PaymentInfo);
// Send notification
await _notificationService.SendOrderConfirmationAsync(order);
scope.Complete(); // Commit all operations
}
catch
{
// Automatic rollback when scope is disposed
throw;
}
}
6. Nested Transactions
Handle nested transaction scenarios:
public async Task ProcessBulkOrdersAsync(List<Order> orders)
{
using var outerTransaction = await _context.Database.BeginTransactionAsync();
try
{
foreach (var order in orders)
{
// Each order processing is a nested transaction
await ProcessSingleOrderAsync(order);
}
await outerTransaction.CommitAsync();
}
catch
{
await outerTransaction.RollbackAsync();
throw;
}
}
private async Task ProcessSingleOrderAsync(Order order)
{
using var innerTransaction = await _context.Database.BeginTransactionAsync();
try
{
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Additional processing
await UpdateInventoryAsync(order.OrderItems);
await innerTransaction.CommitAsync();
}
catch
{
await innerTransaction.RollbackAsync();
throw;
}
}
7. Transaction Best Practices
public class TransactionBestPractices
{
private readonly AppDbContext _context;
// ✅ Good: Use using statements for automatic disposal
public async Task GoodTransactionAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Your operations here
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
// ❌ Bad: Manual transaction management without proper cleanup
public async Task BadTransactionAsync()
{
var transaction = await _context.Database.BeginTransactionAsync();
try
{
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Missing: transaction.Dispose() - can cause connection leaks
}
// ✅ Good: Appropriate isolation level
public async Task ReadCommittedTransactionAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted);
// Operations that need to see committed data
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
// ✅ Good: Handle transaction timeouts
public async Task TransactionWithTimeoutAsync()
{
using var transaction = await _context.Database.BeginTransactionAsync();
transaction.GetDbTransaction().CommandTimeout = 30; // 30 seconds
try
{
// Long-running operations
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (SqlException ex) when (ex.Number == -2) // Timeout
{
await transaction.RollbackAsync();
throw new TimeoutException("Transaction timed out", ex);
}
}
}
8. Transaction Monitoring and Logging
public class TransactionMonitoring
{
private readonly ILogger<TransactionMonitoring> _logger;
public async Task MonitoredTransactionAsync()
{
var stopwatch = Stopwatch.StartNew();
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
_logger.LogInformation("Transaction started: {TransactionId}",
transaction.TransactionId);
// Your operations
await _context.SaveChangesAsync();
await transaction.CommitAsync();
stopwatch.Stop();
_logger.LogInformation("Transaction committed successfully in {Duration}ms",
stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
stopwatch.Stop();
_logger.LogError(ex, "Transaction rolled back after {Duration}ms",
stopwatch.ElapsedMilliseconds);
throw;
}
}
}
Key Points:
- Automatic Transactions: Each
SaveChanges()creates a transaction automatically - Manual Control: Use
BeginTransaction()for explicit transaction management - Isolation Levels: Control data consistency with different isolation levels
- Distributed Transactions: Handle transactions across multiple databases
- Error Handling: Always rollback on exceptions
- Resource Management: Use
usingstatements for automatic cleanup - Performance: Keep transactions short to avoid blocking
- Monitoring: Log transaction duration and outcomes
Best Practices:
- Keep transactions as short as possible
- Use appropriate isolation levels
- Always handle exceptions and rollback
- Use
usingstatements for automatic disposal - Monitor transaction performance
- Avoid long-running operations in transactions
- Consider using
TransactionScopefor distributed scenarios