How do you handle database transactions in Entity Framework Core?

5 minadvancedEF-Coretransactionsdata-consistency

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:

  1. Automatic Transactions: Each SaveChanges() creates a transaction automatically
  2. Manual Control: Use BeginTransaction() for explicit transaction management
  3. Isolation Levels: Control data consistency with different isolation levels
  4. Distributed Transactions: Handle transactions across multiple databases
  5. Error Handling: Always rollback on exceptions
  6. Resource Management: Use using statements for automatic cleanup
  7. Performance: Keep transactions short to avoid blocking
  8. Monitoring: Log transaction duration and outcomes

Best Practices:

  • Keep transactions as short as possible
  • Use appropriate isolation levels
  • Always handle exceptions and rollback
  • Use using statements for automatic disposal
  • Monitor transaction performance
  • Avoid long-running operations in transactions
  • Consider using TransactionScope for distributed scenarios

Related Resources