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