Your application works perfectly in development, but in production, you’re seeing partial data updates and mysterious consistency issues. The problem isn’t your business logic, it’s how you’re handling transactions.
EF Core gives you two ways to manage transactions: the automatic approach with SaveChanges, and explicit transaction control. Each has its place, but using the wrong one can cost you data integrity or performance.
How EF Core SaveChanges Handles Transactions
SaveChanges automatically wraps all your changes in a single database transaction. This means multiple entity operations either all succeed or all fail together:
// This entire operation is one transaction
using var context = new OrderContext();
var customer = new Customer { Name = "John Doe" };
var order = new Order { CustomerId = customer.Id, Total = 100.50m };
var orderItem = new OrderItem { OrderId = order.Id, ProductId = 1 };
context.Customers.Add(customer);
context.Orders.Add(order);
context.OrderItems.Add(orderItem);
await context.SaveChangesAsync(); // All or nothing
If any part fails, everything rolls back. Your database stays consistent without any extra code.
When SaveChanges Transactions Aren’t Enough
Multiple SaveChanges Calls
The automatic transaction only covers a single SaveChanges call. If your business logic requires multiple save operations, you need explicit control:
// Problem: Two separate transactions
public async Task ProcessOrderAsync(Order order)
{
// Transaction 1
context.Orders.Add(order);
await context.SaveChangesAsync();
// Transaction 2 - could fail, leaving orphaned order
await UpdateInventoryAsync(order.Items);
await context.SaveChangesAsync();
}
If the inventory update fails, you’re left with an order but no inventory adjustment. That’s a data consistency nightmare.
Cross-Service Operations
When your business logic spans multiple services or external APIs, SaveChanges can’t protect you:
// Dangerous: No transaction coordination
public async Task CompleteOrderAsync(int orderId)
{
var order = await context.Orders.FindAsync(orderId);
order.Status = OrderStatus.Completed;
await context.SaveChangesAsync(); // Committed to database
// If this fails, order is marked complete but payment wasn't processed
await paymentService.ProcessPaymentAsync(order.PaymentId);
}
The order gets marked as complete even if payment processing fails. Your business is now tracking completed orders that were never paid for.
Explicit Transaction Control
EF Core gives you full transaction control when you need it:
public async Task ProcessOrderWithExplicitTransaction(Order order)
{
using var transaction = await context.Database.BeginTransactionAsync();
try
{
// Add the order
context.Orders.Add(order);
await context.SaveChangesAsync();
// Update inventory
foreach (var item in order.Items)
{
var product = await context.Products.FindAsync(item.ProductId);
product.StockQuantity -= item.Quantity;
}
await context.SaveChangesAsync();
// Process external payment
var paymentResult = await paymentService.ProcessAsync(order.Total);
if (!paymentResult.Success)
{
throw new PaymentException(paymentResult.Error);
}
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
Now everything succeeds together or fails together, even across service boundaries.
Transaction Scope for Complex Operations
For operations spanning multiple contexts or services, use TransactionScope:
public async Task TransferOrderBetweenDatabases(int orderId, int targetTenantId)
{
using var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
try
{
// Source database
using var sourceContext = new OrderContext(sourceConnectionString);
var order = await sourceContext.Orders.FindAsync(orderId);
sourceContext.Orders.Remove(order);
await sourceContext.SaveChangesAsync();
// Target database
using var targetContext = new OrderContext(targetConnectionString);
targetContext.Orders.Add(order);
await targetContext.SaveChangesAsync();
scope.Complete();
}
catch
{
// Transaction automatically rolls back
throw;
}
}
This coordinates transactions across multiple database connections, ensuring data moves atomically between tenants.
Performance Considerations
Lock Duration
Explicit transactions hold database locks longer. Keep them as short as possible:
// Bad: Long-running transaction
using var transaction = await context.Database.BeginTransactionAsync();
var orders = await context.Orders.Where(o => o.Status == OrderStatus.Pending).ToListAsync();
foreach (var order in orders) // Could be thousands of orders
{
await ProcessSingleOrderAsync(order); // External API calls
}
await transaction.CommitAsync(); // Locks held for minutes
// Better: Batch processing with shorter transactions
var pendingOrderIds = await context.Orders
.Where(o => o.Status == OrderStatus.Pending)
.Select(o => o.Id)
.ToListAsync();
foreach (var batch in pendingOrderIds.Chunk(50))
{
using var transaction = await context.Database.BeginTransactionAsync();
foreach (var orderId in batch)
{
await ProcessSingleOrderAsync(orderId);
}
await transaction.CommitAsync(); // Shorter lock duration
}
Deadlock Prevention
SQL Server deadlocks happen when transactions access resources in different orders. Always access tables consistently:
// Deadlock-prone: Inconsistent table access order
public async Task TransferBetweenAccounts(int fromId, int toId, decimal amount)
{
using var transaction = await context.Database.BeginTransactionAsync();
var fromAccount = await context.Accounts.FindAsync(fromId);
var toAccount = await context.Accounts.FindAsync(toId);
fromAccount.Balance -= amount;
toAccount.Balance += amount;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
// Deadlock-resistant: Consistent ordering
public async Task TransferBetweenAccounts(int fromId, int toId, decimal amount)
{
using var transaction = await context.Database.BeginTransactionAsync();
// Always access accounts in ID order
var minId = Math.Min(fromId, toId);
var maxId = Math.Max(fromId, toId);
var accounts = await context.Accounts
.Where(a => a.Id == minId || a.Id == maxId)
.OrderBy(a => a.Id)
.ToListAsync();
var fromAccount = accounts.First(a => a.Id == fromId);
var toAccount = accounts.First(a => a.Id == toId);
fromAccount.Balance -= amount;
toAccount.Balance += amount;
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
Error Handling and Retry Logic
Database operations can fail due to deadlocks, timeouts, or connectivity issues. Implement proper retry logic:
public async Task<bool> ProcessOrderWithRetry(Order order, int maxRetries = 3)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
using var transaction = await context.Database.BeginTransactionAsync();
context.Orders.Add(order);
await context.SaveChangesAsync();
await UpdateInventoryAsync(order.Items);
await context.SaveChangesAsync();
await transaction.CommitAsync();
return true;
}
catch (SqlException ex) when (ex.Number == 1205) // Deadlock
{
if (attempt == maxRetries) throw;
// Exponential backoff
var delay = TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt - 1));
await Task.Delay(delay);
}
}
return false;
}
Pro Tip: I’ve seen production systems go down because developers didn’t handle SQL Server deadlocks. Always implement retry logic for transaction-heavy operations, especially during high-traffic periods.
Isolation Levels and Performance
Different isolation levels offer different consistency guarantees and performance characteristics:
// For read-heavy scenarios with acceptable dirty reads
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.ReadUncommitted);
// For balance between consistency and performance
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);
// For strict consistency requirements
using var transaction = await context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
Read Uncommitted gives best performance but allows dirty reads. Serializable prevents all anomalies but can cause significant blocking.
When to Use Each Approach
Use SaveChanges automatic transactions when:
- Single SaveChanges call handles all changes
- No external service coordination needed
- Simple CRUD operations
- Maximum simplicity is preferred
Use explicit transactions when:
- Multiple SaveChanges calls needed
- Coordinating with external services
- Complex business logic with rollback scenarios
- Fine-grained control over isolation levels
- Handling potential deadlocks with retry logic
Key Takeaway
EF Core’s automatic transactions with SaveChanges work great for simple scenarios, but production applications often need explicit transaction control for proper consistency guarantees.
The key is understanding when your business logic outgrows automatic transactions and making the switch before data consistency issues appear in production. Your future self will thank you for the investment in proper transaction handling.