Entity Framework Core - Transactions & Resiliency
The combination of Transactions and Retry Logic (Connection Resiliency) is one of the most complex areas in EF Core. If you blindly enable retries (EnableRetryOnFailure), standard transaction code will crash.
Here is the comprehensive documentation and code for handling retries correctly using Execution Strategies.
The Problem: Why do we need this?
When you enable automatic retries (e.g., for Azure SQL), EF Core can automatically retry failed read/write commands. However, transactions are stateful.
If a transient error (network blip) occurs inside a transaction:
The database rolls back the transaction entirely.
EF Core cannot simply "resume" the transaction from line 3; it must restart the entire logic block from the beginning.
EF Core blocks you from creating a transaction inside a retryable context unless you explicitly verify you are handling the restart logic.
1. Configuration: Enabling Resiliency
First, ensure your DbContext is configured to retry on transient failures.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
"YourConnectionString",
sqlOptions =>
{
// Enable automatic retries (e.g., max 5 retries, max 30 sec delay)
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});
}
2. The Implementation: "Bank Transfer" Example
This example handles a money transfer. This is the Gold Standard pattern. It handles:
Transaction creation
Automatic retries via Execution Strategy
Idempotency (Preventing double-charging if a retry happens)
public async Task TransferFundsAsync(int fromAccountId, int toAccountId, decimal amount)
{
using var context = new BankingContext();
// 1. Get the Execution Strategy (The Retry Logic Manager)
var strategy = context.Database.CreateExecutionStrategy();
// 2. Execute the entire logic inside the strategy
await strategy.ExecuteAsync(async () =>
{
// EVERYTHING inside this block is retried if a failure occurs.
// Therefore, we must start the transaction INSIDE this block.
using var transaction = await context.Database.BeginTransactionAsync();
try
{
// --- CRITICAL CORNER CASE: IDEMPOTENCY ---
// If the transaction fails AFTER 'SaveChanges' but BEFORE 'Commit' is confirmed,
// the retry logic runs this block again.
// We must ensure we don't deduct the money twice.
var fromAccount = await context.Accounts.FindAsync(fromAccountId);
// Check if we already processed this transfer in a previous (failed) attempt
// Example: We might check a 'TransferLog' or the account balance logic
// (For this example, we assume purely transactional logic, but in real apps
// you often query a 'ProcessedTransactionId' table here).
var toAccount = await context.Accounts.FindAsync(toAccountId);
// Business Logic
if (fromAccount.Balance < amount)
throw new InvalidOperationException("Insufficient funds");
fromAccount.Balance -= amount;
toAccount.Balance += amount;
// Save Changes (Updates rows in memory/DB transaction log)
await context.SaveChangesAsync();
// Commit Transaction
await transaction.CommitAsync();
}
catch (Exception)
{
// Rollback is automatic on Dispose if Commit isn't called,
// but calling it explicitly is safe.
await transaction.RollbackAsync();
// We MUST throw the exception so the Execution Strategy knows
// something failed and can decide whether to Retry or abort.
throw;
}
});
}
3. Detailed Corner Cases of Retry
Here is exactly what happens during different failure points in the code above.
Case A: Failure during Find or Calculation (Before SaveChanges)
What happens: Connection drops while fetching
fromAccount.Behavior: The
strategycatches the exception. It waits (backoff) and restarts the lambda function from the top.Result: A new transaction is started. Data is fetched again. Safe.
Case B: Failure during SaveChanges
What happens: You send the
UPDATESQL, but the DB is momentarily locked or unavailable.Behavior:
SaveChangesthrows a transient exception. Thestrategycatches it, rolls back the uncommitted transaction, and restarts the lambda.Result: Logic runs again. Safe.
Case C: The "Phantom Commit" (Failure during CommitAsync)
The Scenario: This is the most dangerous case.
Your code calls
CommitAsync().The Database successfully commits the data.
BUT, the network connection drops before the Database sends the "Success" acknowledgement back to your C# application.
Your application thinks the Commit failed.
The Behavior: The
strategysees a failure. It retries the block.The Danger: It deducts the money again (Double Charge).
The Fix (Idempotency):
To fix Case C, you must include a mechanism to track if the operation actually finished.
Revised Idempotency Pattern:
await strategy.ExecuteAsync(async () =>
{
using var transaction = await context.Database.BeginTransactionAsync();
// Generate a unique ID for this specific transfer request (outside the retry loop ideally)
var transferId = Guid.Parse("...");
// CORNER CASE FIX: Check if this transfer already exists
var existingTransfer = await context.TransferLogs
.AnyAsync(x => x.RequestId == transferId);
if (existingTransfer)
{
// It was already committed in a previous attempt that "appeared" to fail.
// Do nothing, just return.
return;
}
// ... Perform Debit/Credit Logic ...
// Add a log record so next retry knows we did it
context.TransferLogs.Add(new TransferLog { RequestId = transferId, ... });
await context.SaveChangesAsync();
await transaction.CommitAsync();
});
Summary of Best Practices for Resiliency
Always use
CreateExecutionStrategy()whenEnableRetryOnFailureis on and you need explicit transactions.Make the block Idempotent. Assume the code block might run 2 or 3 times. If it runs twice, the result in the database must remain the same as if it ran once.
Do not capture state. Do not assume variables outside the
.ExecuteAsyncblock are current. Query the DB fresh inside the block.

